Compare commits

..

92 Commits

Author SHA1 Message Date
Cory LaViska
afa69b4f9c 2.4.0 2023-04-14 12:56:26 -04:00
Cory LaViska
102f46d185 bump version 2023-04-14 12:54:55 -04:00
Cory LaViska
9c5e184d82 fixes #1302 2023-04-14 12:48:57 -04:00
Cory LaViska
385b5451c8 add size to radio group; fixes #1301 2023-04-14 12:20:17 -04:00
Cory LaViska
0e7487257b spell check and reorder static function 2023-04-13 14:16:03 -04:00
Matt Pharoah
eab0e3219f 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-04-13 14:12:59 -04:00
Cory LaViska
cf89c901a2 no roles on slots; fixes #1287 2023-04-13 12:52:03 -04:00
Cory LaViska
902b08cc0f revert role and don't use <header> for buttons 2023-04-13 12:47:48 -04:00
Cory LaViska
caf9a09efa fix typos 2023-04-13 12:47:18 -04:00
dhellgartner
65734dc993 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-04-13 12:45:52 -04:00
Cory LaViska
0f02fffc3a less pipeline flakes 🤞🏻 2023-04-13 11:58:46 -04:00
Cory LaViska
931ecad8c5 update changelog 2023-04-13 11:56:33 -04:00
Alessandro
c137f83df6 fix(carousel): clickable elements don't work on chrome (#1266)
* fix(carousel): clickable elements don't work on chrome

