Compare commits

...

49 Commits

Author SHA1 Message Date
Konnor Rogers
c094c1e95b prettier 2023-06-12 09:41:32 -04:00
Konnor Rogers
24c9922251 add a semicolon 2023-06-12 09:41:32 -04:00
Konnor Rogers
b7a0820e1f remove duplication 2023-06-12 09:41:32 -04:00
Cory LaViska
5646f6992d update changelog 2023-06-12 09:41:32 -04:00
Yuki Nishijima
b2f47885e2 Update the tutorial for Rails (#1258)
* Update the tutorial for Rails

* Update docs/tutorials/integrating-with-rails.md

Co-authored-by: Konnor Rogers <konnor5456@gmail.com>

* Update docs/tutorials/integrating-with-rails.md

Co-authored-by: Konnor Rogers <konnor5456@gmail.com>

* Update docs/tutorials/integrating-with-rails.md

Co-authored-by: Konnor Rogers <konnor5456@gmail.com>

* Update docs/tutorials/integrating-with-rails.md

Co-authored-by: Konnor Rogers <konnor5456@gmail.com>

* Update docs/tutorials/integrating-with-rails.md

Co-authored-by: Konnor Rogers <konnor5456@gmail.com>

* Update docs/tutorials/integrating-with-rails.md

* Add a bit more explanation to set up icons

---------

Co-authored-by: Konnor Rogers <konnor5456@gmail.com>
2023-06-12 09:41:32 -04:00
Cory LaViska
39d4d7480d update changelog 2023-06-12 09:41:32 -04:00
dhellgartner
f54cd3f8f0 Split panel tests (#1343)
* Added tests for sl-split-panel

test for horizontal arrangement

* Added tests for sl-split-panel

tests for vertical arrangement

---------

Co-authored-by: stefanie.hellgartner <stefanie.hellgartner@in-tech.com>
2023-06-12 09:41:32 -04:00
Cory LaViska
ecdf3dff6d reflect size; fixes #1348 2023-06-12 09:41:32 -04:00
Cory LaViska
a10ee8b70f update form control guidelines 2023-06-12 09:41:32 -04:00
ErikOnBike
fb899ad676 Replace .map with .forEach when return value not used. See issue #740 (#1349) 2023-06-12 09:41:32 -04:00
Bernhard
7390ac37bf update angular getting started (#1352) 2023-06-12 09:41:32 -04:00
Cory LaViska
2e2cee4cd6 Remove outdated line from docs 2023-06-12 09:41:32 -04:00
Cory LaViska
38a865ef9c update changelog 2023-06-12 09:41:32 -04:00
Stefan Bauer
55a2f29800 Update de.ts (#1339)
"Gleiten" is a verb and means glide, "Slide" must be translated with "Folie" (like in previousSlide, nextSlide)
2023-06-12 09:41:32 -04:00
Yuki Nishijima
5e3c793974 Add better support for Turbo Drive (#1338) 2023-06-12 09:41:32 -04:00
Erick Almeida
05214a2887 Fix Portuguese translation strings (#1336) 2023-06-12 09:41:32 -04:00
Konnor Rogers
8317cb38e4 tests: add regression tests for checkbox and toggle focus behavior (#1330)
* add regression test for checkbox focusing

* change number of checkboxes / switches

* change max-height to 400px so it fails

* re-add positon: relative;
2023-06-12 09:41:32 -04:00
Cory LaViska
ca539055eb add submenu-icon part 2023-06-12 09:41:32 -04:00
Cory LaViska
5d55d0592e update changelog; #1310 2023-06-12 09:41:32 -04:00
Matt Pharoah
92ff4fc950 Fixed clipPath 2023-06-12 09:41:32 -04:00
Matt Pharoah
12e9ee836f Improve performance of sl-rating by not render all icons twice 2023-06-12 09:41:32 -04:00
Cory LaViska
6b51d4a8a0 update changelog 2023-06-12 09:41:32 -04:00
Cory LaViska
e3e09499a2 reorder properties 2023-06-12 09:41:32 -04:00
Konnor Rogers
b4badae867 feat: support variable height elements inside <sl-button> 2023-06-12 09:41:32 -04:00
Cory LaViska
d93f775106 add checkbox + exported parts; #1318 2023-06-12 09:41:32 -04:00
Cory LaViska
37e5a6eae4 update radio size when group size changes 2023-06-12 09:41:32 -04:00
Cory LaViska
c0d449de03 update docs; fixes #1315 2023-06-12 09:41:32 -04:00
Cory LaViska
543ce8997f 2.4.0 2023-06-12 09:41:32 -04:00
Cory LaViska
abc89ef983 bump version 2023-06-12 09:41:32 -04:00
Cory LaViska
c1bfaed14f fixes #1302 2023-06-12 09:41:32 -04:00
Cory LaViska
38754d96cb add size to radio group; fixes #1301 2023-06-12 09:41:32 -04:00
Cory LaViska
85558b0e42 spell check and reorder static function 2023-06-12 09:41:32 -04:00
Matt Pharoah
0c5f14abc6 Improve performance of sl-icon by caching later (#1286)
* Improve performance of sl-icon by caching later

* Fixed error handling

* Don't use requestInclude in sl-icon

* Separate sl-icon errors into cacheable and retryable errors
2023-06-12 09:41:32 -04:00
Cory LaViska
6359367706 no roles on slots; fixes #1287 2023-06-12 09:41:32 -04:00
Cory LaViska
25a7a9428d revert role and don't use <header> for buttons 2023-06-12 09:41:32 -04:00
Cory LaViska
e01341fec5 fix typos 2023-06-12 09:41:32 -04:00
dhellgartner
57788ff92a Slot aria attributes (#1296)
* Fix acessability issue

* Additionally adapted the test

* Added more accessability tests

* Updated the testing documentation

to take the fact that accessability checks cover only
rendered content into account

---------

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-06-12 09:41:32 -04:00
Cory LaViska
a915fdd463 less pipeline flakes 🤞🏻 2023-06-12 09:41:32 -04:00
Cory LaViska
8f1de40825 update changelog 2023-06-12 09:41:32 -04:00
Alessandro
7947c00605 fix(carousel): clickable elements don't work on chrome (#1266)
* fix(carousel): clickable elements don't work on chrome

* fix: update implementation
2023-06-12 09:41:32 -04:00
Cory LaViska
4a2ef8315c don't show hover when focused; fixes #1282 2023-06-12 09:41:32 -04:00
Cory LaViska
405da0d135 update changelog 2023-06-12 09:41:32 -04:00
Cory LaViska
475d690751 wait until registered to set initial state; fixes #1292 2023-06-12 09:41:32 -04:00
Konnor Rogers
b24a148c3c prettier 2023-04-16 13:47:24 -04:00
Konnor Rogers
c61d38b0c9 shim for tests 2023-04-16 11:00:47 -04:00
Konnor Rogers
8ba3f07bd2 generate a CDN path' 2023-04-16 00:39:29 -04:00
Konnor Rogers
205a82333d prettier 2023-04-10 18:59:24 -04:00
Konnor Rogers
3af145c00c import path 2023-04-10 18:41:45 -04:00
Konnor Rogers
7c0e9dad8c build script for NPM deployments 2023-04-10 18:36:45 -04:00
41 changed files with 1152 additions and 405 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ docs/search.json
dist
node_modules
src/react
cdn/

View File

@@ -15,6 +15,7 @@
"autoplay",
"bezier",
"boxicons",
"CACHEABLE",
"callout",
"callouts",
"chatbubble",
@@ -112,6 +113,7 @@
"resizer",
"resizers",
"retargeted",
"RETRYABLE",
"rgba",
"roadmap",
"Roboto",

View File

@@ -2,7 +2,7 @@
[component-header:sl-menu]
You can use [menu items](/components/menu-item), [menu labels](/components/menu-label), and [dividers](/components/divider) to compose a menu. Menus support keyboard interactions, including type-to-select an option.
You can use [menu items](/components/menu-item), [menu labels](/components/menu-label), and [dividers](/components/divider) to compose a menu.
```html preview
<sl-menu style="max-width: 200px;">

View File

@@ -78,29 +78,29 @@ const App = () => (
### Sizes
Use the `size` attribute to change a radio button's size.
Add the `size` attribute to the [Radio Group](/components/radio-group) to change the size of the radio buttons.
```html preview
<sl-radio-group label="Select an option" name="a" value="1">
<sl-radio-button size="small" value="1">Option 1</sl-radio-button>
<sl-radio-button size="small" value="2">Option 2</sl-radio-button>
<sl-radio-button size="small" value="3">Option 3</sl-radio-button>
<sl-radio-group label="Select an option" size="small" value="1">
<sl-radio-button value="1">Option 1</sl-radio-button>
<sl-radio-button value="2">Option 2</sl-radio-button>
<sl-radio-button value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option" name="a" value="1">
<sl-radio-button size="medium" value="1">Option 1</sl-radio-button>
<sl-radio-button size="medium" value="2">Option 2</sl-radio-button>
<sl-radio-button size="medium" value="3">Option 3</sl-radio-button>
<sl-radio-group label="Select an option" size="medium" value="1">
<sl-radio-button value="1">Option 1</sl-radio-button>
<sl-radio-button value="2">Option 2</sl-radio-button>
<sl-radio-button value="3">Option 3</sl-radio-button>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option" name="a" value="1">
<sl-radio-button size="large" value="1">Option 1</sl-radio-button>
<sl-radio-button size="large" value="2">Option 2</sl-radio-button>
<sl-radio-button size="large" value="3">Option 3</sl-radio-button>
<sl-radio-group label="Select an option" size="large" value="1">
<sl-radio-button value="1">Option 1</sl-radio-button>
<sl-radio-button value="2">Option 2</sl-radio-button>
<sl-radio-button value="3">Option 3</sl-radio-button>
</sl-radio-group>
```
@@ -108,27 +108,29 @@ Use the `size` attribute to change a radio button's size.
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option" name="a" value="1">
<SlRadioButton size="small" value="1">Option 1</SlRadioButton>
<SlRadioButton size="small" value="2">Option 2</SlRadioButton>
<SlRadioButton size="small" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<>
<SlRadioGroup label="Select an option" size="small" value="1">
<SlRadioButton value="1">Option 1</SlRadioButton>
<SlRadioButton value="2">Option 2</SlRadioButton>
<SlRadioButton value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<br />
<SlRadioGroup label="Select an option" name="a" value="1">
<SlRadioButton size="medium" value="1">Option 1</SlRadioButton>
<SlRadioButton size="medium" value="2">Option 2</SlRadioButton>
<SlRadioButton size="medium" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<SlRadioGroup label="Select an option" size="medium" value="1">
<SlRadioButton value="1">Option 1</SlRadioButton>
<SlRadioButton value="2">Option 2</SlRadioButton>
<SlRadioButton value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<br />
<br />
<SlRadioGroup label="Select an option" name="a" value="1">
<SlRadioButton size="large" value="1">Option 1</SlRadioButton>
<SlRadioButton size="large" value="2">Option 2</SlRadioButton>
<SlRadioButton size="large" value="3">Option 3</SlRadioButton>
</SlRadioGroup>
<SlRadioGroup label="Select an option" size="large" value="1">
<SlRadioButton value="1">Option 1</SlRadioButton>
<SlRadioButton value="2">Option 2</SlRadioButton>
<SlRadioButton value="3">Option 3</SlRadioButton>
</SlRadioGroup>
</>
);
```

View File

@@ -98,6 +98,53 @@ const App = () => (
);
```
### Sizing Options
The size of [Radios](/components/radio) and [Radio Buttons](/components/radio-buttons) will be determined by the Radio Group's `size` attribute.
```html preview
<sl-radio-group label="Select an option" size="medium" value="medium" class="radio-group-size">
<sl-radio value="small">Small</sl-radio>
<sl-radio value="medium">Medium</sl-radio>
<sl-radio value="large">Large</sl-radio>
</sl-radio-group>
<script>
const radioGroup = document.querySelector('.radio-group-size');
radioGroup.addEventListener('sl-change', () => {
radioGroup.size = radioGroup.value;
});
</script>
```
```jsx react
import { useState } from 'react';
import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => {
const [size, setSize] = useState('medium');
return (
<>
<SlRadioGroup
label="Select an option"
size={size}
value={size}
class="radio-group-size"
onSlChange={event => setSize(event.target.value)}
>
<SlRadio value="small">Small</SlRadio>
<SlRadio value="medium">Medium</SlRadio>
<SlRadio value="large">Large</SlRadio>
</SlRadioGroup>
</>
);
};
```
?> [Radios](/components/radio) and [Radio Buttons](/components/radio-button) also have a `size` attribute. This can be useful in certain compositions, but it will be ignored when used inside of a Radio Group.
### Validation
Setting the `required` attribute to make selecting an option mandatory. If a value has not been selected, it will prevent the form from submitting and display an error message.

View File

@@ -80,12 +80,30 @@ const App = () => (
## Sizes
Use the `size` attribute to change a radio's size.
Add the `size` attribute to the [Radio Group](/components/radio-group) to change the size of the radios.
```html preview
<sl-radio size="small">Small</sl-radio>
<sl-radio size="medium">Medium</sl-radio>
<sl-radio size="large">Large</sl-radio>
<sl-radio-group label="Select an option" size="small" value="1">
<sl-radio value="1">Small 1</sl-radio>
<sl-radio value="2">Small 2</sl-radio>
<sl-radio value="3">Small 3</sl-radio>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option" size="medium" value="1">
<sl-radio value="1">Medium 1</sl-radio>
<sl-radio value="2">Medium 2</sl-radio>
<sl-radio value="3">Medium 3</sl-radio>
</sl-radio-group>
<br />
<sl-radio-group label="Select an option" size="large" value="1">
<sl-radio value="1">Large 1</sl-radio>
<sl-radio value="2">Large 2</sl-radio>
<sl-radio value="3">Large 3</sl-radio>
</sl-radio-group>
```
```jsx react
@@ -93,11 +111,27 @@ import { SlRadio } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlRadio size="small">Small</SlRadio>
<SlRadioGroup size="small" value="1">
<SlRadio value="1">Small 1</SlRadio>
<SlRadio value="2">Small 2</SlRadio>
<SlRadio value="3">Small 3</SlRadio>
</SlRadioGroup>
<br />
<SlRadio size="medium">Medium</SlRadio>
<SlRadioGroup size="medium" value="1">
<SlRadio value="1">Medium 1</SlRadio>
<SlRadio value="2">Medium 2</SlRadio>
<SlRadio value="3">Medium 3</SlRadio>
</SlRadioGroup>
<br />
<SlRadio size="large">Large</SlRadio>
<SlRadioGroup size="large" value="1">
<SlRadio value="1">Large 1</SlRadio>
<SlRadio value="2">Large 2</SlRadio>
<SlRadio value="3">Large 3</SlRadio>
</SlRadioGroup>
</>
);
```

View File

@@ -10,10 +10,30 @@ To add Shoelace to your Angular app, install the package from npm.
npm install @shoelace-style/shoelace
```
Next, [include a theme](/getting-started/themes) and set the [base path](/getting-started/installation#setting-the-base-path) for icons and other assets. In this example, we'll import the light theme and use the CDN as a base path.
Next, [include a theme](/getting-started/themes) into your project. In this example, we'll import the light theme and use the CDN as a base path.
Include the theme into your `angular.json` config.
```json
...
"styles": [
"src/app/theme/styles.scss",
"@shoelace-style/shoelace/dist/themes/light.css"
],
...
```
OR
include it into your `styles.scss`.
```scss
@use "@shoelace-style/shoelace/dist/themes/light.css";
...
```
After that set the [base path](/getting-started/installation#setting-the-base-path) in your `AppModule` for icons and other assets.
```jsx
import '@shoelace-style/shoelace/dist/themes/light.css';
import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path';
setBasePath('https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/');
@@ -31,6 +51,10 @@ import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { AppComponent } from './app.component';
import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path';
setBasePath('https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/');
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
@@ -48,7 +72,8 @@ import { SlDrawer } from '@shoelace-style/shoelace';
@Component({
selector: 'app-drawer-example',
template: '<div id="page"><button (click)="showDrawer()">Show drawer</button><sl-drawer #drawer label="Drawer" class="drawer-focus" style="--size: 50vw"><p>Drawer content</p></sl-drawer></div>'
template: '<div id="page"><button (click)="showDrawer()">Show drawer</button><sl-drawer #drawer label="Drawer" class="drawer-focus" style="--size: 50vw"><p>Drawer content</p></sl-drawer></div>',
providers: [SlDrawer]
})
export class DrawerExampleComponent implements OnInit {

View File

@@ -8,17 +8,38 @@ New versions of Shoelace are released as-needed and generally occur when a criti
## Next
- Added the `checkbox` part and related exported parts to `<sl-tree-item>` so you can target it with CSS [#1318](https://github.com/shoelace-style/shoelace/discussions/1318)
- Added the `submenu-icon` part to `<sl-menu-item>` (submenus have not been implemented yet, but this part is required to allow customizations)
- Added tests for `<sl-split-panel>` [#1343](https://github.com/shoelace-style/shoelace/pull/1343)
- Fixed a bug where changing the size of `<sl-radio-group>` wouldn't update the size of child elements
- Fixed a bug in `<sl-select>` and `<sl-color-picker>` where the `size` attribute wasn't being reflected [#1318](https://github.com/shoelace-style/shoelace/issues/1348)
- Improved `<sl-button>` so it can accept children of variable heights [#1317](https://github.com/shoelace-style/shoelace/pull/1317)
- Improved the docs to more clearly explain sizing radios and radio buttons
- Improved the performance of `<sl-rating>` by partially rendering unseen icons [#1310](https://github.com/shoelace-style/shoelace/pull/1310)
- Improved the Portuguese translation [#1336](https://github.com/shoelace-style/shoelace/pull/1336)
- Improved the German translation [#1339](https://github.com/shoelace-style/shoelace/pull/1339)
- Improved the autoloader so it watches `<html>` instead of `<body>` since the latter gets replaced by some frameworks [#1338](https://github.com/shoelace-style/shoelace/pull/1338)
- Improved the Rails documentation [#1258](https://github.com/shoelace-style/shoelace/pull/1258)
## 2.4.0
- Added the `discover()` function to the experimental autoloader's exports [#1236](https://github.com/shoelace-style/shoelace/pull/1236)
- Added more tests for `<sl-animated-image>` [#1246](https://github.com/shoelace-style/shoelace/pull/1246)
- Added the `size` attribute to `<sl-radio-group>` so labels and controls will be sized consistently [#1301](https://github.com/shoelace-style/shoelace/issues/1301)
- Added tests for `<sl-animated-image>` [#1246](https://github.com/shoelace-style/shoelace/pull/1246)
- Added tests for `<sl-animation>` [#1274](https://github.com/shoelace-style/shoelace/pull/1274)
- Fixed a bug in `<sl-tree-item>` that prevented long labels from wrapping [#1243](https://github.com/shoelace-style/shoelace/issues/1243)
- Fixed a bug in `<sl-tree-item>` that caused labels to be misaligned when text wraps [#1244](https://github.com/shoelace-style/shoelace/issues/1244)
- Fixed an incorrect CSS property value in `<sl-checkbox>` [#1272](https://github.com/shoelace-style/shoelace/pull/1272)
- Fixed a bug in `<sl-avatar>` that caused the initials to show up behind images with transparency [#1260](https://github.com/shoelace-style/shoelace/pull/1260)
- Fixed a bug in `<sl-split-panel>` that prevented the divider from being focusable in some browsers [#1288](https://github.com/shoelace-style/shoelace/issues/1288)
- Fixed a bug that caused `<sl-tab-group>` to affect scrolling when initializing [#1292](https://github.com/shoelace-style/shoelace/issues/1292)
- Fixed a bug in `<sl-menu-item>` that allowed the hover state to show when focused [#1282](https://github.com/shoelace-style/shoelace/issues/1282)
- Fixed a bug in `<sl-carousel>` that prevented interactive elements from receiving clicks [#1262](https://github.com/shoelace-style/shoelace/issues/1262)
- Fixed a bug in `<sl-input>` that caused `valueAsDate` and `valueAsNumber` to not be set synchronously in some cases [#1302](https://github.com/shoelace-style/shoelace/issues/1302)
- Improved the behavior of `<sl-carousel>` when used inside a flex container [#1235](https://github.com/shoelace-style/shoelace/pull/1235)
- Improved the behavior of `<sl-tree-item>` to support buttons and other interactive elements [#1234](https://github.com/shoelace-style/shoelace/issues/1234)
- Improved the performance of `<sl-include>` to prevent an apparent memory leak in some browsers [#1284](https://github.com/shoelace-style/shoelace/pull/1284)
- Improved the accessibility of `<sl-select>`, `<sl-split-panel>`, and `<sl-details>` by ensuring slots don't have roles [#1287](https://github.com/shoelace-style/shoelace/issues/1287)
## 2.3.0

View File

@@ -345,12 +345,10 @@ For non-dependencies, _the user_ should decide what gets registered, even if it
### Form Controls
Form controls should support submission and validation through the following conventions:
Form controls should support submission and validation through the following conventions by implementing the `ShoelaceFormControl` interface:
- All form controls must use `name`, `value`, and `disabled` properties in the same manner as `HTMLInputElement`
- All form controls must have a `setCustomValidity()` method so the user can set a custom validation message
- All form controls must have a `reportValidity()` method that report their validity during form submission
- All form controls must have an `invalid` property that reflects their validity
- All form controls must use `name`, `value`, `disabled`, etc. in the same manner as `HTMLInputElement`
- All form controls must implement `checkValidity()`, `reportValidity()`, `setCustomValidity()`, etc. for validation
- All form controls should mirror their native validation attributes such as `required`, `pattern`, `minlength`, `maxlength`, etc. when possible
- All form controls must be tested to work with the standard `<form>` element
@@ -369,7 +367,7 @@ This will render the icons instantly whereas the default library will fetch them
What to test for a given component:
- Start with a simple test that checks that the default version of the component still renders.
- Add at least one accessibility test:
- Add at least one accessibility test (The accessibility check only covers the parts of the DOM which are currently visible and rendered. Depending on the component, more than one accessibility test is required to cover all scenarios.):
```ts
const myComponent = await fixture<SlAlert>(html`<sl-my-component>SomeContent</sl-my-component>`);

View File

@@ -10,92 +10,148 @@ This integration has been tested with the following:
- Rails >= 6
- Node >= 12.10
- Webpacker >= 5
- Yarn >= 1.22
## Instructions
To get started using Shoelace with Rails, the following packages must be installed.
When using Shoelace, there are mostly three things that need to be served to the client browser:
- Javascript files for the Web Components
- CSS files for light and dark themes (they can co-exist, but one of them is required)
- Shoelace Icons
Depending on the JS bundler you are using, you may need to do some additional configuration. However, the basic steps
should be just about the same. Also, it is recommended to read to the [Bundling section in the Installation](/getting-started/installation?id=bundling)
to understand how Shoelace can be set up with a JS bundler in general. In this tutorial, we will assume that your Rails app
is already set up with a JS bundler that supports importing CSS files directly (e.g. Turbopack, esbuild, Vite).
To get started using Shoelace with Rails, the following package must be installed.
```bash
yarn add @shoelace-style/shoelace copy-webpack-plugin
yarn add @shoelace-style/shoelace
```
### Importing the Default Theme
This is required regardless of the JS bundler you are using.
The next step is to import Shoelace's default theme (stylesheet) in `app/javascript/stylesheets/application.scss`.
### Javascript & CSS
The next step is to import the JavaScript files and default theme for Shoelace. Add the following code to your
entrypoint JS file (generally `application.js`).
```js
// application.js
import '@shoelace-style/shoelace';
// You can also add these two if the JS bundler of your choice supports importing CSS files.
import '@shoelace-style/shoelace/dist/themes/light.css';
import '@shoelace-style/shoelace/dist/themes/dark.css'; // Optional dark mode
```
!> In this example, all Shoelace components are imported for simplicity. However, importing directly from
`@shoelace-style/shoelace` may result in a larger bundle size than necessary. Consider importing only the components
you actually need in the actual application.
You can also import the CSS inside of a `.css` file if you prefer to maintain a separate CSS entrypoint.
Such as with CssBundling-Rails.
```css
@import '@shoelace-style/shoelace/dist/themes/light';
@import '@shoelace-style/shoelace/dist/themes/dark'; // Optional dark theme
// application.css
@import '@shoelace-style/shoelace/dist/themes/light.css';
```
Fore more details about themes, please refer to [Theme Basics](/getting-started/themes?id=theme-basics).
For more details about themes, please refer to [Theme Basics](/getting-started/themes?id=theme-basics).
### Importing Required Scripts
### Serving up Shoelace Icons
After importing the theme, you'll need to import the JavaScript files for Shoelace. Add the following code to `app/javascript/packs/application.js`.
#### Using the `shoelace-rails` gem
```js
import '../stylesheets/application.scss'
import { setBasePath, SlAlert, SlAnimation, SlButton, ... } from '@shoelace-style/shoelace'
You do not have to do anything else if you are using [the `shoelace-rails` gem](https://github.com/yuki24/shoelace-rails).
Here are how it works in different environments:
// ...
- In development and test, the icons are served by the `ActionDispatch::Static` middleware, directly from the
`node_modules/@shoelace-style/shoelace/dist/assets/icons` directory.
- In production, the icon files are automatically copied into the `public/assets` directory as part of the
`assets:precompile` rake task.
const rootUrl = document.currentScript.src.replace(/\/packs.*$/, '')
#### Copying Icon files with a Rake task
// Path to the assets folder (should be independent from the current script source path
// to work correctly in different environments)
setBasePath(rootUrl + '/packs/js/')
```
If you are not using the `shoelace-rails` gem, you can manually copy the icon files to the `public/assets` directory.
One way to do this is to use a rake task and add it as a dependency to the `assets:precompile` task. Most rails
deployment processes run the `rake assets:precompile` task as of part deply, which means that the icon files will be
copied automatically.
### webpack Config
```ruby
# Rakefile
namespace :shoelace do
namespace :icons do
desc "Copy Shoelace icons to the assets path"
task copy: :environment do
cp_r "node_modules/@shoelace-style/shoelace/dist/assets", Rails.public_path
end
end
end
Next we need to add Shoelace's assets to the final build output. To do this, modify `config/webpack/environment.js` to look like this.
```js
const { environment } = require('@rails/webpacker');
// Shoelace config
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
// Add shoelace assets to webpack's build process
environment.plugins.append(
'CopyPlugin',
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, '../../node_modules/@shoelace-style/shoelace/dist/assets'),
to: path.resolve(__dirname, '../../public/packs/js/assets')
}
]
})
);
module.exports = environment;
```
### Adding Pack Tags
The final step is to add the corresponding `pack_tags` to the page. You should have the following `tags` in the `<head>` section of `app/views/layouts/application.html.erb`.
```html
<!DOCTYPE html>
<html>
<head>
<!-- ... -->
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> <%= javascript_pack_tag
'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<%= yield %>
</body>
</html>
Rake::Task["assets:precompile"].enhance(["shoelace:icons:copy"])
```
Now you can start using Shoelace components with Rails!
## Using Rails View Helpers
[the `shoelace-rails` gem](https://github.com/yuki24/shoelace-rails) is a community-maintained library that provides useful Rake tasks and Rails view helpers for
Shoelace components. In order to use it, add the gem by running the following command:
```bash
bundle add shoelace-rails
```
Once it is installed, you should be able to use the following view helpers to render Shoelace components:
```erb
<%= sl_form_for @user do |form| %>
<% # Text input: https://shoelace.style/components/input %>
<%= form.text_field :name %>
<%= form.password_field :password, placeholder: "Password Toggle", 'toggle-password': true %>
<% # Radio buttons: https://shoelace.style/components/color-picker %>
<%= form.color_field :color %>
<% # Radio buttons: https://shoelace.style/components/radio %>
<%= form.collection_radio_buttons :status, { id_1: "Option 1", id_2: "Option 2", id_3: "Option 3" }, :first, :last %>
<% # Select: https://shoelace.style/components/select %>
<%= form.collection_select :tag, { id_1: "Option 1", id_2: "Option 2", id_3: "Option 3" }, :first, :last, {}, { placeholder: "Select one" } %>
<%= form.submit %>
<% end %>
```
And this code will produce:
```html
<form class="new_user" id="new_user" data-remote="true" action="/" accept-charset="UTF-8" method="post">
<sl-input label="Name" type="text" name="user[name]" id="user_name"></sl-input>
<sl-input label="Password" type="password" name="user[password]" id="user_password"></sl-input>
<sl-color-picker value="#ffffff" name="user[color]" id="user_color"></sl-color-picker>
<sl-radio-group no-fieldset="true">
<sl-radio value="id_1" name="user[status]" id="user_status_id_1">Option 1</sl-radio>
<sl-radio value="id_2" name="user[status]" id="user_status_id_2">Option 2</sl-radio>
<sl-radio value="id_3" name="user[status]" id="user_status_id_3">Option 3</sl-radio>
</sl-radio-group>
<sl-select placeholder="Select one" name="user[tag]" id="user_tag">
<sl-menu-item value="id_1">Option 1</sl-menu-item>
<sl-menu-item value="id_2">Option 2</sl-menu-item>
<sl-menu-item value="id_3">Option 3</sl-menu-item>
</sl-select>
<sl-button submit="true" type="primary" data-disable-with="Create User">Create User</sl-button>
</form>
```
For more details about the gem, please refer to [the official README](https://github.com/yuki24/shoelace-rails).
## Additional Resources
- There is a third-party [example repo](https://github.com/ParamagicDev/rails-shoelace-example), courtesy of [ParamagicDev](https://github.com/ParamagicDev) available to help you get started.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@shoelace-style/shoelace",
"version": "2.3.0",
"version": "2.4.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@shoelace-style/shoelace",
"version": "2.3.0",
"version": "2.4.0",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.3.0",
"version": "2.4.0",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",
@@ -23,7 +23,8 @@
"./dist/translations/*": "./dist/translations/*"
},
"files": [
"dist"
"dist",
"cdn"
],
"keywords": [
"web components",

View File

@@ -5,6 +5,7 @@ import commandLineArgs from 'command-line-args';
import { deleteSync } from 'del';
import esbuild from 'esbuild';
import fs from 'fs';
import * as path from 'path';
import getPort, { portNumbers } from 'get-port';
import { globby } from 'globby';
import copy from 'recursive-copy';
@@ -18,83 +19,105 @@ const { bundle, copydir, dir, serve, types } = commandLineArgs([
]);
const outdir = dir;
const cdnDir = 'cdn';
deleteSync(outdir);
fs.mkdirSync(outdir, { recursive: true });
const outputDirectories = [cdnDir, outdir];
outputDirectories.forEach(dir => {
deleteSync(dir);
fs.mkdirSync(dir, { recursive: true });
});
(async () => {
try {
execSync(`node scripts/make-metadata.js --outdir "${outdir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-search.js --outdir "${outdir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-react.js --outdir "${outdir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-web-types.js --outdir "${outdir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-themes.js --outdir "${outdir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-icons.js --outdir "${outdir}"`, { stdio: 'inherit' });
if (types) {
console.log('Running the TypeScript compiler...');
execSync(`tsc --project ./tsconfig.prod.json --outdir "${outdir}"`, { stdio: 'inherit' });
}
outputDirectories.forEach(dir => {
execSync(`node scripts/make-metadata.js --outdir "${dir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-search.js --outdir "${dir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-react.js --outdir "${dir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-web-types.js --outdir "${dir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-themes.js --outdir "${dir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-icons.js --outdir "${dir}"`, { stdio: 'inherit' });
if (types) {
console.log('Running the TypeScript compiler...');
execSync(`tsc --project ./tsconfig.prod.json --outdir "${dir}"`, { stdio: 'inherit' });
}
});
} catch (err) {
console.error(chalk.red(err));
process.exit(1);
}
const alwaysExternal = ['@lit-labs/react', 'react'];
const buildResult = await esbuild
.build({
format: 'esm',
target: 'es2017',
entryPoints: [
//
// NOTE: Entry points must be mapped in package.json > exports, otherwise users won't be able to import them!
//
// The whole shebang
'./src/shoelace.ts',
// The auto-loader
'./src/shoelace-autoloader.ts',
// Components
...(await globby('./src/components/**/!(*.(style|test)).ts')),
// Translations
...(await globby('./src/translations/**/*.ts')),
// Public utilities
...(await globby('./src/utilities/**/!(*.(style|test)).ts')),
// Theme stylesheets
...(await globby('./src/themes/**/!(*.test).ts')),
// React wrappers
...(await globby('./src/react/**/*.ts'))
],
outdir,
chunkNames: 'chunks/[name].[hash]',
incremental: serve,
define: {
// Floating UI requires this to be set
'process.env.NODE_ENV': '"production"'
},
bundle: true,
const bundledConfig = {
format: 'esm',
target: 'es2017',
entryPoints: [
//
// We don't bundle certain dependencies in the unbundled build. This ensures we ship bare module specifiers,
// allowing end users to better optimize when using a bundler. (Only packages that ship ESM can be external.)
// NOTE: Entry points must be mapped in package.json > exports, otherwise users won't be able to import them!
//
// We never bundle React or @lit-labs/react though!
//
external: bundle
? alwaysExternal
: [...alwaysExternal, '@floating-ui/dom', '@shoelace-style/animations', 'lit', 'qr-creator'],
splitting: true,
plugins: []
})
.catch(err => {
console.error(chalk.red(err));
process.exit(1);
});
// The whole shebang
'./src/shoelace.ts',
// The auto-loader
'./src/shoelace-autoloader.ts',
// Components
...(await globby('./src/components/**/!(*.(style|test)).ts')),
// Translations
...(await globby('./src/translations/**/*.ts')),
// Public utilities
...(await globby('./src/utilities/**/!(*.(style|test)).ts')),
// Theme stylesheets
...(await globby('./src/themes/**/!(*.test).ts')),
// React wrappers
...(await globby('./src/react/**/*.ts'))
],
chunkNames: 'chunks/[name].[hash]',
incremental: serve,
define: {
// Floating UI requires this to be set
'process.env.NODE_ENV': '"production"'
},
bundle: true,
outdir: cdnDir,
//
// We don't bundle certain dependencies in the unbundled build. This ensures we ship bare module specifiers,
// allowing end users to better optimize when using a bundler. (Only packages that ship ESM can be external.)
//
// We never bundle React or @lit-labs/react though!
//
external: bundle
? alwaysExternal
: [...alwaysExternal, '@floating-ui/dom', '@shoelace-style/animations', 'lit', 'qr-creator'],
splitting: true,
plugins: []
};
const packageJSON = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')));
const unbundledConfig = {
...bundledConfig,
// Goes to /dist/npm
outdir,
target: 'esnext',
external: [...Object.keys(packageJSON.dependencies || {}), ...Object.keys(packageJSON.peerDependencies || {})]
};
const unbundledResult = await esbuild.build(unbundledConfig).catch(err => {
console.error(chalk.red(err));
console.error(chalk.red('\nFailed to build NPM (unbundled) build'));
process.exit(1);
});
const bundledResult = await esbuild.build(bundledConfig).catch(err => {
console.error(chalk.red(err));
console.error(chalk.red('\nFailed to build CDN (bundled) build'));
process.exit(1);
});
// Copy the build output to an additional directory
if (copydir) {
deleteSync(copydir);
copy(outdir, copydir);
copy(cdnDir, copydir);
}
console.log(chalk.green(`The build has been generated at ${outdir} 📦\n`));
console.log(chalk.green(`The build has been generated to: ${outputDirectories.join(', ')} 📦\n`));
// Dev server
if (serve) {
@@ -118,7 +141,7 @@ fs.mkdirSync(outdir, { recursive: true });
server: {
baseDir: 'docs',
routes: {
'/dist': './dist'
'/dist': './cdn'
}
},
//
@@ -148,37 +171,49 @@ fs.mkdirSync(outdir, { recursive: true });
// Rebuild and reload when source files change
bs.watch(['src/**/!(*.test).*']).on('change', async filename => {
console.log(`Source file changed - ${filename}`);
buildResult
[bundledResult, unbundledResult].forEach(build => {
// Rebuild and reload
.rebuild()
.then(() => {
// Rebuild stylesheets when a theme file changes
if (/^src\/themes/.test(filename)) {
execSync(`node scripts/make-themes.js --outdir "${outdir}"`, { stdio: 'inherit' });
}
})
.then(() => {
// Skip metadata when styles are changed
if (/(\.css|\.styles\.ts)$/.test(filename)) {
return;
}
build
.rebuild()
.then(() => {
// Rebuild stylesheets when a theme file changes
if (/^src\/themes/.test(filename)) {
outputDirectories.forEach(dir => {
execSync(`node scripts/make-themes.js --outdir "${dir}"`, { stdio: 'inherit' });
});
}
})
.then(() => {
// Skip metadata when styles are changed
if (/(\.css|\.styles\.ts)$/.test(filename)) {
return;
}
execSync(`node scripts/make-metadata.js --outdir "${outdir}"`, { stdio: 'inherit' });
})
.then(() => {
bs.reload();
})
.catch(err => console.error(chalk.red(err)));
outputDirectories.forEach(dir => {
execSync(`node scripts/make-metadata.js --outdir "${dir}"`, { stdio: 'inherit' });
});
})
.then(() => {
bs.reload();
})
.catch(err => console.error(chalk.red(err)));
});
});
// Reload without rebuilding when the docs change
bs.watch(['docs/**/*.md']).on('change', filename => {
console.log(`Docs file changed - ${filename}`);
execSync(`node scripts/make-search.js --outdir "${outdir}"`, { stdio: 'inherit' });
outputDirectories.forEach(dir => {
execSync(`node scripts/make-search.js --outdir "${dir}"`, { stdio: 'inherit' });
});
bs.reload();
});
}
// Cleanup on exit
process.on('SIGTERM', () => buildResult.rebuild.dispose());
process.on('SIGTERM', () => {
bundledResult.rebuild.dispose();
unbundledResult.rebuild.dispose();
});
})();

View File

@@ -343,22 +343,25 @@ export default css`
*/
.button--small {
height: auto;
min-height: var(--sl-input-height-small);
font-size: var(--sl-button-font-size-small);
height: var(--sl-input-height-small);
line-height: calc(var(--sl-input-height-small) - var(--sl-input-border-width) * 2);
border-radius: var(--sl-input-border-radius-small);
}
.button--medium {
height: auto;
min-height: var(--sl-input-height-medium);
font-size: var(--sl-button-font-size-medium);
height: var(--sl-input-height-medium);
line-height: calc(var(--sl-input-height-medium) - var(--sl-input-border-width) * 2);
border-radius: var(--sl-input-border-radius-medium);
}
.button--large {
height: auto;
min-height: var(--sl-input-height-large);
font-size: var(--sl-button-font-size-large);
height: var(--sl-input-height-large);
line-height: calc(var(--sl-input-height-large) - var(--sl-input-border-width) * 2);
border-radius: var(--sl-input-border-radius-large);
}

View File

@@ -69,6 +69,7 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
@debounce(100)
handleScrollEnd() {
if (!this.pointers.size) {
// If no pointer is active in the scroll area then the scroll has ended
this.scrolling = false;
this.host.scrollContainer.dispatchEvent(
new CustomEvent('scrollend', {
@@ -78,6 +79,7 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
);
this.host.requestUpdate();
} else {
// otherwise let's wait a bit more
this.handleScrollEnd();
}
}
@@ -87,35 +89,33 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
return;
}
const scrollContainer = this.host.scrollContainer;
this.pointers.add(event.pointerId);
scrollContainer.setPointerCapture(event.pointerId);
if (this.mouseDragging && this.pointers.size === 1) {
const canDrag = this.mouseDragging && !this.dragging && event.button === 0;
if (canDrag) {
event.preventDefault();
scrollContainer.addEventListener('pointermove', this.handlePointerMove);
this.host.scrollContainer.addEventListener('pointermove', this.handlePointerMove);
}
}
handlePointerMove(event: PointerEvent) {
const host = this.host;
const scrollContainer = host.scrollContainer;
if (scrollContainer.hasPointerCapture(event.pointerId)) {
if (!this.dragging) {
this.handleDragStart();
}
const scrollContainer = this.host.scrollContainer;
const hasMoved = !!event.movementX || !!event.movementY;
if (!this.dragging && hasMoved) {
// Start dragging if it hasn't yet
scrollContainer.setPointerCapture(event.pointerId);
this.handleDragStart();
} else if (scrollContainer.hasPointerCapture(event.pointerId)) {
// Ignore pointers that we are not tracking
this.handleDrag(event);
}
}
handlePointerUp(event: PointerEvent) {
const host = this.host;
const scrollContainer = host.scrollContainer;
this.pointers.delete(event.pointerId);
scrollContainer.releasePointerCapture(event.pointerId);
this.host.scrollContainer.releasePointerCapture(event.pointerId);
if (this.pointers.size === 0) {
this.handleDragEnd();

View File

@@ -1,5 +1,5 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
@@ -274,6 +274,58 @@ describe('<sl-checkbox>', () => {
expect(focusSpy.called).to.equal(true);
expect(el.shadowRoot!.activeElement).to.equal(checkbox);
});
it('should not jump the page to the bottom when focusing a checkbox at the bottom of an element with overflow: auto;', async () => {
// https://github.com/shoelace-style/shoelace/issues/1169
const el = await fixture<HTMLDivElement>(html`
<div style="display: flex; flex-direction: column; overflow: auto; max-height: 400px; gap: 8px;">
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
<sl-checkbox>Checkbox</sl-checkbox>
</div>
;
`);
const checkboxes = el.querySelectorAll<SlCheckbox>('sl-checkbox');
const lastSwitch = checkboxes[checkboxes.length - 1];
expect(window.scrollY).to.equal(0);
// Without these 2 timeouts, tests will pass unexpectedly in Safari.
await aTimeout(10);
lastSwitch.focus();
await aTimeout(10);
expect(window.scrollY).to.equal(0);
});
});
describe('blur', () => {

View File

@@ -140,7 +140,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
@property({ type: Boolean, reflect: true }) inline = false;
/** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */
@property() size: 'small' | 'medium' | 'large' = 'medium';
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Removes the button that lets users toggle between format. */
@property({ attribute: 'no-format-toggle', type: Boolean }) noFormatToggle = false;

View File

@@ -6,6 +6,20 @@ import type SlHideEvent from '../../events/sl-hide';
import type SlShowEvent from '../../events/sl-show';
describe('<sl-details>', () => {
describe('accessibility', () => {
it('should be accessible when closed', async () => {
const details = await fixture<SlDetails>(html`<sl-details summary="Test"> Test text </sl-details>`);
await expect(details).to.be.accessible();
});
it('should be accessible when open', async () => {
const details = await fixture<SlDetails>(html`<sl-details open summary="Test">Test text</sl-details>`);
await expect(details).to.be.accessible();
});
});
it('should be visible with the open attribute', async () => {
const el = await fixture<SlDetails>(html`
<sl-details open>

View File

@@ -170,7 +170,7 @@ export default class SlDetails extends ShoelaceElement {
'details--rtl': isRtl
})}
>
<header
<div
part="header"
id="header"
class="details__header"
@@ -192,10 +192,10 @@ export default class SlDetails extends ShoelaceElement {
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
</slot>
</span>
</header>
</div>
<div class="details__body">
<slot part="content" id="content" class="details__content" role="region" aria-labelledby="header"></slot>
<div class="details__body" role="region" aria-labelledby="header">
<slot part="content" id="content" class="details__content"></slot>
</div>
</div>
`;

View File

@@ -1,14 +1,16 @@
import { customElement, property, state } from 'lit/decorators.js';
import { getIconLibrary, unwatchIcon, watchIcon } from './library';
import { html } from 'lit';
import { requestIcon } from './request';
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './icon.styles';
import type { CSSResultGroup } from 'lit';
const CACHEABLE_ERROR = Symbol();
const RETRYABLE_ERROR = Symbol();
type SVGResult = SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR;
let parser: DOMParser;
const iconCache = new Map<string, Promise<SVGResult>>();
/**
* @summary Icons are symbols that can be used to represent various options within an application.
@@ -25,7 +27,37 @@ let parser: DOMParser;
export default class SlIcon extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@state() private svg = '';
/** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */
private static async resolveIcon(url: string): Promise<SVGResult> {
let fileData: Response;
try {
fileData = await fetch(url, { mode: 'cors' });
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
} catch {
return RETRYABLE_ERROR;
}
try {
const div = document.createElement('div');
div.innerHTML = await fileData.text();
const svg = div.firstElementChild;
if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR;
if (!parser) parser = new DOMParser();
const doc = parser.parseFromString(svg.outerHTML, 'text/html');
const svgEl = doc.body.querySelector('svg');
if (!svgEl) return CACHEABLE_ERROR;
svgEl.part.add('svg');
return document.adoptNode(svgEl);
} catch {
return CACHEABLE_ERROR;
}
}
@state() private svg: SVGElement | null = null;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property({ reflect: true }) name?: string;
@@ -87,46 +119,42 @@ export default class SlIcon extends ShoelaceElement {
const library = getIconLibrary(this.library);
const url = this.getUrl();
// Create an instance of the DOM parser. We do it here instead of top-level to support SSR while maintaining a
// single parser instance for optimal performance.
if (!parser) {
parser = new DOMParser();
if (!url) {
this.svg = null;
return;
}
if (url) {
try {
const file = await requestIcon(url);
if (url !== this.getUrl()) {
// If the url has changed while fetching the icon, ignore this request
return;
} else if (file.ok) {
const doc = parser.parseFromString(file.svg, 'text/html');
const svgEl = doc.body.querySelector('svg');
let iconResolver = iconCache.get(url);
if (!iconResolver) {
iconResolver = SlIcon.resolveIcon(url);
iconCache.set(url, iconResolver);
}
if (svgEl !== null) {
svgEl.part.add('svg');
library?.mutator?.(svgEl);
this.svg = svgEl.outerHTML;
this.emit('sl-load');
} else {
this.svg = '';
this.emit('sl-error');
}
} else {
this.svg = '';
this.emit('sl-error');
}
} catch {
const svg = await iconResolver;
if (svg === RETRYABLE_ERROR) {
iconCache.delete(url);
}
if (url !== this.getUrl()) {
// If the url has changed while fetching the icon, ignore this request
return;
}
switch (svg) {
case RETRYABLE_ERROR:
case CACHEABLE_ERROR:
this.svg = null;
this.emit('sl-error');
}
} else if (this.svg.length > 0) {
// If we can't resolve a URL and an icon was previously set, remove it
this.svg = '';
break;
default:
this.svg = svg.cloneNode(true) as SVGElement;
library?.mutator?.(this.svg);
this.emit('sl-load');
}
}
render() {
return html` ${unsafeSVG(this.svg)} `;
return this.svg;
}
}

View File

@@ -1,42 +0,0 @@
import { requestInclude } from '../include/request';
type IconFile =
| {
ok: true;
status: number;
svg: string;
}
| {
ok: false;
status: number;
svg: null;
};
interface IconFileUnknown {
ok: boolean;
status: number;
svg: string | null;
}
const iconFiles = new Map<string, IconFile>();
export async function requestIcon(url: string): Promise<IconFile> {
if (iconFiles.has(url)) {
return iconFiles.get(url)!;
}
const fileData = await requestInclude(url);
const iconFileData: IconFileUnknown = {
ok: fileData.ok,
status: fileData.status,
svg: null
};
if (fileData.ok) {
const div = document.createElement('div');
div.innerHTML = fileData.html;
const svg = div.firstElementChild;
iconFileData.svg = svg?.tagName.toLowerCase() === 'svg' ? svg.outerHTML : '';
}
iconFiles.set(url, iconFileData as IconFile);
return iconFileData as IconFile;
}

View File

@@ -190,13 +190,20 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
*/
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
//
// NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
// can be set before the component is rendered.
//
/** Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. */
get valueAsDate() {
return this.input?.valueAsDate ?? null;
const input = document.createElement('input');
input.type = 'date';
input.value = this.value;
return input.valueAsDate;
}
set valueAsDate(newValue: Date | null) {
// We use an in-memory input instead of the one in the template because the property can be set before render
const input = document.createElement('input');
input.type = 'date';
input.valueAsDate = newValue;
@@ -205,11 +212,13 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
/** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */
get valueAsNumber() {
return this.input?.valueAsNumber ?? parseFloat(this.value);
const input = document.createElement('input');
input.type = 'number';
input.value = this.value;
return input.valueAsNumber;
}
set valueAsNumber(newValue: number) {
// We use an in-memory input instead of the one in the template because the property can be set before render
const input = document.createElement('input');
input.type = 'number';
input.valueAsNumber = newValue;

View File

@@ -64,7 +64,7 @@ export default css`
outline: none;
}
:host(:hover:not([aria-disabled='true'])) .menu-item {
:host(:hover:not([aria-disabled='true'], :focus-visible)) .menu-item {
background-color: var(--sl-color-neutral-100);
color: var(--sl-color-neutral-1000);
}

View File

@@ -25,6 +25,7 @@ import type { CSSResultGroup } from 'lit';
* @csspart prefix - The prefix container.
* @csspart label - The menu item label.
* @csspart suffix - The suffix container.
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
*/
@customElement('sl-menu-item')
export default class SlMenuItem extends ShoelaceElement {
@@ -141,7 +142,7 @@ export default class SlMenuItem extends ShoelaceElement {
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
<span class="menu-item__chevron">
<span part="submenu-icon" class="menu-item__chevron">
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
</span>
</div>

View File

@@ -51,7 +51,10 @@ export default class SlRadioButton extends ShoelaceElement {
/** Disables the radio button. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** The radio button's size. */
/**
* The radio button's size. When used inside a radio group, the size will be determined by the radio group's size so
* this attribute can typically be omitted.
*/
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws a pill-style radio button with rounded edges. */

View File

@@ -151,30 +151,30 @@ describe('<sl-radio-group>', () => {
expect(radioGroup.hasAttribute('data-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-user-valid')).to.be.false;
});
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group value="1">
<sl-radio id="radio-1" name="a" value="1"></sl-radio>
<sl-radio id="radio-2" name="a" value="2"></sl-radio>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radioGroup = form.querySelector<SlRadioGroup>('sl-radio-group')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group value="1">
<sl-radio id="radio-1" name="a" value="1"></sl-radio>
<sl-radio id="radio-2" name="a" value="2"></sl-radio>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radioGroup = form.querySelector<SlRadioGroup>('sl-radio-group')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
// Submitting the form after setting custom validity should not trigger the handler
radioGroup.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
// Submitting the form after setting custom validity should not trigger the handler
radioGroup.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await aTimeout(100);
await aTimeout(100);
expect(submitHandler).to.not.have.been.called;
expect(submitHandler).to.not.have.been.called;
});
});
});
@@ -252,6 +252,53 @@ describe('when submitting a form', () => {
});
});
describe('when a size is applied', () => {
it('should apply the same size to all radios', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group size="large">
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
</sl-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('sl-radio')!;
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
it('should apply the same size to all radio buttons', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group size="large">
<sl-radio-button id="radio-1" value="1"></sl-radio-button>
<sl-radio-button id="radio-2" value="2"></sl-radio-button>
</sl-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('sl-radio-button')!;
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
it('should update the size of all radio buttons when size changes', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group size="small">
<sl-radio-button id="radio-1" value="1"></sl-radio-button>
<sl-radio-button id="radio-2" value="2"></sl-radio-button>
</sl-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('sl-radio-button')!;
expect(radio1.size).to.equal('small');
expect(radio2.size).to.equal('small');
radioGroup.size = 'large';
await radioGroup.updateComplete;
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
});
describe('when the value changes', () => {
it('should emit sl-change when toggled with the arrow keys', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`

View File

@@ -71,6 +71,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
/** The current value of the radio group, submitted as a name/value pair with form data. */
@property({ reflect: true }) value = '';
/** The radio group's size. This size will be applied to all child radios and radio buttons. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
@@ -196,10 +199,20 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
}
private handleSlotChange() {
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private syncRadios() {
if (customElements.get('sl-radio') || customElements.get('sl-radio-button')) {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
// Sync the checked state and size
radios.forEach(radio => {
radio.checked = radio.value === this.value;
radio.size = this.size;
});
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
@@ -224,22 +237,22 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
} else {
// Rerun this handler when <sl-radio> or <sl-radio-button> is registered
customElements.whenDefined('sl-radio').then(() => this.handleSlotChange());
customElements.whenDefined('sl-radio-button').then(() => this.handleSlotChange());
customElements.whenDefined('sl-radio').then(() => this.syncRadios());
customElements.whenDefined('sl-radio-button').then(() => this.syncRadios());
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.formControlController.setValidity(this.validity.valid);
}
@watch('size', { waitUntilFirstUpdate: true })
handleSizeChange() {
this.syncRadios();
}
@watch('value')
handleValueChange() {
if (this.hasUpdated) {
@@ -302,7 +315,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
<slot
@click=${this.handleRadioClick}
@keydown=${this.handleKeyDown}
@slotchange=${this.handleSlotChange}
@slotchange=${this.syncRadios}
role="presentation"
></slot>
`;
@@ -312,7 +325,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
part="form-control"
class=${classMap({
'form-control': true,
'form-control--medium': true,
'form-control--small': this.size === 'small',
'form-control--medium': this.size === 'medium',
'form-control--large': this.size === 'large',
'form-control--radio-group': true,
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText

View File

@@ -36,7 +36,10 @@ export default class SlRadio extends ShoelaceElement {
/** The radio's value. When selected, the radio group will receive this value. */
@property() value: string;
/** The radio's size. */
/**
* The radio's size. When used inside a radio group, the size will be determined by the radio group's size so this
* attribute can typically be omitted.
*/
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Disables the radio. */

View File

@@ -43,12 +43,19 @@ export default css`
padding: var(--symbol-spacing);
}
.rating__symbols--indicator {
position: absolute;
top: 0;
left: 0;
.rating__symbol--active,
.rating__partial--filled {
color: var(--symbol-color-active);
pointer-events: none;
}
.rating__partial-symbol-container {
position: relative;
}
.rating__partial--filled {
position: absolute;
top: var(--symbol-spacing);
left: var(--symbol-spacing);
}
.rating__symbol {
@@ -79,7 +86,7 @@ export default css`
/* Forced colors mode */
@media (forced-colors: active) {
.rating__symbols--indicator {
.rating__symbol--active {
color: SelectedItem;
}
}

View File

@@ -249,16 +249,51 @@ export default class SlRating extends ShoelaceElement {
@mousemove=${this.handleMouseMove}
@touchmove=${this.handleTouchMove}
>
<span class="rating__symbols rating__symbols--inactive">
<span class="rating__symbols">
${counter.map(index => {
// Users can click the current value to clear the rating. When this happens, we set this.isHovering to
// false to prevent the hover state from confusing them as they move the mouse out of the control. This
// extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol.
if (displayValue > index && displayValue < index + 1) {
// Users can click the current value to clear the rating. When this happens, we set this.isHovering to
// false to prevent the hover state from confusing them as they move the mouse out of the control. This
// extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol.
return html`
<span
class=${classMap({
rating__symbol: true,
'rating__partial-symbol-container': true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
})}
role="presentation"
@mouseenter=${this.handleMouseEnter}
>
<div
style=${styleMap({
clipPath: isRtl
? `inset(0 ${(displayValue - index) * 100}% 0 0)`
: `inset(0 0 0 ${(displayValue - index) * 100}%)`
})}
>
${unsafeHTML(this.getSymbol(index + 1))}
</div>
<div
class="rating__partial--filled"
style=${styleMap({
clipPath: isRtl
? `inset(0 0 0 ${100 - (displayValue - index) * 100}%)`
: `inset(0 ${100 - (displayValue - index) * 100}% 0 0)`
})}
>
${unsafeHTML(this.getSymbol(index + 1))}
</div>
</span>
`;
}
return html`
<span
class=${classMap({
rating__symbol: true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1,
'rating__symbol--active': displayValue >= index + 1
})}
role="presentation"
@mouseenter=${this.handleMouseEnter}
@@ -268,30 +303,6 @@ export default class SlRating extends ShoelaceElement {
`;
})}
</span>
<span class="rating__symbols rating__symbols--indicator">
${counter.map(index => {
return html`
<span
class=${classMap({
rating__symbol: true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
})}
style=${styleMap({
clipPath:
displayValue > index + 1
? 'none'
: isRtl
? `inset(0 0 0 ${100 - ((displayValue - index) / 1) * 100}%)`
: `inset(0 ${100 - ((displayValue - index) / 1) * 100}% 0 0)`
})}
role="presentation"
>
${unsafeHTML(this.getSymbol(index + 1))}
</span>
`;
})}
</span>
</div>
`;
}

View File

@@ -8,15 +8,31 @@ import type SlOption from '../option/option';
import type SlSelect from './select';
describe('<sl-select>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<SlSelect>(html`
<sl-select label="Select one">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
`);
await expect(el).to.be.accessible();
describe('accessibility', () => {
it('should pass accessibility tests when closed', async () => {
const select = await fixture<SlSelect>(html`
<sl-select label="Select one">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
`);
await expect(select).to.be.accessible();
});
it('should pass accessibility tests when open', async () => {
const select = await fixture<SlSelect>(html`
<sl-select label="Select one">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
`);
await select.show();
await expect(select).to.be.accessible();
});
});
it('should be disabled with the disabled attribute', async () => {

View File

@@ -108,7 +108,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
@defaultValue() defaultValue: string | string[] = '';
/** The select's size. */
@property() size: 'small' | 'medium' | 'large' = 'medium';
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Placeholder text to show as a hint when the select is empty. */
@property() placeholder = '';
@@ -821,7 +821,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
</slot>
</div>
<slot
<div
id="listbox"
role="listbox"
aria-expanded=${this.open ? 'true' : 'false'}
@@ -832,7 +832,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
tabindex="-1"
@mouseup=${this.handleOptionClick}
@slotchange=${this.handleDefaultSlotChange}
></slot>
>
<slot></slot>
</div>
</sl-popup>
</div>

View File

@@ -1,9 +1,260 @@
import { expect, fixture, html } from '@open-wc/testing';
import { dragElement } from '../../internal/test';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { queryByTestId } from '../../internal/test/data-testid-helpers';
import { resetMouse } from '@web/test-runner-commands';
import type SlSplitPanel from './split-panel';
const DIVIDER_WIDTH_IN_PX = 4;
const getPanel = (splitPanel: SlSplitPanel, testid: string): HTMLElement => {
const startPanel = queryByTestId<HTMLElement>(splitPanel, testid);
expect(startPanel).not.to.be.null;
return startPanel!;
};
const getPanelWidth = (splitPanel: SlSplitPanel, testid: string) => {
const panel = getPanel(splitPanel, testid);
const { width } = panel.getBoundingClientRect();
return width;
};
const getPanelHeight = (splitPanel: SlSplitPanel, testid: string) => {
const panel = getPanel(splitPanel, testid);
const { height } = panel.getBoundingClientRect();
return height;
};
const getDivider = (splitPanel: SlSplitPanel): Element => {
const divider = splitPanel.shadowRoot?.querySelector('[part="divider"]');
expect(divider).not.to.be.null;
return divider!;
};
describe('<sl-split-panel>', () => {
it('should render a component', async () => {
const el = await fixture(html` <sl-split-panel></sl-split-panel> `);
afterEach(async () => {
await resetMouse();
});
expect(el).to.exist;
it('should render a component', async () => {
const splitPanel = await fixture(html` <sl-split-panel></sl-split-panel> `);
expect(splitPanel).to.exist;
});
it('should be accessible', async () => {
const splitPanel = await fixture(html`<sl-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
await expect(splitPanel).to.be.accessible();
});
it('should show both panels', async () => {
const splitPanel = await fixture(html`<sl-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
expect(splitPanel).to.contain.text('Start');
expect(splitPanel).to.contain.text('End');
});
describe('panel sizing horizontal', () => {
it('has two evenly sized panels by default', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</sl-split-panel>`);
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
expect(startPanelWidth).to.be.equal(endPanelWidth);
});
it('changes the sizing of the panels based on the position attribute', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</sl-split-panel>`);
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
expect(startPanelWidth * 3).to.be.equal(endPanelWidth - DIVIDER_WIDTH_IN_PX);
});
it('updates the position in pixels to the correct result', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</sl-split-panel>`);
splitPanel.position = 10;
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
expect(startPanelWidth).to.be.equal(splitPanel.positionInPixels - DIVIDER_WIDTH_IN_PX / 2);
});
it('emits the sl-reposition event on position change', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const repositionPromise = oneEvent(splitPanel, 'sl-reposition');
splitPanel.position = 10;
return repositionPromise;
});
it('can be resized using the mouse', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const positionInPixels = splitPanel.positionInPixels;
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = splitPanel.positionInPixels;
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 30);
});
it('cannot be resized if disabled', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel disabled>
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const positionInPixels = splitPanel.positionInPixels;
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = splitPanel.positionInPixels;
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels);
});
it('snaps to predefined positions', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const positionInPixels = splitPanel.positionInPixels;
splitPanel.snap = `${positionInPixels - 40}px`;
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = splitPanel.positionInPixels;
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 40);
});
});
describe('panel sizing vertical', () => {
it('has two evenly sized panels by default', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</sl-split-panel>`);
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
expect(startPanelHeight).to.be.equal(endPanelHeight);
});
it('changes the sizing of the panels based on the position attribute', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25" vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</sl-split-panel>`);
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
expect(startPanelHeight * 3).to.be.equal(endPanelHeight - DIVIDER_WIDTH_IN_PX);
});
it('updates the position in pixels to the correct result', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25" vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</sl-split-panel>`);
splitPanel.position = 10;
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
expect(startPanelHeight).to.be.equal(splitPanel.positionInPixels - DIVIDER_WIDTH_IN_PX / 2);
});
it('emits the sl-reposition event on position change ', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const repositionPromise = oneEvent(splitPanel, 'sl-reposition');
splitPanel.position = 10;
return repositionPromise;
});
it('can be resized using the mouse ', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const positionInPixels = splitPanel.positionInPixels;
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = splitPanel.positionInPixels;
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 30);
});
it('cannot be resized if disabled', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel disabled vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const positionInPixels = splitPanel.positionInPixels;
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = splitPanel.positionInPixels;
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels);
});
it('snaps to predefined positions', async () => {
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</sl-split-panel>`);
const positionInPixels = splitPanel.positionInPixels;
splitPanel.snap = `${positionInPixels - 40}px`;
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = splitPanel.positionInPixels;
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 40);
});
});
});

View File

@@ -262,5 +262,65 @@ describe('<sl-switch>', () => {
});
});
it('should not jump the page to the bottom when focusing a switch at the bottom of an element with overflow: auto;', async () => {
// https://github.com/shoelace-style/shoelace/issues/1169
const el = await fixture<HTMLDivElement>(html`
<div style="display: flex; flex-direction: column; overflow: auto; max-height: 400px;">
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
<sl-switch>Switch</sl-switch>
</div>
;
`);
const switches = el.querySelectorAll<SlSwitch>('sl-switch');
const lastSwitch = switches[switches.length - 1];
expect(window.scrollY).to.equal(0);
// Without these 2 timeouts, tests will pass unexpectedly in Safari.
await aTimeout(10);
lastSwitch.focus();
await aTimeout(10);
expect(window.scrollY).to.equal(0);
});
runFormControlBaseTests('sl-switch');
});

View File

@@ -70,6 +70,11 @@ export default class SlTabGroup extends ShoelaceElement {
@property({ attribute: 'no-scroll-controls', type: Boolean }) noScrollControls = false;
connectedCallback() {
const whenAllDefined = Promise.allSettled([
customElements.whenDefined('sl-tab'),
customElements.whenDefined('sl-tab-panel')
]);
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => {
@@ -89,20 +94,24 @@ export default class SlTabGroup extends ShoelaceElement {
}
});
// After the first update...
this.updateComplete.then(() => {
this.syncTabsAndPanels();
this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true });
this.resizeObserver.observe(this.nav);
// Set initial tab state when the tabs first become visible
const intersectionObserver = new IntersectionObserver((entries, observer) => {
if (entries[0].intersectionRatio > 0) {
this.setAriaLabels();
this.setActiveTab(this.getActiveTab() ?? this.tabs[0], { emitEvents: false });
observer.unobserve(entries[0].target);
}
// Wait for tabs and tab panels to be registered
whenAllDefined.then(() => {
// Set initial tab state when the tabs become visible
const intersectionObserver = new IntersectionObserver((entries, observer) => {
if (entries[0].intersectionRatio > 0) {
this.setAriaLabels();
this.setActiveTab(this.getActiveTab() ?? this.tabs[0], { emitEvents: false });
observer.unobserve(entries[0].target);
}
});
intersectionObserver.observe(this.tabGroup);
});
intersectionObserver.observe(this.tabGroup);
});
}
@@ -241,8 +250,8 @@ export default class SlTabGroup extends ShoelaceElement {
this.activeTab = tab;
// Sync active tab and panel
this.tabs.map(el => (el.active = el === this.activeTab));
this.panels.map(el => (el.active = el.name === this.activeTab?.panel));
this.tabs.forEach(el => (el.active = el === this.activeTab));
this.panels.forEach(el => (el.active = el.name === this.activeTab?.panel));
this.syncIndicator();
if (['top', 'bottom'].includes(this.placement)) {

View File

@@ -47,6 +47,14 @@ import type { CSSResultGroup, PropertyValueMap } from 'lit';
* @csspart expand-button - The container that wraps the tree item's expand button and spinner.
* @csspart label - The tree item's label.
* @csspart children - The container that wraps the tree item's nested children.
* @csspart checkbox - The checkbox that shows when using multiselect.
* @csspart checkbox__base - The checkbox's exported `base` part.
* @csspart checkbox__control - The checkbox's exported `control` part.
* @csspart checkbox__control--checked - The checkbox's exported `control--checked` part.
* @csspart checkbox__control--indeterminate - The checkbox's exported `control--indeterminate` part.
* @csspart checkbox__checked-icon - The checkbox's exported `checked-icon` part.
* @csspart checkbox__indeterminate-icon - The checkbox's exported `indeterminate-icon` part.
* @csspart checkbox__label - The checkbox's exported `label` part.
*/
@customElement('sl-tree-item')
export default class SlTreeItem extends ShoelaceElement {
@@ -258,11 +266,21 @@ export default class SlTreeItem extends ShoelaceElement {
() =>
html`
<sl-checkbox
tabindex="-1"
part="checkbox"
exportparts="
base:checkbox__base,
control:checkbox__control,
control--checked:checkbox__control--checked,
control--indeterminate:checkbox__control--indeterminate,
checked-icon:checkbox__checked-icon,
indeterminate-icon:checkbox__indeterminate-icon,
label:checkbox__label
"
class="tree-item__checkbox"
?disabled="${this.disabled}"
?checked="${live(this.selected)}"
?indeterminate="${this.indeterminate}"
tabindex="-1"
></sl-checkbox>
`
)}

View File

@@ -65,3 +65,19 @@ export async function moveMouseOnElement(
await sendMouse({ type: 'move', position: [clickX, clickY] });
}
/** A testing utility that drags an element with the mouse. */
export async function dragElement(
/** The element to drag */
el: Element,
/** The horizontal distance to drag in pixels */
deltaX = 0,
/** The vertical distance to drag in pixels */
deltaY = 0
): Promise<void> {
await moveMouseOnElement(el);
await sendMouse({ type: 'down' });
const { clickX, clickY } = determineMousePosition(el, 'center', deltaX, deltaY);
await sendMouse({ type: 'move', position: [clickX, clickY] });
await sendMouse({ type: 'up' });
}

View File

@@ -55,4 +55,4 @@ function register(tagName: string): Promise<void> {
discover(document.body);
// Listen for new undefined elements
observer.observe(document.body, { subtree: true, childList: true });
observer.observe(document.documentElement, { subtree: true, childList: true });

View File

@@ -28,7 +28,7 @@ const translation: Translation = {
scrollToStart: 'Zum Anfang scrollen',
selectAColorFromTheScreen: 'Wähle eine Farbe vom Bildschirm',
showPassword: 'Passwort anzeigen',
slideNum: slide => `Gleiten ${slide}`,
slideNum: slide => `Folie ${slide}`,
toggleColorFormat: 'Farbformat umschalten'
};

View File

@@ -25,10 +25,10 @@ const translation: Translation = {
remove: 'Remover',
resize: 'Mudar o tamanho',
scrollToEnd: 'Rolar até o final',
scrollToStart: 'Rolar até o começo',
scrollToStart: 'Rolar até o início',
selectAColorFromTheScreen: 'Selecionar uma cor da tela',
showPassword: 'Mostrar senhaShow password',
slideNum: slide => `Diapositivo ${slide}`,
showPassword: 'Mostrar senha',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Trocar o formato de cor'
};

View File

@@ -5,7 +5,7 @@ import { playwrightLauncher } from '@web/test-runner-playwright';
export default {
rootDir: '.',
files: 'src/**/*.test.ts', // "default" group
concurrentBrowsers: 3,
concurrentBrowsers: 1,
nodeResolve: true,
testFramework: {
config: {
@@ -26,7 +26,9 @@ export default {
],
testRunnerHtml: testFramework => `
<html lang="en-US">
<head></head>
<head>
<script>window.process = {env: { NODE_ENV: "production" }};</script>
</head>
<body>
<link rel="stylesheet" href="dist/themes/light.css">
<script type="module" src="dist/shoelace.js"></script>