* fix: update implementation
2023-04-13 11:55:40 -04:00
Cory LaViska
d3a0a38dce don't show hover when focused; fixes #1282 2023-04-13 10:31:24 -04:00
Cory LaViska
b76af1aa21 update changelog 2023-04-13 09:51:11 -04:00
Cory LaViska
5cf6a37ee2 wait until registered to set initial state; fixes #1292 2023-04-13 09:51:07 -04:00
Cory LaViska
63194abf93 prettier + changelog 2023-04-05 09:21:25 -05:00
Konnor Rogers
e196b0915a fix: split-panel divider now focusable in Edge / Chrome (#1289) 2023-04-05 09:18:57 -05:00
Cory LaViska
d2369d1de8 fix @since 2023-04-04 16:22:24 -05:00
dhellgartner
a9bbcc5556 first draft of testing guidelines (#1223)
Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-04-04 09:00:03 -05:00
Cory LaViska
8d9430e7a2 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-04-03 16:25:21 -05:00
Cory LaViska
0411754949 update changelog 2023-04-03 16:25:18 -05:00
Christophe Eymard
91ffaa1a2d stop holding a reference to a Promise when it is resolved (#1284) 2023-04-03 16:23:59 -05:00
Cory LaViska
ae9972a91a clarify events; #1283 2023-04-03 16:19:35 -05:00
Cory LaViska
478fa6f2bb update changelog 2023-03-31 15:03:31 -04:00
Cory LaViska
6a52a04591 Merge branch 'sloth30799-sloth30799' into next 2023-03-31 14:39:50 -04:00
Cory LaViska
a8f87e0d5e Merge branch 'sloth30799' of github.com:sloth30799/shoelace into sloth30799-sloth30799 2023-03-31 14:37:54 -04:00
Cory LaViska
cbc96fdf5c update changelog 2023-03-31 11:59:49 -04:00
dhellgartner
b4d24dd9af Added a basic test for animation (#1274)
Did not manage to check
that the properties are correctly passed
to the animation api at this point so this
stays a blackbox test

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-03-31 11:59:07 -04:00
Cory LaViska
4b66cc2acb update changelog 2023-03-31 11:53:43 -04:00
Thomas Blum
3766d5ce27 Fixed wrong property value (#1272) 2023-03-31 11:52:03 -04:00
Cory LaViska
4b7d686754 prettier + highlighter 2023-03-29 16:49:59 -04:00
gennitdev
b948a07a4d Include slot example for Vue (#1271) 2023-03-29 16:48:39 -04:00
Cory LaViska
6d3505aefa fix property name 2023-03-28 11:31:42 -04:00
Cory LaViska
b22650ff51 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-03-27 09:31:46 -04:00
Cory LaViska
23a7f65b49 fix variable name case 2023-03-27 09:31:43 -04:00
Han Ye Htun
f4fba8eab4 added tests 2023-03-24 00:05:10 +06:30
Christophe Eymard
1734bf54a7 replaced Set with WeakSet (#1249) 2023-03-23 12:13:13 -04:00
Cory LaViska
88efec7815 prettier 2023-03-23 08:39:20 -04:00
Marko Hrovatic
e335189bb8 Update angular.md (#1264)
Added an example how to access Shoelace components from component code
2023-03-22 16:39:20 -04:00
Han Ye Htun
d03ca4ab95 Avatar Initials visible when image has a transparent background(#1256) 2023-03-22 21:46:23 +06:30
Cory LaViska
257407758f remove unnecessary dep 2023-03-21 16:25:52 -04:00
Han Ye Htun
2443c046aa Avatar Initials visible when image has a transparent background(#1256) 2023-03-22 00:50:01 +06:30
Cory LaViska
d710eb3947 update changelog 2023-03-20 14:00:42 -04:00
dhellgartner
7b2f6f230d Added tests to the sl-animated-images component (#1246)
Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-03-20 13:59:54 -04:00
Cory LaViska
07cb6070cc Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-03-20 11:25:56 -04:00
Cory LaViska
bd7dc2a7be #1244 2023-03-20 11:25:55 -04:00
Alessandro
db931c12be fix: prevent expand button to shrink (#1245) 2023-03-20 11:24:58 -04:00
Cory LaViska
765b311a08 update changelog 2023-03-14 12:00:53 -04:00
Cory LaViska
ce198d9c0b Merge branch 'alenaksu-fix/tree-item/clickable-label-elements' into next 2023-03-14 11:59:06 -04:00
Cory LaViska
8f5893931b Merge branch 'fix/tree-item/clickable-label-elements' of github.com:alenaksu/shoelace into alenaksu-fix/tree-item/clickable-label-elements 2023-03-14 11:57:36 -04:00
Stefan Bauer
221be48589 fixed big bug typo ;-) (#1242) 2023-03-14 11:56:55 -04:00
Cory LaViska
234ff2619d fixes #1243 2023-03-14 11:16:39 -04:00
Alessandro
b37be46ba3 fix(tree-item): move label outside of checkbox 2023-03-13 19:52:12 +00:00
Cory LaViska
6e2ea508db update changelog 2023-03-13 11:48:35 -04:00
Jared White
0e6e2abd28 Export autoload discover function and support shadow roots (#1236)
* Export autoload discover function and support shadow roots

* run prettier
2023-03-13 11:47:37 -04:00
Cory LaViska
db1bdfbf65 update changelog 2023-03-13 10:58:01 -04:00
Alessandro
7bf0f647b3 fix(carousel): change the way slide position is computed (#1235) 2023-03-13 10:56:00 -04:00
Cory LaViska
df25f8617b 2.3.0 2023-03-09 16:19:23 -05:00
Cory LaViska
ad2099a27f update version 2023-03-09 16:19:13 -05:00
Martin Alix
708127f96d Update French for Slide # (#1231) 2023-03-09 16:10:06 -05:00
Cory LaViska
9deb51e95a update docs 2023-03-09 16:09:33 -05:00
Cory LaViska
67852ea657 update installation docs 2023-03-07 16:52:02 -05:00
Cory LaViska
7240f4f8f4 Merge branch 'next' into autoload 2023-03-07 14:05:26 -05:00
Cory LaViska
17ee89a5e8 rename variable for clarity 2023-03-07 13:23:02 -05:00
Cory LaViska
f2177dccaf closes #1226 2023-03-07 11:03:03 -05:00
Cory LaViska
6aaf17b81a fixes #1224 2023-03-06 17:11:39 -05:00
dhellgartner
d113d13792 Fixed the avatar tests to produce less logs (#1222)
The reason for the problems is that the error event does
not escape from the shadow dom.
Thus it cannot be awaited for in the test

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-03-06 08:30:01 -05:00
Cory LaViska
ab9cb5f185 update changelog 2023-03-03 10:56:55 -05:00
Cory LaViska
76fd7aa28d trigger update immediately 2023-03-03 10:55:53 -05:00
Cory LaViska
8f17bf4e9d Improve Carousel Accessibility (#1218)
* fix demo

* improve accessibility, reorg, and polish up

* add support for up/down

* fix docs

* update docs
2023-03-03 10:53:17 -05:00
Cory LaViska
0f0f71af9b add custom-elements.json to exports 2023-03-03 10:36:30 -05:00
Cory LaViska
e624701022 fixes #1220 2023-03-03 10:16:15 -05:00
Cory LaViska
4cedfc3201 fix check 2023-03-02 11:43:09 -05:00
Cory LaViska
d88d9fc81a update example 2023-03-02 11:43:00 -05:00
Cory LaViska
051baa4ff5 remove warning 2023-03-02 11:27:47 -05:00
Cory LaViska
57c3d7009b fix example 2023-03-02 11:27:42 -05:00
Cory LaViska
a27fd4d2e9 Merge branch 'next' into autoload 2023-03-02 10:49:30 -05:00
Cory LaViska
79ac425e2b fix demo 2023-03-01 12:59:14 -05:00
Cory LaViska
857f318f9c fix overscroll (#1217) 2023-03-01 11:48:16 -05:00
Cory LaViska
c0966bf767 remove unused property 2023-03-01 11:35:25 -05:00
Cory LaViska
86cecc9e30 fix carousel pagination in iOS 2023-03-01 11:34:42 -05:00
Alessandro
ec036d8e61 fix(carousel): various fixes and improvements (#1216)
* fix(carousel): don't resume autoplay if interacting

* fix(carousel): wrap pagination items

* chore(carousel): add unit tests

* feat(carousel): more reactive pagination dots

* fix(carousel): trigger scrollend when user scroll exactly over a snap point
2023-03-01 11:05:29 -05:00
Cory LaViska
77b25f4581 add tag parts to <sl-select> 2023-03-01 10:58:24 -05:00
Cory LaViska
a8d59b3329 update changelog 2023-02-28 17:11:21 -05:00
Cory LaViska
2371c5490f Merge branch 'next' into autoload 2023-02-28 12:45:23 -05:00
Cory LaViska
a127b8722e fix autoload timing issues 2023-02-22 14:18:43 -05:00
Cory LaViska
9c573fb454 add autoloader to docs 2023-02-22 14:18:36 -05:00
Cory LaViska
a346d18930 add autoloader docs 2023-02-22 14:18:19 -05:00
Cory LaViska
a32488baeb add autoloader prototype 2023-02-22 14:18:04 -05:00
Cory LaViska
a4131caeda add subpath 2023-02-22 14:16:11 -05:00
Cory LaViska
6c62a4f4c0 use passive listeners 2023-02-22 12:54:33 -05:00
78 changed files with 1125 additions and 477 deletions

View File

@@ -9,9 +9,13 @@
"atrule",
"autocorrect",
"autofix",
"autoload",
"autoloader",
"autoloading",
"autoplay",
"bezier",
"boxicons",
"CACHEABLE",
"callout",
"callouts",
"chatbubble",
@@ -36,6 +40,7 @@
"datetime",
"describedby",
"Docsify",
"dogfood",
"dropdowns",
"easings",
"enterkeyhint",
@@ -107,6 +112,8 @@
"reregister",
"resizer",
"resizers",
"retargeted",
"RETRYABLE",
"rgba",
"roadmap",
"Roboto",

View File

@@ -436,6 +436,9 @@
result += `
## Importing
If you're using the autoloader or the traditional loader, you can ignore this section. Otherwise, feel free to
use any of the following snippets to [cherry pick](getting-started/installation#cherry-picking) this component.
<sl-tab-group>
<sl-tab slot="nav" panel="script">Script</sl-tab>
<sl-tab slot="nav" panel="import">Import</sl-tab>

View File

@@ -8,6 +8,17 @@ html {
box-sizing: inherit;
}
/* Show custom elements only after they're registered */
:not(:defined),
:not(:defined) * {
opacity: 0;
}
:defined {
opacity: 1;
transition: 0.1s opacity;
}
body {
font-family: var(--sl-font-sans);
font-size: var(--sl-font-size-medium);

View File

@@ -533,7 +533,7 @@ const App = () => (
### Adding and Removing Slides
The content of the carousel can be changed by appending or removing carousel items. The carousel will update itself automatically.
The content of the carousel can be changed by adding or removing carousel items. The carousel will update itself automatically.
```html preview
<sl-carousel class="dynamic-carousel" pagination navigation>
@@ -582,12 +582,19 @@ The content of the carousel can be changed by appending or removing carousel ite
slide.innerText = `Slide ${dynamicCarousel.children.length + 1}`;
slide.style.setProperty('background', `var(--sl-color-${color}-200)`);
dynamicCarousel.appendChild(slide);
dynamicRemove.disabled = false;
};
const removeSlide = () => {
const slide = dynamicCarousel.children[dynamicCarousel.children.length - 1];
slide.remove();
colorIndex--;
const numSlides = dynamicCarousel.querySelectorAll('sl-carousel-item').length;
if (numSlides > 1) {
slide.remove();
colorIndex--;
}
dynamicRemove.disabled = numSlides - 1 <= 1;
};
dynamicAdd.addEventListener('click', addSlide);
@@ -656,9 +663,7 @@ const App = () => {
### Vertical Scrolling
Setting the `orientation` attribute to `vertical`, will make the carousel laying out vertically, making it
possible for the user to scroll it up and down. In case of heterogeneous content, for example images of different sizes,
it's important to specify a predefined height to the carousel through CSS.
Setting the `orientation` attribute to `vertical` will render the carousel in a vertical layout. If the content of your slides vary in height, you will need to set amn explicit `height` or `max-height` on the carousel using CSS.
```html preview
<sl-carousel class="vertical" pagination orientation="vertical">
@@ -895,7 +900,7 @@ const App = () => {
### Scroll Hint
Use the `--scroll-hint` attribute to add inline padding in horizontal carousels and block padding in vertical carousels. Setting a padding will make the closest slides slightly visible, hinting that there are more items in the carousel.
Use the `--scroll-hint` custom property to add inline padding in horizontal carousels and block padding in vertical carousels. This will make the closest slides slightly visible, hinting that there are more items in the carousel.
```html preview
<sl-carousel class="scroll-hint" pagination style="--scroll-hint: 10%;">

View File

@@ -100,7 +100,8 @@ Use the `setCustomValidity()` method to set a custom validation message. This wi
const errorMessage = `Don't forget to check me!`;
// Set initial validity as soon as the element is defined
customElements.whenDefined('sl-checkbox').then(() => {
customElements.whenDefined('sl-checkbox').then(async () => {
await checkbox.updateComplete;
checkbox.setCustomValidity(errorMessage);
});

View File

@@ -17,8 +17,10 @@ QR codes are useful for providing small pieces of information to users who can q
const qrCode = container.querySelector('sl-qr-code');
const input = container.querySelector('sl-input');
input.value = qrCode.value;
input.addEventListener('sl-input', () => (qrCode.value = input.value));
customElements.whenDefined('sl-qr-code').then(() => {
input.value = qrCode.value;
input.addEventListener('sl-input', () => (qrCode.value = input.value));
});
</script>
<style>

View File

@@ -175,7 +175,7 @@ Use the `setCustomValidity()` method to set a custom validation message. This wi
const errorMessage = 'You must choose the last option';
// Set initial validity as soon as the element is defined
customElements.whenDefined('sl-radio-group').then(() => {
customElements.whenDefined('sl-radio').then(() => {
radioGroup.setCustomValidity(errorMessage);
});

View File

@@ -41,6 +41,38 @@ import { AppComponent } from './app.component';
export class AppModule {}
```
## Reference Shoelace components in your Angular component code
```js
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>'
})
export class DrawerExampleComponent implements OnInit {
// use @ViewChild to get a reference to the #drawer element within component template
@ViewChild('drawer')
drawer?: ElementRef<SlDrawer>;
...
constructor(...) {
}
ngOnInit() {
}
...
showDrawer() {
// use nativeElement to access Shoelace components
this.drawer?.nativeElement.show();
}
}
```
Now you can start using Shoelace components in your app!
?> Are you using Shoelace with Angular? [Help us improve this page!](https://github.com/shoelace-style/shoelace/blob/next/docs/frameworks/angular.md)

View File

@@ -58,7 +58,7 @@ Now you can start using Shoelace components in your app!
### QR code generator example
```vue
```html
<template>
<div class="container">
<h1>QR code generator</h1>
@@ -70,22 +70,22 @@ Now you can start using Shoelace components in your app!
</template>
<script setup>
import { ref } from 'vue';
import '@shoelace-style/shoelace/dist/components/qr-code/qr-code.js';
import '@shoelace-style/shoelace/dist/components/input/input.js';
import { ref } from 'vue';
import '@shoelace-style/shoelace/dist/components/qr-code/qr-code.js';
import '@shoelace-style/shoelace/dist/components/input/input.js';
const qrCode = ref();
const qrCode = ref();
</script>
<style>
.container {
max-width: 400px;
margin: 0 auto;
}
.container {
max-width: 400px;
margin: 0 auto;
}
sl-input {
margin: var(--sl-spacing-large) 0;
}
sl-input {
margin: var(--sl-spacing-large) 0;
}
</style>
```
@@ -98,3 +98,18 @@ When binding complex data such as objects and arrays, use the `.prop` modifier t
```
?> Are you using Shoelace with Vue? [Help us improve this page!](https://github.com/shoelace-style/shoelace/blob/next/docs/frameworks/vue.md)
### Slots
To use Shoelace components with slots, follow the Vue documentation on using [slots with custom elements](https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue).
Here is an example:
```html
<sl-drawer label="Drawer" placement="start" class="drawer-placement-start" :open="drawerIsOpen">
This drawer slides in from the start.
<div slot="footer">
<sl-button variant="primary" @click=" drawerIsOpen = false">Close</sl-button>
</div>
</sl-drawer>
```

View File

@@ -1,32 +1,50 @@
# Installation
You can use Shoelace via CDN or by installing it locally. You can also [cherry pick](#cherry-picking) individual components for faster load times.
If you're using a framework, make sure to check out the pages for [React](/frameworks/react), [Vue](/frameworks/vue), and [Angular](/frameworks/angular).
You can load Shoelace via CDN or by installing it locally. If you're using a framework, make sure to check out the pages for [React](/frameworks/react), [Vue](/frameworks/vue), and [Angular](/frameworks/angular) for additional information.
## CDN Installation (Easiest)
The easiest way to install Shoelace is with the CDN. Just add the following tags to your page to get all components and the default light theme.
<sl-tab-group>
<sl-tab slot="nav" panel="autoloader" active>Autoloader</sl-tab>
<sl-tab slot="nav" panel="traditional">Traditional Loader</sl-tab>
<sl-tab-panel name="autoloader">
The experimental autoloader is the easiest and most efficient way to use Shoelace. A lightweight script watches the DOM for unregistered Shoelace elements and lazy loads them for you — even if they're added dynamically.
While convenient, autoloading may lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/). The linked article describes some ways to alleviate it.
<!-- prettier-ignore -->
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace-autoloader.js"></script>
```
</sl-tab-panel>
<sl-tab-panel name="traditional">
The traditional CDN loader registers all Shoelace elements up front. Note that, if you're only using a handful of components, it will be much more efficient to stick with the autoloader. However, you can also [cherry pick](#cherry-picking) components if you want to load specific ones up front.
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```
?> If you're only using a handful of components, it will be more efficient to [cherry pick](#cherry-picking) the ones you need.
</sl-tab-panel>
</sl-tab-group>
### Dark Theme
If you prefer to use the [dark theme](/getting-started/themes#dark-theme) instead, use this code and add `<html class="sl-theme-dark">` to the page.
The code above will load the light theme. If you want to use the [dark theme](/getting-started/themes#dark-theme) instead, update the stylesheet as shown below and add `<html class="sl-theme-dark">` to your page.
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/dark.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```
### Light & Dark Theme
If you want to load the light or dark theme based on the user's `prefers-color-scheme` setting, use this. The `media` attributes ensure that only the user's preferred theme stylesheet loads and the `onload` attribute sets the appropriate [theme class](/getting-started/themes) on the `<html>` element.
If you want to load the light or dark theme based on the user's `prefers-color-scheme` setting, use the stylesheets below. The `media` attributes ensure that only the user's preferred theme stylesheet loads and the `onload` attribute sets the appropriate [theme class](/getting-started/themes) on the `<html>` element.
```html
<link
@@ -40,7 +58,6 @@ If you want to load the light or dark theme based on the user's `prefers-color-s
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/dark.css"
onload="document.documentElement.classList.add('sl-theme-dark');"
/>
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```
Now you can [start using Shoelace!](/getting-started/usage)
@@ -68,7 +85,7 @@ Alternatively, [you can use a bundler](#bundling).
## Setting the Base Path
Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` and will "just work" for most users.
Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` or `shoelace-autoloader.js` and will "just work" for most users.
However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Shoelace, you'll need to set the base path. You can do this one of two ways.
@@ -88,9 +105,7 @@ However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Sh
## Cherry Picking
The previous approach is the _easiest_ way to load Shoelace, but easy isn't always efficient. You'll incur the full size of the library even if you only use a handful of components. This is convenient for prototyping or if you're using most of the components, but it may result in longer load times in production. To improve this, you can cherry pick the components you need.
Cherry picking can be done from your local install or [directly from the CDN](https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/). This will limit the number of files the browser has to download and reduce the amount of bytes being transferred. The disadvantage is that you need to load components manually.
Cherry picking can be done from [the CDN](#cdn-installation-easiest) or your [local installation](#local-installation). This approach will load only the components you need up front, while limiting the number of files the browser has to download. The disadvantage is that you need to import each individual component.
Here's an example that loads only the button component. Again, if you're not using a module resolver, you'll need to adjust the path to point to the folder Shoelace is in.

View File

@@ -32,9 +32,10 @@ Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska).
Add the following code to your page.
<!-- prettier-ignore -->
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace-autoloader.js"></script>
```
Now you have access to all of Shoelace's components! Try adding a button:
@@ -43,7 +44,7 @@ Now you have access to all of Shoelace's components! Try adding a button:
<sl-button>Click me</sl-button>
```
?> This will load all of Shoelace's components, but you should probably only load the ones you're actually using. To learn how, or for other ways to install Shoelace, refer to the [installation instructions](getting-started/installation).
?> This will activate Shoelace's experimental autoloader, which registers components on the fly as you use them. To learn more about it, or for other ways to install Shoelace, refer to the [installation instructions](getting-started/installation).
## New to Web Components?

View File

@@ -33,7 +33,9 @@ Refer to a component's documentation for a complete list of its properties.
## Events
You can listen for standard events such as `click`, `mouseover`, etc. as you normally would. In addition, some components emit custom events. These work the same way as standard events, but are prefixed with `sl-` to prevent collisions with standard events and other libraries.
You can listen for standard events such as `click`, `mouseover`, etc. as you normally would. However, it's important to note that many events emitted within a component's shadow root will be [retargeted](https://dom.spec.whatwg.org/#retarget) to the host element. This may result in, for example, multiple `click` handlers executing even if the user clicks just once. Furthermore, `event.target` will point to the host element, making things even more confusing.
As a result, you should almost always listen for custom events instead. For example, instead of listening to `click` to determine when an `<sl-checkbox>` gets toggled, listen to `sl-change`.
```html
<sl-checkbox>Check me</sl-checkbox>
@@ -46,7 +48,7 @@ You can listen for standard events such as `click`, `mouseover`, etc. as you nor
</script>
```
Refer to a component's documentation for a complete list of its custom events.
All custom events are prefixed with `sl-` to prevent collisions with standard events and other libraries. Refer to a component's documentation for a complete list of its custom events.
## Methods

View File

@@ -39,9 +39,9 @@
<!-- Import Shoelace -->
<link rel="stylesheet" href="/dist/themes/light.css" />
<link rel="stylesheet" href="/dist/themes/dark.css" />
<script type="module" src="/dist/shoelace.js"></script>
<script type="module" src="/dist/shoelace-autoloader.js"></script>
</head>
<body data-shoelace="/dist">
<body>
<div id="app"></div>
<script>
// Set the initial theme to prevent flashing

View File

@@ -6,15 +6,48 @@ Components with the <sl-badge variant="warning" pill>Experimental</sl-badge> bad
New versions of Shoelace are released as-needed and generally occur when a critical mass of changes have accumulated. At any time, you can see what's coming in the next release by visiting [next.shoelace.style](https://next.shoelace.style).
## 2.4.0
- Added the `discover()` function to the experimental autoloader's exports [#1236](https://github.com/shoelace-style/shoelace/pull/1236)
- 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
- Added an experimental autoloader
- Added the `subpath` argument to `getBasePath()` to make it easier to generate full paths to any file
- Added `custom-elements.json` to package exports
- Added `tag__base`, `tag__content`, `tag__remove-button`, `tag__remove-button__base` parts to `<sl-select>`
- Fixed a bug in `<sl-rating>` that allowed the `sl-change` event to be emitted when disabled [#1220](https://github.com/shoelace-style/shoelace/issues/1220)
- Fixed a regression in `<sl-input>` that caused `min` and `max` to stop working when `type="date"` [#1224](https://github.com/shoelace-style/shoelace/issues/1224)
- Improved accessibility of `<sl-carousel>` [#1218](https://github.com/shoelace-style/shoelace/pull/1218)
- Improved `<sl-option>` so it converts non-string values to strings for convenience [#1226](https://github.com/shoelace-style/shoelace/issues/1226)
- Updated the docs to dogfood the autoloader
## 2.2.0
- Added TypeScript types to all custom events [#1183](https://github.com/shoelace-style/shoelace/pull/1183)
- Added the `svg` part to `<sl-icon>`
- Added the `getForm()` method to all form controls [#1180](https://github.com/shoelace-style/shoelace/issues/1180)
- Added the experimental carousel component [#851](https://github.com/shoelace-style/shoelace/pull/851)
- Fixed a bug in `<sl-select>` that caused the display label to render incorrectly in Chrome after form validation [#1197](https://github.com/shoelace-style/shoelace/discussions/1197)
- Fixed a bug in `<sl-input>` that prevented users from applying their own value for `autocapitalize`, `autocomplete`, and `autocorrect` when using `type="password` [#1205](https://github.com/shoelace-style/shoelace/issues/1205)
- Fixed a bug in `<sl-tab-group>` that prevented scroll controls from showing when dynamically adding tabs [#1208](https://github.com/shoelace-style/shoelace/issues/1208)
- Fixed a big in `<sl-input>` that caused the calendar icon to be clipped in Firefox [#1213](https://github.com/shoelace-style/shoelace/pull/1213)
- Fixed a bug in `<sl-input>` that caused the calendar icon to be clipped in Firefox [#1213](https://github.com/shoelace-style/shoelace/pull/1213)
- Fixed a bug in `<sl-tab>` that caused `sl-tab-show` to be emitted when activating the close button
- Fixed a bug in `<sl-spinner>` that caused `--track-color` to be invisible with certain colors
- Fixed a bug in `<sl-menu-item>` that caused the focus color to show when selecting menu items with a mouse or touch device

View File

@@ -363,3 +363,26 @@ Avoid inlining SVG icons inside of templates. If a component requires an icon, m
```
This will render the icons instantly whereas the default library will fetch them from a remote source. If an icon isn't available in the system library, you will need to add it to `library.system.ts`. Using the system library ensures that all icons load instantly and are customizable by users who wish to provide a custom resolver for the system library.
### Writing tests
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 (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>`);
await expect(myComponent).to.be.accessible();
```
- Try to cover all features advertised in the component's description
Guidelines for writing tests:
- Each test should declare its own, hand crafted hml fixture for the component. Do not try to write one big component to match all tests. This helps keeping each test understandable in isolation.
- Tests should not produce log lines. Note that sometimes this cannot be prevented as the test runner might log errors (e.g. 404s).
- Try keeping the main test readable: Extract more complicated sets of selectors/commands/assertions into separate functions.
- Try to aim testing the user facing features of the component instead of the internal workings of the component.
- Group multiple tests for one feature into describe blocks.

5
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@shoelace-style/shoelace",
"version": "2.2.0",
"version": "2.4.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@shoelace-style/shoelace",
"version": "2.2.0",
"version": "2.4.0",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
@@ -56,7 +56,6 @@
"lint-staged": "^13.1.0",
"lunr": "^2.3.9",
"npm-check-updates": "^16.6.2",
"open": "^8.4.0",
"pascal-case": "^3.1.2",
"plop": "^3.1.1",
"prettier": "^2.8.2",

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.2.0",
"version": "2.4.0",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",
@@ -14,6 +14,7 @@
"types": "./dist/shoelace.d.ts",
"import": "./dist/shoelace.js"
},
"./dist/custom-elements.json": "./dist/custom-elements.json",
"./dist/themes/*": "./dist/themes/*",
"./dist/components/*": "./dist/components/*",
"./dist/utilities/*": "./dist/utilities/*",
@@ -109,7 +110,6 @@
"lint-staged": "^13.1.0",
"lunr": "^2.3.9",
"npm-check-updates": "^16.6.2",
"open": "^8.4.0",
"pascal-case": "^3.1.2",
"plop": "^3.1.1",
"prettier": "^2.8.2",

View File

@@ -7,7 +7,6 @@ import esbuild from 'esbuild';
import fs from 'fs';
import getPort, { portNumbers } from 'get-port';
import { globby } from 'globby';
import open from 'open';
import copy from 'recursive-copy';
const { bundle, copydir, dir, serve, types } = commandLineArgs([
@@ -51,6 +50,8 @@ fs.mkdirSync(outdir, { recursive: true });
//
// The whole shebang
'./src/shoelace.ts',
// The auto-loader
'./src/shoelace-autoloader.ts',
// Components
...(await globby('./src/components/**/!(*.(style|test)).ts')),
// Translations
@@ -106,7 +107,6 @@ fs.mkdirSync(outdir, { recursive: true });
deleteSync('docs/dist');
const browserSyncConfig = {
open: false,
startPath: '/',
port,
logLevel: 'silent',
@@ -120,6 +120,22 @@ fs.mkdirSync(outdir, { recursive: true });
routes: {
'/dist': './dist'
}
},
//
// Suppress Chrome's document.write() warning
//
// More info: https://github.com/BrowserSync/browser-sync/issues/1600)
//
snippetOptions: {
rule: {
match: /<\/head>/u,
fn: (snippet, match) => {
const {
groups: { src }
} = /src='(?<src>[^']+)'/u.exec(snippet);
return `<script src="${src}" async></script>${match}`;
}
}
}
};
@@ -127,7 +143,6 @@ fs.mkdirSync(outdir, { recursive: true });
bs.init(browserSyncConfig, () => {
const url = `http://localhost:${port}`;
console.log(chalk.cyan(`Launched the Shoelace dev server at ${url} 🥾\n`));
open(url);
});
// Rebuild and reload when source files change

View File

@@ -1,9 +1,70 @@
import { expect, fixture, html } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import type SlAnimatedImage from './animated-image';
describe('<sl-animated-image>', () => {
it('should render a component', async () => {
const el = await fixture(html` <sl-animated-image></sl-animated-image> `);
const animatedImage = await fixture(html` <sl-animated-image></sl-animated-image> `);
expect(el).to.exist;
expect(animatedImage).to.exist;
});
it('should render be accessible', async () => {
const animatedImage = await fixture(html` <sl-animated-image></sl-animated-image> `);
await expect(animatedImage).to.be.accessible();
});
const files = ['docs/assets/images/walk.gif', 'docs/assets/images/tie.webp'];
files.forEach((file: string) => {
it(`should load a ${file} without errors`, async () => {
const animatedImage = await fixture<SlAnimatedImage>(html` <sl-animated-image></sl-animated-image> `);
let errorCount = 0;
oneEvent(animatedImage, 'sl-error').then(() => errorCount++);
await loadImage(animatedImage, file);
expect(errorCount).to.be.equal(0);
});
it(`should play ${file} on click`, async () => {
const animatedImage = await fixture<SlAnimatedImage>(html` <sl-animated-image></sl-animated-image> `);
await loadImage(animatedImage, file);
expect(animatedImage.play).not.to.be.true;
await clickOnElement(animatedImage);
expect(animatedImage.play).to.be.true;
});
it(`should pause and resume ${file} on click`, async () => {
const animatedImage = await fixture<SlAnimatedImage>(html` <sl-animated-image></sl-animated-image> `);
await loadImage(animatedImage, file);
animatedImage.play = true;
await clickOnElement(animatedImage);
expect(animatedImage.play).to.be.false;
await clickOnElement(animatedImage);
expect(animatedImage.play).to.be.true;
});
});
it('should emit an error event on invalid url', async () => {
const animatedImage = await fixture<SlAnimatedImage>(html` <sl-animated-image></sl-animated-image> `);
const errorPromise = oneEvent(animatedImage, 'sl-error');
animatedImage.src = 'completelyWrong';
await errorPromise;
});
});
async function loadImage(animatedImage: SlAnimatedImage, file: string) {
const loadingPromise = oneEvent(animatedImage, 'sl-load');
animatedImage.src = file;
await loadingPromise;
}

View File

@@ -0,0 +1,81 @@
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import type SlAnimation from './animation';
describe('<sl-animation>', () => {
const boxToAnimate = html`<div style="width: 10px; height: 10px;" data-testid="animated-box"></div>`;
it('renders', async () => {
const animationContainer = await fixture<SlAnimation>(html`<sl-animation>${boxToAnimate}</sl-animation>`);
expect(animationContainer).to.exist;
});
it('is accessible', async () => {
const animationContainer = await fixture<SlAnimation>(html`<sl-animation>${boxToAnimate}</sl-animation>`);
await expect(animationContainer).to.be.accessible();
});
describe('animation start', () => {
it('does not start the animation by default', async () => {
const animationContainer = await fixture<SlAnimation>(
html`<sl-animation name="bounce" easing="ease-in-out" duration="10">${boxToAnimate}</sl-animation>`
);
await aTimeout(0);
expect(animationContainer.play).to.be.false;
});
it('emits the correct event on animation start', async () => {
const animationContainer = await fixture<SlAnimation>(
html`<sl-animation name="bounce" easing="ease-in-out" duration="10">${boxToAnimate}</sl-animation>`
);
const startPromise = oneEvent(animationContainer, 'sl-start');
animationContainer.play = true;
return startPromise;
});
});
it('emits the correct event on animation end', async () => {
const animationContainer = await fixture<SlAnimation>(
html`<sl-animation name="bounce" easing="ease-in-out" duration="1">${boxToAnimate}</sl-animation>`
);
const endPromise = oneEvent(animationContainer, 'sl-finish');
animationContainer.iterations = 1;
animationContainer.play = true;
return endPromise;
});
it('can be finished by hand', async () => {
const animationContainer = await fixture<SlAnimation>(
html`<sl-animation name="bounce" easing="ease-in-out" duration="1000">${boxToAnimate}</sl-animation>`
);
const endPromise = oneEvent(animationContainer, 'sl-finish');
animationContainer.iterations = 1;
animationContainer.play = true;
await aTimeout(0);
animationContainer.finish();
return endPromise;
});
it('can be cancelled', async () => {
const animationContainer = await fixture<SlAnimation>(
html`<sl-animation name="bounce" easing="ease-in-out" duration="1">${boxToAnimate}</sl-animation>`
);
let animationHasFinished = false;
oneEvent(animationContainer, 'sl-finish').then(() => (animationHasFinished = true));
const cancelPromise = oneEvent(animationContainer, 'sl-cancel');
animationContainer.play = true;
await aTimeout(0);
animationContainer.cancel();
await cancelPromise;
expect(animationHasFinished).to.be.false;
});
});

View File

@@ -1,5 +1,4 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import type SlAvatar from './avatar';
// The default avatar background just misses AA contrast, but the next step up is way too dark. Since avatars aren't
@@ -73,6 +72,46 @@ describe('<sl-avatar>', () => {
});
});
describe('when image is present, the initials or icon part should not render', () => {
const initials = 'SL';
const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const label = 'Small transparent square';
before(async () => {
el = await fixture<SlAvatar>(
html`<sl-avatar image="${image}" label="${label}" initials="${initials}"></sl-avatar>`
);
});
it('should pass accessibility tests', async () => {
/**
* The image element itself is ancillary, because it's parent container contains the
* aria-label which dictates what "sl-avatar" is. This also implies that label text will
* resolve to "" when not provided and ignored by readers. This is why we use alt="" on
* the image element to pass accessibility.
* https://html.spec.whatwg.org/multipage/images.html#ancillary-images
*/
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "image" part, with src and a role of presentation', () => {
const part = el.shadowRoot!.querySelector('[part~="image"]')!;
expect(part.getAttribute('src')).to.eq(image);
});
it('should not render the initials part', () => {
const part = el.shadowRoot!.querySelector<HTMLElement>('[part~="initials"]')!;
expect(part).to.not.exist;
});
it('should not render the icon part', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=icon]')!;
expect(slot).to.not.exist;
});
});
['square', 'rounded', 'circle'].forEach(shape => {
describe(`when passed a shape attribute ${shape}`, () => {
before(async () => {
@@ -113,23 +152,20 @@ describe('<sl-avatar>', () => {
});
it('should not render the image when the image fails to load', async () => {
const errorHandler = sinon.spy();
el = await fixture<SlAvatar>(html`<sl-avatar></sl-avatar>`);
el.addEventListener('error', errorHandler);
el.image = 'bad_image';
waitUntil(() => errorHandler.calledOnce);
await aTimeout(0);
await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
expect(el.shadowRoot!.querySelector('img')).to.be.null;
});
it('should show a valid image after being passed an invalid image initially', async () => {
const errorHandler = sinon.spy();
el = await fixture<SlAvatar>(html`<sl-avatar></sl-avatar>`);
el.addEventListener('error', errorHandler);
el.image = 'bad_image';
waitUntil(() => errorHandler.calledOnce);
await aTimeout(0);
await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
el.image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
await el.updateComplete;

View File

@@ -52,6 +52,29 @@ export default class SlAvatar extends ShoelaceElement {
}
render() {
const avatarWithImage = html`
<img
part="image"
class="avatar__image"
src="${this.image}"
loading="${this.loading}"
alt=""
@error="${() => (this.hasError = true)}"
/>
`;
let avatarWithoutImage = html``;
if (this.initials) {
avatarWithoutImage = html`<div part="initials" class="avatar__initials">${this.initials}</div>`;
} else {
avatarWithoutImage = html`
<slot name="icon" part="icon" class="avatar__icon" aria-hidden="true">
<sl-icon name="person-fill" library="system"></sl-icon>
</slot>
`;
}
return html`
<div
part="base"
@@ -64,25 +87,7 @@ export default class SlAvatar extends ShoelaceElement {
role="img"
aria-label=${this.label}
>
${this.initials
? html` <div part="initials" class="avatar__initials">${this.initials}</div> `
: html`
<slot name="icon" part="icon" class="avatar__icon" aria-hidden="true">
<sl-icon name="person-fill" library="system"></sl-icon>
</slot>
`}
${this.image && !this.hasError
? html`
<img
part="image"
class="avatar__image"
src="${this.image}"
loading="${this.loading}"
alt=""
@error="${() => (this.hasError = true)}"
/>
`
: ''}
${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
</div>
`;
}

View File

@@ -25,8 +25,7 @@ export default class SlCarouselItem extends ShoelaceElement {
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'listitem');
this.setAttribute('aria-roledescription', 'slide');
this.setAttribute('role', 'group');
}
render() {

View File

@@ -8,6 +8,7 @@ export class AutoplayController implements ReactiveController {
private host: ReactiveElement;
private timerId = 0;
private tickCallback: () => void;
private activeInteractions = 0;
paused = false;
stopped = true;
@@ -57,12 +58,16 @@ export class AutoplayController implements ReactiveController {
}
pause = () => {
this.paused = true;
this.host.requestUpdate();
if (!this.activeInteractions++) {
this.paused = true;
this.host.requestUpdate();
}
};
resume = () => {
this.paused = false;
this.host.requestUpdate();
if (!--this.activeInteractions) {
this.paused = false;
this.host.requestUpdate();
}
};
}

View File

@@ -28,8 +28,8 @@ export default css`
.carousel__pagination {
grid-area: pagination;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--sl-spacing-small);
}
@@ -46,6 +46,7 @@ export default css`
overscroll-behavior-x: contain;
scrollbar-width: none;
aspect-ratio: calc(var(--aspect-ratio) * var(--slides-per-page));
border-radius: var(--sl-border-radius-small);
--slide-size: calc((100% - (var(--slides-per-page) - 1) * var(--slide-gap)) / var(--slides-per-page));
}
@@ -64,6 +65,7 @@ export default css`
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--scroll-hint);
padding-inline: var(--scroll-hint);
overflow-y: hidden;
}
.carousel__slides--vertical {
@@ -74,6 +76,7 @@ export default css`
scroll-snap-type: y mandatory;
scroll-padding-block: var(--scroll-hint);
padding-block: var(--scroll-hint);
overflow-x: hidden;
}
.carousel__slides--dragging,
@@ -101,7 +104,7 @@ export default css`
align-items: center;
background: none;
border: none;
border-radius: var(--sl-border-radius-medium);
border-radius: var(--sl-border-radius-small);
font-size: inherit;
color: var(--sl-color-neutral-600);
padding: var(--sl-spacing-x-small);
@@ -138,12 +141,20 @@ export default css`
width: var(--sl-spacing-small);
height: var(--sl-spacing-small);
background-color: var(--sl-color-neutral-300);
will-change: transform;
transition: var(--sl-transition-fast) ease-in;
padding: 0;
margin: 0;
}
.carousel__pagination-item--active {
background-color: var(--sl-color-neutral-600);
background-color: var(--sl-color-neutral-700);
transform: scale(1.2);
}
/* Focus styles */
.carousel__slides:focus-visible,
.carousel__navigation-button:focus-visible,
.carousel__pagination-item:focus-visible {
outline: var(--sl-focus-ring);
outline-offset: var(--sl-focus-ring-offset);
}
`;

View File

@@ -17,7 +17,7 @@ describe('<sl-carousel>', () => {
// Assert
expect(el).to.exist;
expect(el).to.have.attribute('role', 'region');
expect(el).to.have.attribute('aria-roledescription', 'carousel');
expect(el).to.have.attribute('aria-label', 'Carousel');
expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist;
expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist;
});
@@ -78,6 +78,34 @@ describe('<sl-carousel>', () => {
// Assert
expect(el.next).not.to.have.been.called;
});
it('should not resume if the user is still interacting', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel autoplay autoplay-interval="10">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
sinon.stub(el, 'next');
await el.updateComplete;
// Act
el.dispatchEvent(new Event('mouseenter'));
el.dispatchEvent(new Event('focusin'));
await el.updateComplete;
el.dispatchEvent(new Event('mouseleave'));
await el.updateComplete;
clock.next();
clock.next();
// Assert
expect(el.next).not.to.have.been.called;
});
});
describe('when `loop` attribute is provided', () => {
@@ -511,7 +539,6 @@ describe('<sl-carousel>', () => {
// Assert
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
expect(el.scrollContainer).to.have.attribute('aria-live', 'polite');
expect(el.scrollContainer).to.have.attribute('aria-atomic', 'true');
expect(pagination).to.have.attribute('role', 'tablist');
@@ -557,45 +584,5 @@ describe('<sl-carousel>', () => {
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
});
});
describe('when autoplay is active', () => {
it('should disable live announcement', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel autoplay>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
await el.updateComplete;
// Assert
expect(el.scrollContainer).to.have.attribute('aria-live', 'off');
});
describe('and user is interacting with the carousel', () => {
it('should enable live announcement', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel autoplay>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
await el.updateComplete;
// Act
el.dispatchEvent(new Event('focusin'));
await el.updateComplete;
// Assert
expect(el.scrollContainer).to.have.attribute('aria-live', 'polite');
});
});
});
});
});

View File

@@ -10,7 +10,6 @@ import { prefersReducedMotion } from '../../internal/animate';
import { range } from 'lit/directives/range.js';
import { ScrollController } from './scroll-controller';
import { watch } from '../../internal/watch';
import { when } from 'lit/directives/when.js';
import ShoelaceElement from '../../internal/shoelace-element';
import SlCarouselItem from '../carousel-item/carousel-item';
import styles from './carousel.styles';
@@ -19,7 +18,7 @@ import type { CSSResultGroup } from 'lit';
/**
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
*
* @since 2.0
* @since 2.2
* @status experimental
*
* @dependency sl-icon
@@ -98,7 +97,7 @@ export default class SlCarousel extends ShoelaceElement {
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'region');
this.setAttribute('aria-roledescription', 'carousel');
this.setAttribute('aria-label', this.localize.term('carousel'));
const intersectionObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
@@ -137,63 +136,61 @@ export default class SlCarousel extends ShoelaceElement {
this.mutationObserver.observe(this, { childList: true, subtree: false });
}
private getPageCount() {
return Math.ceil(this.getSlides().length / this.slidesPerPage);
}
private getCurrentPage() {
return Math.floor(this.activeSlide / this.slidesPerPage);
}
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
}
/**
* Move the carousel backward by `slides-per-move` slides.
*
* @param behavior - The behavior used for scrolling.
*/
previous(behavior: ScrollBehavior = 'smooth') {
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
}
private handleKeyDown(event: KeyboardEvent) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const target = event.target as HTMLElement;
const isRtl = this.localize.dir() === 'rtl';
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
const isNext =
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
const isPrevious =
event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight');
/**
* Move the carousel forward by `slides-per-move` slides.
*
* @param behavior - The behavior used for scrolling.
*/
next(behavior: ScrollBehavior = 'smooth') {
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
}
event.preventDefault();
/**
* Scrolls the carousel to the slide specified by `index`.
*
* @param index - The slide index.
* @param behavior - The behavior used for scrolling.
*/
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
const { slidesPerPage, loop } = this;
if (isPrevious) {
this.previous();
}
const slidesWithClones = this.getSlides({ excludeClones: false });
const normalizedIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
const slide = slidesWithClones[normalizedIndex];
if (isNext) {
this.next();
}
this.scrollContainer.scrollTo({
left: slide.offsetLeft,
top: slide.offsetTop,
behavior: prefersReducedMotion() ? 'auto' : behavior
});
}
if (event.key === 'Home') {
this.goToSlide(0);
}
handleSlotChange(mutations: MutationRecord[]) {
const needsInitialization = mutations.some(mutation =>
[...mutation.addedNodes, ...mutation.removedNodes].some(
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
)
);
if (event.key === 'End') {
this.goToSlide(this.getSlides().length - 1);
}
// Reinitialize the carousel if a carousel item has been added or removed
if (needsInitialization) {
this.initializeSlides();
this.requestUpdate();
if (isFocusInPagination) {
this.updateComplete.then(() => {
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
'[part~="pagination-item--active"]'
);
if (activePaginationItem) {
activePaginationItem.focus();
}
});
}
}
}
handleScrollEnd() {
private handleScrollEnd() {
const slides = this.getSlides();
const entries = [...this.intersectionObserverEntries.values()];
@@ -214,6 +211,20 @@ export default class SlCarousel extends ShoelaceElement {
}
}
private handleSlotChange(mutations: MutationRecord[]) {
const needsInitialization = mutations.some(mutation =>
[...mutation.addedNodes, ...mutation.removedNodes].some(
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
)
);
// Reinitialize the carousel if a carousel item has been added or removed
if (needsInitialization) {
this.initializeSlides();
}
this.requestUpdate();
}
@watch('loop', { waitUntilFirstUpdate: true })
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
initializeSlides() {
@@ -223,11 +234,12 @@ export default class SlCarousel extends ShoelaceElement {
this.intersectionObserverEntries.clear();
// Removes all the cloned elements from the carousel
this.getSlides({ excludeClones: false }).forEach(slide => {
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
intersectionObserver.unobserve(slide);
slide.classList.remove('--in-view');
slide.classList.remove('--is-active');
slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1));
if (slide.hasAttribute('data-clone')) {
slide.remove();
@@ -307,85 +319,62 @@ export default class SlCarousel extends ShoelaceElement {
this.scrollController.mouseDragging = this.mouseDragging;
}
private renderPagination = () => {
/**
* Move the carousel backward by `slides-per-move` slides.
*
* @param behavior - The behavior used for scrolling.
*/
previous(behavior: ScrollBehavior = 'smooth') {
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
}
/**
* Move the carousel forward by `slides-per-move` slides.
*
* @param behavior - The behavior used for scrolling.
*/
next(behavior: ScrollBehavior = 'smooth') {
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
}
/**
* Scrolls the carousel to the slide specified by `index`.
*
* @param index - The slide index.
* @param behavior - The behavior used for scrolling.
*/
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
const { slidesPerPage, loop, scrollContainer } = this;
const slides = this.getSlides();
const slidesCount = slides.length;
const slidesWithClones = this.getSlides({ excludeClones: false });
const { activeSlide, slidesPerPage } = this;
const pagesCount = Math.ceil(slidesCount / slidesPerPage);
const currentPage = Math.floor(activeSlide / slidesPerPage);
// Sets the next index without taking into account clones, if any.
const newActiveSlide = (index + slides.length) % slides.length;
this.activeSlide = newActiveSlide;
return html`
<nav part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
${map(range(pagesCount), index => {
const isActive = index === currentPage;
return html`
<button
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
class="${classMap({
'carousel__pagination-item': true,
'carousel__pagination-item--active': isActive
})}"
aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
role="tab"
@click="${() => this.goToSlide(index * slidesPerPage)}"
></button>
`;
})}
</nav>
`;
};
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
// to normalize the starting index in order to ignore the first nth clones.
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
const nextSlide = slidesWithClones[nextSlideIndex];
private renderNavigation = () => {
const { loop, activeSlide } = this;
const slides = this.getSlides();
const slidesCount = slides.length;
const prevEnabled = loop || activeSlide > 0;
const nextEnabled = loop || activeSlide < slidesCount - 1;
const isLtr = this.localize.dir() === 'ltr';
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const nextSlideRect = nextSlide.getBoundingClientRect();
return html`
<nav part="navigation" class="carousel__navigation">
<button
@click="${prevEnabled ? () => this.previous() : null}"
aria-disabled="${prevEnabled ? 'false' : 'true'}"
aria-controls="scroll-container"
class="${classMap({
'carousel__navigation-button': true,
'carousel__navigation-button--previous': true,
'carousel__navigation-button--disabled': !prevEnabled
})}"
aria-label="${this.localize.term('previousSlide')}"
part="navigation-button navigation-button--previous"
>
<slot name="previous-icon">
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
</slot>
</button>
<button
@click="${nextEnabled ? () => this.next() : null}"
aria-disabled="${nextEnabled ? 'false' : 'true'}"
aria-controls="scroll-container"
class="${classMap({
'carousel__navigation-button': true,
'carousel__navigation-button--next': true,
'carousel__navigation-button--disabled': !nextEnabled
})}"
aria-label="${this.localize.term('nextSlide')}"
part="navigation-button navigation-button--next"
>
<slot name="next-icon">
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
</slot>
</button>
</nav>
`;
};
scrollContainer.scrollTo({
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
behavior: prefersReducedMotion() ? 'auto' : behavior
});
}
render() {
const { autoplayController, scrollController } = this;
const { scrollController, slidesPerPage } = this;
const pagesCount = this.getPageCount();
const currentPage = this.getCurrentPage();
const prevEnabled = this.loop || currentPage > 0;
const nextEnabled = this.loop || currentPage < pagesCount - 1;
const isLtr = this.localize.dir() === 'ltr';
return html`
<div part="base" class="carousel">
@@ -397,18 +386,79 @@ export default class SlCarousel extends ShoelaceElement {
'carousel__slides--horizontal': this.orientation === 'horizontal',
'carousel__slides--vertical': this.orientation === 'vertical'
})}"
@scrollend="${this.handleScrollEnd}"
role="list"
tabindex="0"
style="--slides-per-page: ${this.slidesPerPage};"
aria-live="${!autoplayController.stopped && !autoplayController.paused ? 'off' : 'polite'}"
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
aria-atomic="true"
tabindex="0"
@keydown=${this.handleKeyDown}
@scrollend=${this.handleScrollEnd}
>
<slot></slot>
</div>
${when(this.navigation, this.renderNavigation)} ${when(this.pagination, this.renderPagination)}
${this.navigation
? html`
<div part="navigation" class="carousel__navigation">
<button
part="navigation-button navigation-button--previous"
class="${classMap({
'carousel__navigation-button': true,
'carousel__navigation-button--previous': true,
'carousel__navigation-button--disabled': !prevEnabled
})}"
aria-label="${this.localize.term('previousSlide')}"
aria-controls="scroll-container"
aria-disabled="${prevEnabled ? 'false' : 'true'}"
@click=${prevEnabled ? () => this.previous() : null}
>
<slot name="previous-icon">
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
</slot>
</button>
<button
part="navigation-button navigation-button--next"
class=${classMap({
'carousel__navigation-button': true,
'carousel__navigation-button--next': true,
'carousel__navigation-button--disabled': !nextEnabled
})}
aria-label="${this.localize.term('nextSlide')}"
aria-controls="scroll-container"
aria-disabled="${nextEnabled ? 'false' : 'true'}"
@click=${nextEnabled ? () => this.next() : null}
>
<slot name="next-icon">
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
</slot>
</button>
</div>
`
: ''}
${this.pagination
? html`
<div part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
${map(range(pagesCount), index => {
const isActive = index === currentPage;
return html`
<button
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
class="${classMap({
'carousel__pagination-item': true,
'carousel__pagination-item--active': isActive
})}"
role="tab"
aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
tabindex=${isActive ? '0' : '-1'}
@click=${() => this.goToSlide(index * slidesPerPage)}
@keydown=${this.handleKeyDown}
></button>
`;
})}
</div>
`
: ''}
</div>
`;
}

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', {
@@ -77,6 +78,9 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
})
);
this.host.requestUpdate();
} else {
// otherwise let's wait a bit more
this.handleScrollEnd();
}
}
@@ -85,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

@@ -11,7 +11,7 @@ export default css`
.checkbox {
position: relative;
display: inline-flex;
align-items: top;
align-items: flex-start;
font-family: var(--sl-input-font-family);
font-weight: var(--sl-input-font-weight);
color: var(--sl-input-label-color);

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

@@ -3,7 +3,7 @@ import type { IconLibrary } from './library';
const library: IconLibrary = {
name: 'default',
resolver: name => `${getBasePath()}/assets/icons/${name}.svg`
resolver: name => getBasePath(`assets/icons/${name}.svg`)
};
export default library;

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

@@ -4,20 +4,26 @@ interface IncludeFile {
html: string;
}
const includeFiles = new Map<string, Promise<IncludeFile>>();
const includeFiles = new Map<string, IncludeFile | Promise<IncludeFile>>();
/** Fetches an include file from a remote source. Caching is enabled so the origin is only pinged once. */
export function requestInclude(src: string, mode: 'cors' | 'no-cors' | 'same-origin' = 'cors'): Promise<IncludeFile> {
if (includeFiles.has(src)) {
return includeFiles.get(src)!;
const prev = includeFiles.get(src);
if (prev !== undefined) {
// Promise.resolve() transparently unboxes prev if it was a promise.
return Promise.resolve(prev);
}
const fileDataPromise = fetch(src, { mode: mode }).then(async response => {
return {
const res = {
ok: response.ok,
status: response.status,
html: await response.text()
};
// Replace the cached promise with its result to avoid having buggy browser Promises retain memory as mentioned in #1284 and #1249
includeFiles.set(src, res);
return res;
});
// Cache the promise to only fetch() once per src
includeFiles.set(src, fileDataPromise);
return fileDataPromise;
}

View File

@@ -144,10 +144,10 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
@property({ type: Number }) maxlength: number;
/** The input's minimum value. Only applies to date and number input types. */
@property({ type: Number }) min: number;
@property() min: number | string;
/** The input's maximum value. Only applies to date and number input types. */
@property({ type: Number }) max: number;
@property() max: number | string;
/**
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
@@ -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

@@ -41,4 +41,14 @@ describe('<sl-option>', () => {
expect(slotChangeHandler).to.have.been.calledOnce;
});
it('should convert non-string values to string', async () => {
const el = await fixture<SlOption>(html` <sl-option>Text</sl-option> `);
// @ts-expect-error - intentional
el.value = 10;
await el.updateComplete;
expect(el.value).to.equal('10');
});
});

View File

@@ -92,6 +92,12 @@ export default class SlOption extends ShoelaceElement {
@watch('value')
handleValueChange() {
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
// instead of requiring them to cast the value to a string.
if (typeof this.value !== 'string') {
this.value = String(this.value);
}
if (this.value.includes(' ')) {
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
this.value = this.value.replace(/ /g, '_');

View File

@@ -72,7 +72,7 @@ export default class SlQrCode extends ShoelaceElement {
part="base"
class="qr-code"
role="img"
aria-label=${this.label.length > 0 ? this.label : this.value}
aria-label=${this.label?.length > 0 ? this.label : this.value}
style=${styleMap({
width: `${this.size}px`,
height: `${this.size}px`

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,34 @@ 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');
});
});
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
@@ -197,27 +200,40 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
private handleSlotChange() {
const radios = this.getAllRadios();
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');
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot?.querySelector('button');
if (buttonRadio) {
buttonRadio.tabIndex = 0;
}
} else {
radios[0].tabIndex = 0;
}
}
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot!.querySelector('button')!;
buttonRadio.tabIndex = 0;
} else {
radios[0].tabIndex = 0;
}
}
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (this.hasButtonGroup) {
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (buttonGroup) {
buttonGroup.disableRole = true;
if (buttonGroup) {
buttonGroup.disableRole = true;
}
}
} 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());
}
}
@@ -304,7 +320,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

@@ -1,5 +1,5 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { defaultValue } from '../../internal/default-value';
import { FormControlController } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
@@ -156,6 +156,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
this.emit('sl-focus');
}
@eventOptions({ passive: true })
private handleThumbDragStart() {
this.hasTooltip = true;
}

View File

@@ -79,6 +79,20 @@ describe('<sl-rating>', () => {
expect(el.value).to.equal(1);
});
it('should not emit sl-change when disabled', async () => {
const el = await fixture<SlRating>(html` <sl-rating value="5" disabled></sl-rating> `);
const lastSymbol = el.shadowRoot!.querySelector<HTMLSpanElement>('.rating__symbol:last-child')!;
const changeHandler = sinon.spy();
el.addEventListener('sl-change', changeHandler);
await clickOnElement(lastSymbol);
await el.updateComplete;
expect(changeHandler).to.not.have.been.called;
expect(el.value).to.equal(5);
});
it('should not emit sl-change when the value is changed programmatically', async () => {
const el = await fixture<SlRating>(html` <sl-rating label="Test" value="1"></sl-rating> `);
el.addEventListener('sl-change', () => expect.fail('sl-change incorrectly emitted'));

View File

@@ -1,7 +1,7 @@
import '../icon/icon';
import { clamp } from '../../internal/math';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { styleMap } from 'lit/directives/style-map.js';
@@ -89,6 +89,10 @@ export default class SlRating extends ShoelaceElement {
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
return;
}
this.setValue(this.getValueFromMousePosition(event));
this.emit('sl-change');
}
@@ -159,6 +163,7 @@ export default class SlRating extends ShoelaceElement {
event.preventDefault();
}
@eventOptions({ passive: true })
private handleTouchMove(event: TouchEvent) {
this.hoverValue = this.getValueFromTouchPosition(event);
}

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

@@ -59,6 +59,10 @@ import type SlRemoveEvent from '../../events/sl-remove';
* @csspart listbox - The listbox container where options are slotted.
* @csspart tags - The container that houses option tags when `multiselect` is used.
* @csspart tag - The individual tags that represent each multiselect option.
* @csspart tag__base - The tag's base part.
* @csspart tag__content - The tag's content part.
* @csspart tag__remove-button - The tag's remove button.
* @csspart tag__remove-button__base - The tag's remove button base part.
* @csspart clear-button - The clear button.
* @csspart expand-icon - The container that wraps the expand icon.
*/
@@ -433,18 +437,15 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
const values: string[] = [];
// Check for duplicate values in menu items
allOptions.forEach(option => {
if (values.includes(option.value)) {
console.error(
`An option with a duplicate value of "${option.value}" has been found in <sl-select>. All options must have unique values.`,
option
);
}
values.push(option.value);
});
if (customElements.get('sl-option')) {
allOptions.forEach(option => values.push(option.value));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
} else {
// Rerun this handler when <sl-option> is registered
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
}
}
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
@@ -761,6 +762,12 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
return html`
<sl-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base
"
?pill=${this.pill}
size=${this.size}
removable
@@ -814,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'}
@@ -825,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

@@ -2,8 +2,17 @@ import { expect, fixture, html } from '@open-wc/testing';
describe('<sl-split-panel>', () => {
it('should render a component', async () => {
const el = await fixture(html` <sl-split-panel></sl-split-panel> `);
const splitPanel = await fixture(html` <sl-split-panel></sl-split-panel> `);
expect(el).to.exist;
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();
});
});

View File

@@ -249,17 +249,21 @@ export default class SlSplitPanel extends ShoelaceElement {
return html`
<slot name="start" part="panel start" class="start"></slot>
<slot
name="divider"
<div
part="divider"
class="divider"
tabindex=${ifDefined(this.disabled ? undefined : '0')}
role="separator"
aria-valuenow=${this.position}
aria-valuemin="0"
aria-valuemax="100"
aria-label=${this.localize.term('resize')}
@keydown=${this.handleKeyDown}
@mousedown=${this.handleDrag}
@touchstart=${this.handleDrag}
></slot>
>
<slot name="divider"></slot>
</div>
<slot name="end" part="panel end" class="end"></slot>
`;

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);
});
}

View File

@@ -26,7 +26,6 @@ export default css`
color: var(--sl-color-neutral-700);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.tree-item__checkbox {
@@ -39,7 +38,7 @@ export default css`
font-family: var(--sl-font-sans);
font-size: var(--sl-font-size-medium);
font-weight: var(--sl-font-weight-normal);
line-height: var(--sl-line-height-normal);
line-height: var(--sl-line-height-dense);
letter-spacing: var(--sl-letter-spacing-normal);
}
@@ -63,6 +62,7 @@ export default css`
padding: var(--sl-spacing-x-small);
width: 1rem;
height: 1rem;
flex-shrink: 0;
cursor: pointer;
}

View File

@@ -263,12 +263,11 @@ export default class SlTreeItem extends ShoelaceElement {
?disabled="${this.disabled}"
?checked="${live(this.selected)}"
?indeterminate="${this.indeterminate}"
>
<slot class="tree-item__label" part="label"></slot>
</sl-checkbox>
`,
() => html` <slot class="tree-item__label" part="label"></slot> `
></sl-checkbox>
`
)}
<slot class="tree-item__label" part="label"></slot>
</div>
<slot

View File

@@ -19,7 +19,7 @@ const reportValidityOverloads: WeakMap<HTMLFormElement, () => boolean> = new Wea
// We store a Set of controls that users have interacted with. This allows us to determine the interaction state
// without littering the DOM with additional data attributes.
//
const userInteractedControls: Set<ShoelaceFormControl> = new Set();
const userInteractedControls: WeakSet<ShoelaceFormControl> = new WeakSet();
//
// We store a WeakMap of interactions for each form control so we can track when all conditions are met for validation.
@@ -271,7 +271,7 @@ export class FormControlController implements ReactiveController {
el.requestUpdate();
}
private doAction(type: 'submit' | 'reset', invoker?: HTMLInputElement | SlButton) {
private doAction(type: 'submit' | 'reset', submitter?: HTMLInputElement | SlButton) {
if (this.form) {
const button = document.createElement('button');
button.type = type;
@@ -283,13 +283,13 @@ export class FormControlController implements ReactiveController {
button.style.whiteSpace = 'nowrap';
// Pass name, value, and form attributes through to the temporary button
if (invoker) {
button.name = invoker.name;
button.value = invoker.value;
if (submitter) {
button.name = submitter.name;
button.value = submitter.value;
['formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget'].forEach(attr => {
if (invoker.hasAttribute(attr)) {
button.setAttribute(attr, invoker.getAttribute(attr)!);
if (submitter.hasAttribute(attr)) {
button.setAttribute(attr, submitter.getAttribute(attr)!);
}
});
}
@@ -306,15 +306,15 @@ export class FormControlController implements ReactiveController {
}
/** Resets the form, restoring all the control to their default value */
reset(invoker?: HTMLInputElement | SlButton) {
this.doAction('reset', invoker);
reset(submitter?: HTMLInputElement | SlButton) {
this.doAction('reset', submitter);
}
/** Submits the form, triggering validation and form data injection. */
submit(invoker?: HTMLInputElement | SlButton) {
submit(submitter?: HTMLInputElement | SlButton) {
// Calling form.submit() bypasses the submit event and constraint validation. To prevent this, we can inject a
// native submit button into the form, "click" it, then remove it to simulate a standard form submission.
this.doAction('submit', invoker);
this.doAction('submit', submitter);
}
/**

View File

@@ -32,7 +32,7 @@ export function unlockBodyScrolling(lockingEl: HTMLElement) {
if (locks.size === 0) {
document.body.classList.remove('sl-scroll-lock');
document.body.style.removeProperty('--sl-scrollbar-width');
document.body.style.removeProperty('--sl-scroll-lock-size');
}
}

View File

@@ -105,8 +105,8 @@ export interface ShoelaceFormControl extends ShoelaceElement {
// Constraint validation attributes
pattern?: string;
min?: number | Date;
max?: number | Date;
min?: number | string | Date;
max?: number | string | Date;
step?: number | 'any';
required?: boolean;
minlength?: number;

View File

@@ -0,0 +1,58 @@
import { getBasePath } from './utilities/base-path';
const observer = new MutationObserver(mutations => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
discover(node as Element);
}
}
}
});
/**
* Checks a node for undefined elements and attempts to register them.
*/
export async function discover(root: Element | ShadowRoot) {
const rootTagName = root instanceof Element ? root.tagName.toLowerCase() : '';
const rootIsCustomElement = rootTagName?.includes('-');
const tags = [...root.querySelectorAll(':not(:defined)')]
.map(el => el.tagName.toLowerCase())
.filter(tag => tag.startsWith('sl-'));
// If the root element is an undefined custom element, add it to the list
if (rootIsCustomElement && !customElements.get(rootTagName)) {
tags.push(rootTagName);
}
// Make the list unique
const tagsToRegister = [...new Set(tags)];
await Promise.allSettled(tagsToRegister.map(tagName => register(tagName)));
}
/**
* Registers an element by tag name.
*/
function register(tagName: string): Promise<void> {
const tagWithoutPrefix = tagName.replace(/^sl-/i, '');
const path = getBasePath(`components/${tagWithoutPrefix}/${tagWithoutPrefix}.js`);
// If the element is already defined, there's nothing more to do
if (customElements.get(tagName)) {
return Promise.resolve();
}
// Register it
return new Promise((resolve, reject) => {
import(path)
.then(() => resolve())
.catch(() => reject(new Error(`Unable to automatically load<${tagName}> from ${path}`)));
});
}
// Initial discovery
discover(document.body);
// Listen for new undefined elements
observer.observe(document.body, { subtree: true, childList: true });

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Dansk',
$dir: 'ltr',
carousel: 'Karrusel',
clearEntry: 'Ryd indtastning',
close: 'Luk',
copy: 'Kopier',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Scroll til start',
selectAColorFromTheScreen: 'Vælg en farve fra skærmen',
showPassword: 'Vis adgangskode',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Skift farveformat'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Deutsch',
$dir: 'ltr',
carousel: 'Karussell',
clearEntry: 'Eingabe löschen',
close: 'Schließen',
copy: 'Kopieren',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Zum Anfang scrollen',
selectAColorFromTheScreen: 'Wähle eine Farbe vom Bildschirm',
showPassword: 'Passwort anzeigen',
slideNum: slide => `Gleiten ${slide}`,
toggleColorFormat: 'Farbformat umschalten'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'English',
$dir: 'ltr',
carousel: 'Carousel',
clearEntry: 'Clear entry',
close: 'Close',
copy: 'Copy',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Scroll to start',
selectAColorFromTheScreen: 'Select a color from the screen',
showPassword: 'Show password',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Toggle color format'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Español',
$dir: 'ltr',
carousel: 'Carrusel',
clearEntry: 'Borrar entrada',
close: 'Cerrar',
copy: 'Copiar',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Desplazarse al inicio',
selectAColorFromTheScreen: 'Seleccione un color de la pantalla',
showPassword: 'Mostrar contraseña',
slideNum: slide => `Diapositiva ${slide}`,
toggleColorFormat: 'Alternar formato de color'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'فارسی',
$dir: 'rtl',
carousel: 'چرخ فلک',
clearEntry: 'پاک کردن ورودی',
close: 'بستن',
copy: 'رونوشت',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'پیمایش به ابتدا',
selectAColorFromTheScreen: 'انتخاب یک رنگ از صفحه نمایش',
showPassword: 'نمایش رمز',
slideNum: slide => `اسلاید ${slide}`,
toggleColorFormat: 'تغییر قالب رنگ'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Français',
$dir: 'ltr',
carousel: 'Carrousel',
clearEntry: `Effacer l'entrée`,
close: 'Fermer',
copy: 'Copier',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: `Faire défiler jusqu'au début`,
selectAColorFromTheScreen: `Sélectionnez une couleur à l'écran`,
showPassword: 'Montrer le mot de passe',
slideNum: slide => `Diapositive ${slide}`,
toggleColorFormat: 'Changer le format de couleur'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'עברית',
$dir: 'rtl',
carousel: 'קרוסלה',
clearEntry: 'נקה קלט',
close: 'סגור',
copy: 'העתק',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'גלול להתחלה',
selectAColorFromTheScreen: 'בחור צבע מהמסך',
showPassword: 'הראה סיסמה',
slideNum: slide => `שקופית ${slide}`,
toggleColorFormat: 'החלף פורמט צבע'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Magyar',
$dir: 'ltr',
carousel: 'Körhinta',
clearEntry: 'Bejegyzés törlése',
close: 'Bezárás',
copy: 'Másolás',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Görgessen az elejére',
selectAColorFromTheScreen: 'Szín választása a képernyőről',
showPassword: 'Jelszó megjelenítése',
slideNum: slide => `${slide}. dia`,
toggleColorFormat: 'Színformátum változtatása'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: '日本語',
$dir: 'ltr',
carousel: 'カルーセル',
clearEntry: 'クリアエントリ',
close: '閉じる',
copy: 'コピー',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: '最初にスクロールする',
selectAColorFromTheScreen: '画面から色を選択してください',
showPassword: 'パスワードを表示',
slideNum: slide => `スライド ${slide}`,
toggleColorFormat: '色のフォーマットを切り替える'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Nederlands',
$dir: 'ltr',
carousel: 'Carrousel',
clearEntry: 'Invoer wissen',
close: 'Sluiten',
copy: 'Kopiëren',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Scroll naar begin',
selectAColorFromTheScreen: 'Selecteer een kleur van het scherm',
showPassword: 'Laat wachtwoord zien',
slideNum: slide => `Schuif ${slide}`,
toggleColorFormat: 'Wissel kleurnotatie'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Polski',
$dir: 'ltr',
carousel: 'Karuzela',
clearEntry: 'Wyczyść wpis',
close: 'Zamknij',
copy: 'Kopiuj',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Przewiń do początku',
selectAColorFromTheScreen: 'Próbkuj z ekranu',
showPassword: 'Pokaż hasło',
slideNum: slide => `Slajd ${slide}`,
toggleColorFormat: 'Przełącz format'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Português',
$dir: 'ltr',
carousel: 'Carrossel',
clearEntry: 'Limpar entrada',
close: 'Fechar',
copy: 'Copiar',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Rolar até o começo',
selectAColorFromTheScreen: 'Selecionar uma cor da tela',
showPassword: 'Mostrar senhaShow password',
slideNum: slide => `Diapositivo ${slide}`,
toggleColorFormat: 'Trocar o formato de cor'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Русский',
$dir: 'ltr',
carousel: 'Карусель',
clearEntry: 'Очистить запись',
close: 'Закрыть',
copy: 'Скопировать',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Пролистать к началу',
selectAColorFromTheScreen: 'Выберите цвет на экране',
showPassword: 'Показать пароль',
slideNum: slide => `Слайд ${slide}`,
toggleColorFormat: 'Переключить цветовую модель'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Svenska',
$dir: 'ltr',
carousel: 'Karusell',
clearEntry: 'Återställ val',
close: 'Stäng',
copy: 'Kopiera',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Skrolla till början',
selectAColorFromTheScreen: 'Välj en färg från skärmen',
showPassword: 'Visa lösenord',
slideNum: slide => `Bild ${slide}`,
toggleColorFormat: 'Växla färgformat'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: 'Türkçe',
$dir: 'ltr',
carousel: 'Atlıkarınca',
clearEntry: 'Girişi sil',
close: 'Kapat',
copy: 'Kopya',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: 'Başa kay',
selectAColorFromTheScreen: 'Ekrandan bir renk seçin',
showPassword: 'Şifreyi göster',
slideNum: slide => `Slayt ${slide}`,
toggleColorFormat: 'Renk biçimini değiştir'
};

View File

@@ -6,6 +6,7 @@ const translation: Translation = {
$name: '正體中文',
$dir: 'ltr',
carousel: '旋轉木馬',
clearEntry: '清空',
close: '關閉',
copy: '複製',
@@ -27,6 +28,7 @@ const translation: Translation = {
scrollToStart: '捲至頁首',
selectAColorFromTheScreen: '從螢幕中選擇一種顏色',
showPassword: '顯示密碼',
slideNum: slide => `幻燈片 ${slide}`,
toggleColorFormat: '切換顏色格式'
};

View File

@@ -9,16 +9,18 @@ export function setBasePath(path: string) {
* Gets the library's base path.
*
* The base path is used to load assets such as icons and images, so it needs to be set for components to work properly.
* By default, this script will look for a script ending in shoelace.js and set the base path to the directory that
* contains that file. To override this behavior, you can add the data-shoelace attribute to any script on the page
* (it probably makes the most sense to attach it to the Shoelace script, but it could also be on a bundle). The value
* can be a local folder or it can point to a CORS-enabled endpoint such as a CDN.
* By default, this script will look for a script ending in shoelace.js or shoelace-autoloader.js and set the base path
* to the directory that contains that file. To override this behavior, you can add the data-shoelace attribute to any
* script on the page (it probably makes the most sense to attach it to the Shoelace script, but it could also be on a
* bundle). The value can be a local folder or it can point to a CORS-enabled endpoint such as a CDN.
*
* <script src="bundle.js" data-shoelace="/custom/base/path"></script>
*
* Alternatively, you can set the base path manually using the exported setBasePath() function.
*
* @param subpath - An optional path to append to the base path.
*/
export function getBasePath() {
export function getBasePath(subpath = '') {
if (!basePath) {
const scripts = [...document.getElementsByTagName('script')] as HTMLScriptElement[];
const configScript = scripts.find(script => script.hasAttribute('data-shoelace'));
@@ -27,7 +29,9 @@ export function getBasePath() {
// Use the data-shoelace attribute
setBasePath(configScript.getAttribute('data-shoelace')!);
} else {
const fallbackScript = scripts.find(s => /shoelace(\.min)?\.js($|\?)/.test(s.src));
const fallbackScript = scripts.find(s => {
return /shoelace(\.min)?\.js($|\?)/.test(s.src) || /shoelace-autoloader(\.min)?\.js($|\?)/.test(s.src);
});
let path = '';
if (fallbackScript) {
@@ -38,5 +42,6 @@ export function getBasePath() {
}
}
return basePath.replace(/\/$/, '');
// Return the base path without a trailing slash. If one exists, append the subpath separated by a slash.
return basePath.replace(/\/$/, '') + (subpath ? `/${subpath.replace(/^\//, '')}` : ``);
}

View File

@@ -13,6 +13,7 @@ export interface Translation extends DefaultTranslation {
$name: string; // e.g. English, Español
$dir: 'ltr' | 'rtl';
carousel: string;
clearEntry: string;
close: string;
copy: string;
@@ -30,5 +31,6 @@ export interface Translation extends DefaultTranslation {
scrollToStart: string;
selectAColorFromTheScreen: string;
showPassword: string;
slideNum: (slide: number) => string;
toggleColorFormat: string;
}

View File

@@ -3,6 +3,7 @@
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"target": "es2017",
"module": "es2020",
"lib": [
"dom",
"dom.Iterable",

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: {