Compare commits

...

101 Commits

Author SHA1 Message Date
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
5990fbd000 2.2.0 2023-02-28 17:05:11 -05:00
Cory LaViska
954d78dcd1 update version 2023-02-28 17:04:59 -05:00
Cory LaViska
3ea31389dd fixes #1082 2023-02-28 13:33:34 -05:00
Cory LaViska
d79799043a remove unused import 2023-02-28 13:07:18 -05:00
Cory LaViska
9f8ce58288 use clickOnElement 2023-02-28 13:07:07 -05:00
Cory LaViska
2371c5490f Merge branch 'next' into autoload 2023-02-28 12:45:23 -05:00
Cory LaViska
77abd42d66 fix popup positioning edge case; closes #1135 2023-02-28 12:30:05 -05:00
Cory LaViska
218e78e947 add getForm() method; closes #1180 2023-02-28 12:10:14 -05:00
Cory LaViska
8a1efac9b8 fixes #1201 2023-02-28 12:03:20 -05:00
Cory LaViska
f9ae8327f6 fix menu focus color 2023-02-28 09:46:49 -05:00
Cory LaViska
7f3076d195 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-02-27 16:40:55 -05:00
Cory LaViska
1fa79e64ae fix track color in dark mode 2023-02-27 16:40:53 -05:00
Bünyamin Eskiocak
dde1010465 Fixed clipped calendar toggle in Firefox (#1213)
* fixed clipped calendar toggle in firefox

* changelog

---------

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
2023-02-27 12:04:32 -05:00
Cory LaViska
3a3a7347bc link to event 2023-02-27 11:15:57 -05:00
Cory LaViska
77c9750206 fix sl-tab-show event when closing 2023-02-27 11:13:59 -05:00
Cory LaViska
3d2e618be8 fix term 2023-02-24 16:08:06 -05:00
Cory LaViska
79feaae7fc adjust attribution 2023-02-24 15:56:31 -05:00
Cory LaViska
b0f7dfb86b update comment 2023-02-24 15:55:02 -05:00
Cory LaViska
e1979b8f38 reorder custom properties 2023-02-24 15:54:57 -05:00
Cory LaViska
7e9ae32b9b add carousel terms 2023-02-24 15:48:39 -05:00
Cory LaViska
480a1df246 reorder 2023-02-24 15:24:39 -05:00
Cory LaViska
ff798adb49 update examples 2023-02-24 15:10:54 -05:00
Cory LaViska
70a64262e9 add svg part 2023-02-24 12:51:46 -05:00
Cory LaViska
5f65896150 update localize 2023-02-24 12:35:25 -05:00
Cory LaViska
c69db4919b wip; improve carousel docs 2023-02-23 16:50:18 -05:00
Cory LaViska
a526e8a956 cleanup 2023-02-23 15:00:27 -05:00
Cory LaViska
4970ba065e set a default aspect ratio 2023-02-23 15:00:02 -05:00
Cory LaViska
0292ed30c5 update example 2023-02-23 14:59:48 -05:00
Cory LaViska
b64b1c2536 fix test 2023-02-23 14:49:49 -05:00
Cory LaViska
8f9eb012ba sort imports 2023-02-23 14:42:58 -05:00
Cory LaViska
c8fd9f19d2 ignore lorem ipsum 2023-02-23 14:40:05 -05:00
Cory LaViska
603aa93322 update and fix typos 2023-02-23 14:39:05 -05:00
Cory LaViska
74203de094 sort imports 2023-02-23 14:23:38 -05:00
Cory LaViska
4fa4682a45 Merge branch 'alenaksu-feat/carousel' into next 2023-02-23 14:20:01 -05:00
Cory LaViska
34e0fb2fc1 Merge branch 'feat/carousel' of github.com:alenaksu/shoelace into alenaksu-feat/carousel 2023-02-23 14:13:19 -05:00
Cory LaViska
50972f2b38 update changelog and comment 2023-02-23 11:33:31 -05:00
Cory LaViska
652ce6c9f1 Merge branch 'mpharoah-mpharoah/typescript-events' into next 2023-02-23 11:26:47 -05:00
Cory LaViska
8412b150b2 Merge branch 'mpharoah/typescript-events' of github.com:mpharoah/shoelace into mpharoah-mpharoah/typescript-events 2023-02-23 11:24:19 -05:00
Justin Fagnani
22b8ef4edf Fix a few spelling issues (#1192) 2023-02-23 11:15:20 -05:00
Cory LaViska
0865dede6f fix heading 2023-02-23 11:11:19 -05:00
Cory LaViska
d638d811ad remove unused type 2023-02-23 11:04:51 -05:00
Cory LaViska
bc58472b7b fix skipped tests 2023-02-23 11:02:26 -05:00
Cory LaViska
226c856b1e update scroll controls when adding tabs; fixes #1208 2023-02-23 10:12:36 -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
Cory LaViska
5b12de1edf fixes #1205 2023-02-21 11:54:39 -05:00
Cory LaViska
f79a670ca3 fix padding in Chrome; closes #1197 2023-02-17 09:56:52 -05:00
Matt Pharoah
d818980dea Fixed copy-paste type in comment 2023-02-15 09:41:05 -05:00
Matt Pharoah
636f61006f Adjusted comment to be more clear 2023-02-15 09:39:43 -05:00
Matt Pharoah
d93e698baf Added comments to explain the Typescript metaprogramming 2023-02-15 09:37:48 -05:00
Matt Pharoah
f8d8291caa Added sl-invalid event type 2023-02-14 23:06:47 -05:00
Matt Pharoah
21bef1c2ea Merge branch 'next' into mpharoah/typescript-events 2023-02-14 22:59:20 -05:00
Matt Pharoah
d6a7820a52 Make emit return the actual event type instead of CustomEvent<any> 2023-02-09 15:56:54 -05:00
Matt Pharoah
39ca1208f5 Removed unused event handler from sl-dropdown 2023-02-09 13:09:31 -05:00
Matt Pharoah
610a06bcb9 Use PropertyKey instead of string where appropriate 2023-02-09 13:06:17 -05:00
Matt Pharoah
b8584c0581 Require detail to always be provided when calling emit with an event that requires it 2023-02-09 13:00:04 -05:00
Matt Pharoah
ab19afeb66 Resolved merge conflict 2023-02-08 18:24:17 -05:00
Matt Pharoah
41b5cb367f Use typed events in components and tests 2023-02-08 18:19:27 -05:00
Matt Pharoah
e65b09fdec Fixed Typescript error when CustomEvents with non-object details are registered 2023-02-08 17:55:28 -05:00
Matt Pharoah
15a4049a01 Require the options parameter to have a detail property for events with details 2023-02-08 17:42:14 -05:00
Matt Pharoah
ce708fbba8 Perform type checking of the event detail in the emit function if the event type matches a Shoelace event 2023-02-08 17:01:00 -05:00
Matt Pharoah
75bd7784fb Basic events have an empty details object, not a null 2023-02-08 14:12:15 -05:00
Matt Pharoah
6e092ccf7a Added event types for all events 2023-02-08 13:55:19 -05:00
Matt Pharoah
b7b73ea3a9 Added sl-request-close event 2023-02-08 13:30:56 -05:00
Matt Pharoah
9dab91e0d1 Added event for sl-error 2023-02-08 13:17:12 -05:00
Matt Pharoah
a3a802a37b Register events with global event map 2023-02-08 13:14:43 -05:00
Matt Pharoah
358ad7bb30 Fixed type in documentation (TreeItem[] -> SlTreeItem[]) 2023-02-07 17:44:56 -05:00
Matt Pharoah
0a555c53c7 Export typescript types for events with details 2023-02-07 17:20:01 -05:00
alenaksu
48ccc95dd9 chore: add react examples 2023-01-08 13:36:24 +01:00
Alessandro
c6a6a77bbd feat: add carousel component
feat: add nav indicators

wip

wip

wip

fix: minor fixes

fix: minor fixes

fix: some refactor

chore: update docs

chore: update docs

fix: remove slide component

feat: create sl-carousel-item

feat: code refactoring and improvements

chore: update docs with more examples

chore: fix docs

feat: add autoplay

feat: implement accessibility

fix: change icons for rtl

chore: minor change

feat: improve accessibility

fix: minor regression

fix: minor regression

chore: fix docs

fix: improve accessibility and minor fixes

fix: remove heading and refactor component

chore: add custom style exmaple

fix: address review commnets

* Removed header from carousel
* Added `ArrowUp` and `ArrowDown` in keyboard navigation
* Added `--scroll-hint-margin` css property
* Added an example with customized carousel layout
* Fixed thumbnails navigation in demo
* Renamed show-controls to show-arrows and updated the corresponding parts/css accordingly
* Changed `activeSlideElement` getter to a private method
* Changed pagination colors
* Added `--slide-width` and `--slide-height` css properties

chore: update docs

fix: integrate latest repo changes

fix: add aspect ratio and rebase

chore: remove ignore path

feat: multiple slides per page

feat: multiple slide per page

fix: various improvements

chore: minor changes

chore: minor changes

chore: add bit of documentation

chore: improve documentation

fix: add unit tests and fix minor issues

chore: update documentation and unit tests

chore: update tests
2023-01-08 12:24:24 +01:00
130 changed files with 4055 additions and 309 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,5 @@
docs/dist
docs/search.json
dist
examples
node_modules
src/react

View File

@@ -9,6 +9,9 @@
"atrule",
"autocorrect",
"autofix",
"autoload",
"autoloader",
"autoloading",
"autoplay",
"bezier",
"boxicons",
@@ -27,6 +30,7 @@
"Consolas",
"contenteditable",
"copydir",
"Cotte",
"coverpage",
"crossorigin",
"crutchcorn",
@@ -35,6 +39,7 @@
"datetime",
"describedby",
"Docsify",
"dogfood",
"dropdowns",
"easings",
"enterkeyhint",
@@ -53,6 +58,7 @@
"FOUC",
"FOUCE",
"fullscreen",
"gestern",
"giga",
"globby",
"Grayscale",
@@ -70,10 +76,12 @@
"jsonata",
"keydown",
"keyframes",
"Kool",
"labelledby",
"Laravel",
"LaViska",
"listbox",
"listitem",
"litelement",
"lowercasing",
"Lucide",
@@ -106,9 +114,13 @@
"rgba",
"roadmap",
"Roboto",
"roledescription",
"Sapan",
"saturationl",
"Schilp",
"scrollbars",
"scrollend",
"scroller",
"Segoe",
"semibold",
"slotchange",
@@ -121,6 +133,7 @@
"tabpanel",
"templating",
"tera",
"testid",
"textareas",
"textfield",
"tinycolor",
@@ -143,6 +156,7 @@
"ignorePaths": [
"package.json",
"package-lock.json",
"docs/assets/examples/include.html",
".vscode/**",
"src/translations/!(en).ts",
"**/*.min.js"

View File

@@ -31,6 +31,8 @@
- [Button](/components/button)
- [Button Group](/components/button-group)
- [Card](/components/card)
- [Carousel](/components/carousel)
- [Carousel Item](/components/carousel-item)
- [Checkbox](/components/checkbox)
- [Color Picker](/components/color-picker)
- [Details](/components/details)

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

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

@@ -0,0 +1,81 @@
# Carousel Item
[component-header:sl-carousel-item]
```html preview
<sl-carousel pagination>
<sl-carousel-item>
<img
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
src="/assets/examples/carousel/mountains.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
src="/assets/examples/carousel/waterfall.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
src="/assets/examples/carousel/sunset.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
src="/assets/examples/carousel/field.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
src="/assets/examples/carousel/valley.jpg"
/>
</sl-carousel-item>
</sl-carousel>
```
```jsx react
import { SlCarousel, SlCarouselItem } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlCarousel pagination>
<SlCarouselItem>
<img
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
src="/assets/examples/carousel/mountains.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
src="/assets/examples/carousel/waterfall.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
src="/assets/examples/carousel/sunset.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
src="/assets/examples/carousel/field.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
src="/assets/examples/carousel/valley.jpg"
/>
</SlCarouselItem>
</SlCarousel>
);
```
?> Additional demonstrations can be found in the [carousel examples](/components/carousel).
[component-metadata:sl-carousel-item]

1221
docs/components/carousel.md Normal file

File diff suppressed because it is too large Load Diff

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

@@ -64,7 +64,7 @@ const App = () => (
### Getting the Selected Item
When dropdowns are used with [menus](/components/menu), you can listen for the `sl-select` event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
When dropdowns are used with [menus](/components/menu), you can listen for the [`sl-select`](/components/menu#events) event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
```html preview
<div class="dropdown-selection">

View File

@@ -531,7 +531,7 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
</div>
```
## Tabler Icons
### Tabler Icons
This will register the [Tabler Icons](https://tabler-icons.io/) library using the jsDelivr CDN. This library features over 1,950 open source icons.

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

@@ -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

@@ -164,7 +164,7 @@ checkbox.checked = true;
console.log(checkbox.hasAttribute('checked')); // false
```
Most devs will expect this to be `true` instead of `false`, but the component hasn't had a chance to re-render yet so the attribute doesn't exist when `hasAttribute()` is called. Since changes are batched, we need to wait for the update before proceeding. This can be done using the `updateComplete` property, which is available on all Lit-based components.
Most developers will expect this to be `true` instead of `false`, but the component hasn't had a chance to re-render yet so the attribute doesn't exist when `hasAttribute()` is called. Since changes are batched, we need to wait for the update before proceeding. This can be done using the `updateComplete` property, which is available on all Lit-based components.
```js
const checkbox = document.querySelector('sl-checkbox');

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,6 +6,39 @@ 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.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-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
- Fixed a bug in `<sl-select>` that caused `sl-change` and `sl-input` to be emitted too early [#1201](https://github.com/shoelace-style/shoelace/issues/1201)
- Fixed a positioning edge case that caused `<sl-popup>` to positioned nested popups incorrectly [#1135](https://github.com/shoelace-style/shoelace/issues/1135)
- Fixed a bug in `<sl-tree>` that caused the tree item to collapse when clicking a child item, dragging the mouse, and releasing it on the parent node [#1082](https://github.com/shoelace-style/shoelace/issues/1082)
- Updated `@shoelace-style/localize` to 3.1.0
- Updated `@floating-ui/dom` to 1.2.1
When using `<input type="password">` the default value for `autocapitalize`, `autocomplete`, and `autocorrect` may be affected due to the bug fixed in [#1205](https://github.com/shoelace-style/shoelace/issues/1205). For any affected users, setting these attributes to `off` will restore the previous behavior.
## 2.1.0
- Added the `sl-focus` and `sl-blur` events to `<sl-color-picker>`
@@ -273,8 +306,7 @@ This release removes the `<sl-responsive-media>` component. When this component
- Fixed a bug in `<sl-tree>` that prevented the keyboard from working when the component was nested in a shadow root [#871](https://github.com/shoelace-style/shoelace/issues/871)
- Fixed a bug in `<sl-tab-group>` that prevented the keyboard from working when the component was nested in a shadow root [#872](https://github.com/shoelace-style/shoelace/issues/872)
- Fixed a bug in `<sl-tab>` that allowed disabled tabs to erroneously receive focus
- Improved single selection in `<sl-tree>` so nodes expand and collapse and rece
ive selection when clicking on the label
- Improved single selection in `<sl-tree>` so nodes expand and collapse and receive selection when clicking on the label
- Renamed `expanded-icon` and `collapsed-icon` slots to `expand-icon` and `collapse-icon` in the experimental `<sl-tree>` and `<sl-tree-item>` components
- Improved RTL support for `<sl-image-comparer>`
- Refactored components to extend from `ShoelaceElement` to make `dir` and `lang` reactive properties in all components

59
package-lock.json generated
View File

@@ -1,19 +1,20 @@
{
"name": "@shoelace-style/shoelace",
"version": "2.1.0",
"version": "2.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@shoelace-style/shoelace",
"version": "2.1.0",
"version": "2.3.0",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
"@floating-ui/dom": "^1.1.0",
"@floating-ui/dom": "^1.2.1",
"@lit-labs/react": "^1.1.1",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.0.4",
"@shoelace-style/localize": "^3.1.0",
"composed-offset-position": "^0.0.4",
"lit": "^2.6.1",
"qr-creator": "^1.0.0"
},
@@ -1043,16 +1044,16 @@
}
},
"node_modules/@floating-ui/core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz",
"integrity": "sha512-zbsLwtnHo84w1Kc8rScAo5GMk1GdecSlrflIbfnEBJwvTSj1SL6kkOYV+nHraMCPEy+RNZZUaZyL8JosDGCtGQ=="
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.1.tgz",
"integrity": "sha512-LSqwPZkK3rYfD7GKoIeExXOyYx6Q1O4iqZWwIehDNuv3Dv425FIAE8PRwtAx1imEolFTHgBEcoFHm9MDnYgPCg=="
},
"node_modules/@floating-ui/dom": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.0.tgz",
"integrity": "sha512-TSogMPVxbRe77QCj1dt8NmRiJasPvuc+eT5jnJ6YpLqgOD2zXc5UA3S1qwybN+GVCDNdKfpKy1oj8RpzLJvh6A==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.2.1.tgz",
"integrity": "sha512-Rt45SmRiV8eU+xXSB9t0uMYiQ/ZWGE/jumse2o3i5RGlyvcbqOF4q+1qBnzLE2kZ5JGhq0iMkcGXUKbFe7MpTA==",
"dependencies": {
"@floating-ui/core": "^1.0.5"
"@floating-ui/core": "^1.2.1"
}
},
"node_modules/@gar/promisify": {
@@ -1478,9 +1479,9 @@
}
},
"node_modules/@shoelace-style/localize": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.4.tgz",
"integrity": "sha512-HFY90KD+b1Td2otSBryCOpQjBEArIwlV6Tv4J4rC/E/D5wof2eLF6JUVrbiRNn8GRmwATe4YDAEK7NUD08xO1w=="
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.1.0.tgz",
"integrity": "sha512-evGxn5wIQh1/Ks1RbZm7rY4DxPKAUnXKTixZNgnYV/N2V8Bbbvsi+S14gNa42SQNUJK5WooNtlar2B8cehEwZQ=="
},
"node_modules/@sindresorhus/is": {
"version": "0.7.0",
@@ -4523,6 +4524,11 @@
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
"dev": true
},
"node_modules/composed-offset-position": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.4.tgz",
"integrity": "sha512-vMlvu1RuNegVE0YsCDSV/X4X10j56mq7PCIyOKK74FxkXzGLwhOUmdkJLSdOBOMwWycobGUMgft2lp+YgTe8hw=="
},
"node_modules/compress-brotli": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.8.tgz",
@@ -16270,16 +16276,16 @@
}
},
"@floating-ui/core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz",
"integrity": "sha512-zbsLwtnHo84w1Kc8rScAo5GMk1GdecSlrflIbfnEBJwvTSj1SL6kkOYV+nHraMCPEy+RNZZUaZyL8JosDGCtGQ=="
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.1.tgz",
"integrity": "sha512-LSqwPZkK3rYfD7GKoIeExXOyYx6Q1O4iqZWwIehDNuv3Dv425FIAE8PRwtAx1imEolFTHgBEcoFHm9MDnYgPCg=="
},
"@floating-ui/dom": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.0.tgz",
"integrity": "sha512-TSogMPVxbRe77QCj1dt8NmRiJasPvuc+eT5jnJ6YpLqgOD2zXc5UA3S1qwybN+GVCDNdKfpKy1oj8RpzLJvh6A==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.2.1.tgz",
"integrity": "sha512-Rt45SmRiV8eU+xXSB9t0uMYiQ/ZWGE/jumse2o3i5RGlyvcbqOF4q+1qBnzLE2kZ5JGhq0iMkcGXUKbFe7MpTA==",
"requires": {
"@floating-ui/core": "^1.0.5"
"@floating-ui/core": "^1.2.1"
}
},
"@gar/promisify": {
@@ -16623,9 +16629,9 @@
"integrity": "sha512-Be+cahtZyI2dPKRm8EZSx3YJQ+jLvEcn3xzRP7tM4tqBnvd/eW/64Xh0iOf0t2w5P8iJKfdBbpVNE9naCaOf2g=="
},
"@shoelace-style/localize": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.4.tgz",
"integrity": "sha512-HFY90KD+b1Td2otSBryCOpQjBEArIwlV6Tv4J4rC/E/D5wof2eLF6JUVrbiRNn8GRmwATe4YDAEK7NUD08xO1w=="
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.1.0.tgz",
"integrity": "sha512-evGxn5wIQh1/Ks1RbZm7rY4DxPKAUnXKTixZNgnYV/N2V8Bbbvsi+S14gNa42SQNUJK5WooNtlar2B8cehEwZQ=="
},
"@sindresorhus/is": {
"version": "0.7.0",
@@ -18941,6 +18947,11 @@
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
"dev": true
},
"composed-offset-position": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.4.tgz",
"integrity": "sha512-vMlvu1RuNegVE0YsCDSV/X4X10j56mq7PCIyOKK74FxkXzGLwhOUmdkJLSdOBOMwWycobGUMgft2lp+YgTe8hw=="
},
"compress-brotli": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.8.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.1.0",
"version": "2.3.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/*",
@@ -63,10 +64,11 @@
},
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
"@floating-ui/dom": "^1.1.0",
"@floating-ui/dom": "^1.2.1",
"@lit-labs/react": "^1.1.1",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.0.4",
"@shoelace-style/localize": "^3.1.0",
"composed-offset-position": "^0.0.4",
"lit": "^2.6.1",
"qr-creator": "^1.0.0"
},

View File

@@ -51,6 +51,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
@@ -120,6 +122,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}`;
}
}
}
};

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
@@ -113,23 +112,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

@@ -28,22 +28,22 @@ export default class SlButtonGroup extends ShoelaceElement {
*/
@property() label = '';
private handleFocus(event: CustomEvent) {
private handleFocus(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--focus');
}
private handleBlur(event: CustomEvent) {
private handleBlur(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--focus');
}
private handleMouseOver(event: CustomEvent) {
private handleMouseOver(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--hover');
}
private handleMouseOut(event: CustomEvent) {
private handleMouseOut(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--hover');
}

View File

@@ -257,6 +257,11 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
return true;
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (this.isButton()) {

View File

@@ -0,0 +1,26 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
export default css`
${componentStyles}
:host {
--aspect-ratio: inherit;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
width: 100%;
max-height: 100%;
aspect-ratio: var(--aspect-ratio);
scroll-snap-align: start;
scroll-snap-stop: always;
}
::slotted(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
`;

View File

@@ -0,0 +1,17 @@
import { expect, fixture, html } from '@open-wc/testing';
describe('<sl-carousel-item>', () => {
it('should render a component', async () => {
const el = await fixture(html` <sl-carousel-item></sl-carousel-item> `);
expect(el).to.exist;
});
it('should pass accessibility tests', async () => {
// Arrange
const el = await fixture(html` <div role="list"><sl-carousel-item></sl-carousel-item></div> `);
// Assert
await expect(el).to.be.accessible();
});
});

View File

@@ -0,0 +1,40 @@
import { customElement } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './carousel-item.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary A carousel item represent a slide within a [carousel](/components/carousel).
*
* @since 2.0
* @status experimental
*
* @slot - The carousel item's content..
*
* @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default.
*
*/
@customElement('sl-carousel-item')
export default class SlCarouselItem extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static isCarouselItem(node: Node) {
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
}
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'group');
}
render() {
return html` <slot></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-carousel-item': SlCarouselItem;
}
}

View File

@@ -0,0 +1,73 @@
import type { ReactiveController, ReactiveElement } from 'lit';
/**
* A controller that repeatedly calls the specified callback with the provided interval time.
* The timer is automatically paused while the user is interacting with the component.
*/
export class AutoplayController implements ReactiveController {
private host: ReactiveElement;
private timerId = 0;
private tickCallback: () => void;
private activeInteractions = 0;
paused = false;
stopped = true;
constructor(host: ReactiveElement, tickCallback: () => void) {
host.addController(this);
this.host = host;
this.tickCallback = tickCallback;
}
hostConnected(): void {
this.host.addEventListener('mouseenter', this.pause);
this.host.addEventListener('mouseleave', this.resume);
this.host.addEventListener('focusin', this.pause);
this.host.addEventListener('focusout', this.resume);
this.host.addEventListener('touchstart', this.pause, { passive: true });
this.host.addEventListener('touchend', this.resume);
}
hostDisconnected(): void {
this.stop();
this.host.removeEventListener('mouseenter', this.pause);
this.host.removeEventListener('mouseleave', this.resume);
this.host.removeEventListener('focusin', this.pause);
this.host.removeEventListener('focusout', this.resume);
this.host.removeEventListener('touchstart', this.pause);
this.host.removeEventListener('touchend', this.resume);
}
start(interval: number) {
this.stop();
this.stopped = false;
this.timerId = window.setInterval(() => {
if (!this.paused) {
this.tickCallback();
}
}, interval);
}
stop() {
clearInterval(this.timerId);
this.stopped = true;
this.host.requestUpdate();
}
pause = () => {
if (!this.activeInteractions++) {
this.paused = true;
this.host.requestUpdate();
}
};
resume = () => {
if (!--this.activeInteractions) {
this.paused = false;
this.host.requestUpdate();
}
};
}

View File

@@ -0,0 +1,160 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
export default css`
${componentStyles}
:host {
--slide-gap: var(--sl-spacing-medium, 1rem);
--aspect-ratio: 16 / 9;
--scroll-hint: 0px;
display: flex;
}
.carousel {
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-template-rows: 1fr min-content;
grid-template-areas:
'. slides .'
'. pagination .';
gap: var(--sl-spacing-medium);
align-items: center;
min-height: 100%;
min-width: 100%;
position: relative;
}
.carousel__pagination {
grid-area: pagination;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--sl-spacing-small);
}
.carousel__slides {
grid-area: slides;
display: grid;
height: 100%;
width: 100%;
align-items: center;
justify-items: center;
overflow: auto;
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));
}
@media (prefers-reduced-motion) {
:where(.carousel__slides) {
scroll-behavior: auto;
}
}
.carousel__slides--horizontal {
grid-auto-flow: column;
grid-auto-columns: var(--slide-size);
grid-auto-rows: 100%;
column-gap: var(--slide-gap);
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--scroll-hint);
padding-inline: var(--scroll-hint);
overflow-y: hidden;
}
.carousel__slides--vertical {
grid-auto-flow: row;
grid-auto-columns: 100%;
grid-auto-rows: var(--slide-size);
row-gap: var(--slide-gap);
scroll-snap-type: y mandatory;
scroll-padding-block: var(--scroll-hint);
padding-block: var(--scroll-hint);
overflow-x: hidden;
}
.carousel__slides--dragging,
.carousel__slides--dropping {
scroll-snap-type: unset;
}
:host([vertical]) ::slotted(sl-carousel-item) {
height: 100%;
}
.carousel__slides::-webkit-scrollbar {
display: none;
}
.carousel__navigation {
grid-area: navigation;
display: contents;
font-size: var(--sl-font-size-x-large);
}
.carousel__navigation-button {
flex: 0 0 auto;
display: flex;
align-items: center;
background: none;
border: none;
border-radius: var(--sl-border-radius-small);
font-size: inherit;
color: var(--sl-color-neutral-600);
padding: var(--sl-spacing-x-small);
cursor: pointer;
transition: var(--sl-transition-medium) color;
appearance: none;
}
.carousel__navigation-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.carousel__navigation-button--disabled::part(base) {
pointer-events: none;
}
.carousel__navigation-button--previous {
grid-column: 1;
grid-row: 1;
}
.carousel__navigation-button--next {
grid-column: 3;
grid-row: 1;
}
.carousel__pagination-item {
display: block;
cursor: pointer;
background: none;
border: 0;
border-radius: var(--sl-border-radius-circle);
width: var(--sl-spacing-small);
height: var(--sl-spacing-small);
background-color: var(--sl-color-neutral-300);
padding: 0;
margin: 0;
}
.carousel__pagination-item--active {
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

@@ -0,0 +1,588 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import sinon from 'sinon';
import type SlCarousel from './carousel';
describe('<sl-carousel>', () => {
it('should render a carousel with default configuration', async () => {
// Arrange
const el = await fixture(html`
<sl-carousel>
<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>
`);
// Assert
expect(el).to.exist;
expect(el).to.have.attribute('role', 'region');
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;
});
describe('when `autoplay` attribute is provided', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers({
now: new Date()
});
});
afterEach(() => {
clock.restore();
});
it('should scroll forwards every `autoplay-interval` milliseconds', 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
clock.next();
clock.next();
// Assert
expect(el.next).to.have.been.calledTwice;
});
it('should pause the autoplay while the user is 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'));
await el.updateComplete;
clock.next();
clock.next();
// 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', () => {
it('should create clones of the first and last slides', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel loop>
<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>
`);
// Act
await el.updateComplete;
// Assert
expect(el.firstElementChild).to.have.attribute('data-clone', '2');
expect(el.lastElementChild).to.have.attribute('data-clone', '0');
});
describe('and `slides-per-page` is provided', () => {
it('should create multiple clones', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel loop slides-per-page="2">
<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>
`);
// Act
await el.updateComplete;
const clones = [...el.children].filter(child => child.hasAttribute('data-clone'));
// Assert
expect(clones).to.have.lengthOf(4);
});
});
});
describe('when `pagination` attribute is provided', () => {
it('should render pagination controls', async () => {
// Arrange
const el = await fixture(html`
<sl-carousel pagination>
<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>
`);
// Assert
expect(el).to.exist;
expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist;
expect(el.shadowRoot!.querySelector('.carousel__pagination')).to.exist;
});
describe('and user clicks on a pagination button', () => {
it('should scroll the carousel to the nth slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel pagination>
<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, 'goToSlide');
await el.updateComplete;
// Act
const paginationItem = el.shadowRoot!.querySelectorAll('.carousel__pagination-item')[2] as HTMLElement;
await clickOnElement(paginationItem);
expect(el.goToSlide).to.have.been.calledWith(2);
});
});
});
describe('when `navigation` attribute is provided', () => {
it('should render navigation controls', async () => {
// Arrange
const el = await fixture(html`
<sl-carousel navigation>
<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>
`);
// Assert
expect(el).to.exist;
expect(el.shadowRoot!.querySelector('.carousel__navigation')).to.exist;
expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist;
});
});
describe('when `slides-per-page` attribute is provided', () => {
it('should show multiple slides at a given time', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-page="2">
<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>
`);
// Act
await el.updateComplete;
// Assert
expect(el.scrollContainer.style.getPropertyValue('--slides-per-page').trim()).to.be.equal('2');
});
});
describe('when `slides-per-move` attribute is provided', () => {
it('should set the granularity of snapping', async () => {
// Arrange
const expectedSnapGranularity = 2;
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-move="${expectedSnapGranularity}">
<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-item>Node 4</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
for (let i = 0; i < el.children.length; i++) {
const child = el.children[i] as HTMLElement;
if (i % expectedSnapGranularity === 0) {
expect(child.style.getPropertyValue('scroll-snap-align')).to.be.equal('');
} else {
expect(child.style.getPropertyValue('scroll-snap-align')).to.be.equal('none');
}
}
});
});
describe('when `orientation` attribute is provided', () => {
describe('and value is `vertical`', () => {
it('should make the scrollable along the y-axis', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel orientation="vertical" style="height: 100px">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
expect(el.scrollContainer.scrollWidth).to.be.equal(el.scrollContainer.clientWidth);
expect(el.scrollContainer.scrollHeight).to.be.greaterThan(el.scrollContainer.clientHeight);
});
});
describe('and value is `horizontal`', () => {
it('should make the scrollable along the x-axis', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel orientation="horizontal" style="height: 100px">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
expect(el.scrollContainer.scrollWidth).to.be.greaterThan(el.scrollContainer.clientWidth);
expect(el.scrollContainer.scrollHeight).to.be.equal(el.scrollContainer.clientHeight);
});
});
});
describe('Navigation controls', () => {
describe('when the user clicks the next button', () => {
it('should scroll to the next slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<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>
`);
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
sinon.stub(el, 'next');
await el.updateComplete;
// Act
await clickOnElement(nextButton);
await el.updateComplete;
// Assert
expect(el.next).to.have.been.calledOnce;
});
describe('and carousel is positioned on the last slide', () => {
it('should not scroll', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<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>
`);
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
sinon.stub(el, 'next');
el.goToSlide(2, 'auto');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
// Act
await clickOnElement(nextButton);
await el.updateComplete;
// Assert
expect(nextButton).to.have.attribute('aria-disabled', 'true');
expect(el.next).not.to.have.been.called;
});
describe('and `loop` attribute is provided', () => {
it('should scroll to the first slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation loop>
<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>
`);
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
el.goToSlide(2, 'auto');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
// Act
await clickOnElement(nextButton);
// wait first scroll to clone
await oneEvent(el.scrollContainer, 'scrollend');
// wait scroll to actual item
await oneEvent(el.scrollContainer, 'scrollend');
// Assert
expect(nextButton).to.have.attribute('aria-disabled', 'false');
expect(el.activeSlide).to.be.equal(0);
});
});
});
});
describe('and clicks the previous button', () => {
it('should scroll to the previous slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<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>
`);
// Go to the second slide so that the previous button will be enabled
el.goToSlide(1, 'auto');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
sinon.stub(el, 'previous');
await el.updateComplete;
// Act
await clickOnElement(previousButton);
await el.updateComplete;
// Assert
expect(el.previous).to.have.been.calledOnce;
});
describe('and carousel is positioned on the first slide', () => {
it('should not scroll', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<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>
`);
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
sinon.stub(el, 'previous');
await el.updateComplete;
// Act
await clickOnElement(previousButton);
await el.updateComplete;
// Assert
expect(previousButton).to.have.attribute('aria-disabled', 'true');
expect(el.previous).not.to.have.been.called;
});
describe('and `loop` attribute is provided', () => {
it('should scroll to the last slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation loop>
<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>
`);
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
await el.updateComplete;
// Act
await clickOnElement(previousButton);
// wait first scroll to clone
await oneEvent(el.scrollContainer, 'scrollend');
// wait scroll to actual item
await oneEvent(el.scrollContainer, 'scrollend');
// Assert
expect(previousButton).to.have.attribute('aria-disabled', 'false');
expect(el.activeSlide).to.be.equal(2);
});
});
});
});
});
describe('API', () => {
describe('#next', () => {
it('should scroll the carousel to the next slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-move="2">
<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, 'goToSlide');
await el.updateComplete;
// Act
el.next();
expect(el.goToSlide).to.have.been.calledWith(2);
});
});
describe('#previous', () => {
it('should scroll the carousel to the previous slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-move="2">
<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, 'goToSlide');
await el.updateComplete;
// Act
el.previous();
expect(el.goToSlide).to.have.been.calledWith(-2);
});
});
describe('#goToSlide', () => {
it('should scroll the carousel to the nth slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel>
<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.goToSlide(2);
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
// Assert
expect(el.activeSlide).to.be.equal(2);
});
});
});
describe('Accessibility', () => {
it('should pass accessibility tests', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation pagination>
<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>
`);
const pagination = el.shadowRoot!.querySelector('.carousel__pagination')!;
const navigation = el.shadowRoot!.querySelector('.carousel__navigation')!;
await el.updateComplete;
// Assert
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
expect(el.scrollContainer).to.have.attribute('aria-atomic', 'true');
expect(pagination).to.have.attribute('role', 'tablist');
expect(pagination).to.have.attribute('aria-controls', el.scrollContainer.id);
for (const paginationItem of pagination.querySelectorAll('.carousel__pagination-item')) {
expect(paginationItem).to.have.attribute('role', 'tab');
expect(paginationItem).to.have.attribute('aria-selected');
expect(paginationItem).to.have.attribute('aria-label');
}
for (const navigationItem of navigation.querySelectorAll('.carousel__navigation-item')) {
expect(navigationItem).to.have.attribute('aria-controls', el.scrollContainer.id);
expect(navigationItem).to.have.attribute('aria-disabled');
expect(navigationItem).to.have.attribute('aria-label');
}
await expect(el).to.be.accessible();
});
describe('when scrolling', () => {
it('should update aria-busy attribute', 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.goToSlide(2, 'smooth');
await oneEvent(el.scrollContainer, 'scroll');
await el.updateComplete;
// Assert
expect(el.scrollContainer).to.have.attribute('aria-busy', 'true');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
});
});
});
});

View File

@@ -0,0 +1,468 @@
import '../icon/icon';
import { AutoplayController } from './autoplay-controller';
import { clamp } from 'src/internal/math';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '@shoelace-style/localize';
import { map } from 'lit/directives/map.js';
import { prefersReducedMotion } from '../../internal/animate';
import { range } from 'lit/directives/range.js';
import { ScrollController } from './scroll-controller';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import SlCarouselItem from '../carousel-item/carousel-item';
import styles from './carousel.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
*
* @since 2.0
* @status experimental
*
* @dependency sl-icon
*
* @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes.
*
* @slot - The carousel's main content, one or more `<sl-carousel-item>` elements.
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<sl-icon>`.
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<sl-icon>`.
*
* @csspart base - The carousel's internal wrapper.
* @csspart scroll-container - The scroll container that wraps the slides.
* @csspart pagination - The pagination indicators wrapper.
* @csspart pagination-item - The pagination indicator.
* @csspart pagination-item--active - Applied when the item is active.
* @csspart navigation - The navigation wrapper.
* @csspart navigation-button - The navigation button.
* @csspart navigation-button--previous - Applied to the previous button.
* @csspart navigation-button--next - Applied to the next button.
*
* @cssproperty --slide-gap - The space between each slide.
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
* partially visible as a scroll hint.
*/
@customElement('sl-carousel')
export default class SlCarousel extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
@property({ type: Boolean, reflect: true }) loop = false;
/** When set, show the carousel's navigation. */
@property({ type: Boolean, reflect: true }) navigation = false;
/** When set, show the carousel's pagination indicators. */
@property({ type: Boolean, reflect: true }) pagination = false;
/** When set, the slides will scroll automatically when the user is not interacting with them. */
@property({ type: Boolean, reflect: true }) autoplay = false;
/** Specifies the amount of time, in milliseconds, between each automatic scroll. */
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
/** Specifies how many slides should be shown at a given time. */
@property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1;
/**
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
* greater than one.
*/
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
/** Specifies the orientation in which the carousel will lay out. */
@property() orientation: 'horizontal' | 'vertical' = 'horizontal';
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.carousel__slides') scrollContainer: HTMLElement;
@query('.carousel__pagination') paginationContainer: HTMLElement;
// The index of the active slide
@state() activeSlide = 0;
private autoplayController = new AutoplayController(this, () => this.next());
private scrollController = new ScrollController(this);
private readonly slides = this.getElementsByTagName('sl-carousel-item');
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
// A map containing the state of all the slides
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
private readonly localize = new LocalizeController(this);
private mutationObserver: MutationObserver;
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'region');
this.setAttribute('aria-label', this.localize.term('carousel'));
const intersectionObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
// Store all the entries in a map to be processed when scrolling ends
this.intersectionObserverEntries.set(entry.target, entry);
const slide = entry.target;
slide.toggleAttribute('inert', !entry.isIntersecting);
slide.classList.toggle('--in-view', entry.isIntersecting);
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
});
},
{
root: this,
threshold: 0.6
}
);
this.intersectionObserver = intersectionObserver;
// Store the initial state of each slide
intersectionObserver.takeRecords().forEach(entry => {
this.intersectionObserverEntries.set(entry.target, entry);
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.intersectionObserver.disconnect();
this.mutationObserver.disconnect();
}
protected firstUpdated(): void {
this.initializeSlides();
this.mutationObserver = new MutationObserver(this.handleSlotChange.bind(this));
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'));
}
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');
event.preventDefault();
if (isPrevious) {
this.previous();
}
if (isNext) {
this.next();
}
if (event.key === 'Home') {
this.goToSlide(0);
}
if (event.key === 'End') {
this.goToSlide(this.getSlides().length - 1);
}
if (isFocusInPagination) {
this.updateComplete.then(() => {
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
'[part~="pagination-item--active"]'
);
if (activePaginationItem) {
activePaginationItem.focus();
}
});
}
}
}
private handleScrollEnd() {
const slides = this.getSlides();
const entries = [...this.intersectionObserverEntries.values()];
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
this.goToSlide(clonePosition, 'auto');
return;
}
// Activate the first intersecting slide
if (firstIntersecting) {
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
}
}
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() {
const slides = this.getSlides();
const intersectionObserver = this.intersectionObserver;
this.intersectionObserverEntries.clear();
// Removes all the cloned elements from the carousel
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('slide_num', index + 1));
if (slide.hasAttribute('data-clone')) {
slide.remove();
}
});
if (this.loop) {
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
const slidesPerPage = this.slidesPerPage;
const lastSlides = slides.slice(-slidesPerPage);
const firstSlides = slides.slice(0, slidesPerPage);
lastSlides.reverse().forEach((slide, i) => {
const clone = slide.cloneNode(true) as HTMLElement;
clone.setAttribute('data-clone', String(slides.length - i - 1));
this.prepend(clone);
});
firstSlides.forEach((slide, i) => {
const clone = slide.cloneNode(true) as HTMLElement;
clone.setAttribute('data-clone', String(i));
this.append(clone);
});
}
this.getSlides({ excludeClones: false }).forEach(slide => {
intersectionObserver.observe(slide);
});
// Because the DOM may be changed, restore the scroll position to the active slide
this.goToSlide(this.activeSlide, 'auto');
}
@watch('activeSlide')
handelSlideChange() {
const slides = this.getSlides();
slides.forEach((slide, i) => {
slide.classList.toggle('--is-active', i === this.activeSlide);
});
// Do not emit an event on first render
if (this.hasUpdated) {
this.emit('sl-slide-change', {
detail: {
index: this.activeSlide,
slide: slides[this.activeSlide]
}
});
}
}
@watch('slidesPerMove')
handleSlidesPerMoveChange() {
const slides = this.getSlides({ excludeClones: false });
const slidesPerMove = this.slidesPerMove;
slides.forEach((slide, i) => {
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
if (shouldSnap) {
slide.style.removeProperty('scroll-snap-align');
} else {
slide.style.setProperty('scroll-snap-align', 'none');
}
});
}
@watch('autoplay')
handleAutoplayChange() {
this.autoplayController.stop();
if (this.autoplay) {
this.autoplayController.start(this.autoplayInterval);
}
}
@watch('mouseDragging')
handleMouseDraggingChange() {
this.scrollController.mouseDragging = this.mouseDragging;
}
/**
* 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 } = this;
const slides = this.getSlides();
const slidesWithClones = this.getSlides({ excludeClones: false });
// Sets the next index without taking into account clones, if any.
const newActiveSlide = (index + slides.length) % slides.length;
this.activeSlide = newActiveSlide;
// 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];
this.scrollContainer.scrollTo({
left: nextSlide.offsetLeft,
top: nextSlide.offsetTop,
behavior: prefersReducedMotion() ? 'auto' : behavior
});
}
render() {
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">
<div
id="scroll-container"
part="scroll-container"
class="${classMap({
carousel__slides: true,
'carousel__slides--horizontal': this.orientation === 'horizontal',
'carousel__slides--vertical': this.orientation === 'vertical'
})}"
style="--slides-per-page: ${this.slidesPerPage};"
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
aria-atomic="true"
tabindex="0"
@keydown=${this.handleKeyDown}
@scrollend=${this.handleScrollEnd}
>
<slot></slot>
</div>
${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>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-carousel': SlCarousel;
}
}

View File

@@ -0,0 +1,178 @@
import { debounce } from 'src/internal/debounce';
import { prefersReducedMotion } from 'src/internal/animate';
import { waitForEvent } from 'src/internal/event';
import type { ReactiveController, ReactiveElement } from 'lit';
interface ScrollHost extends ReactiveElement {
scrollContainer: HTMLElement;
}
/**
* A controller for handling scrolling and mouse dragging.
*/
export class ScrollController<T extends ScrollHost> implements ReactiveController {
private host: T;
private pointers = new Set();
dragging = false;
scrolling = false;
mouseDragging = false;
constructor(host: T) {
this.host = host;
host.addController(this);
this.handleScroll = this.handleScroll.bind(this);
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
}
async hostConnected() {
const host = this.host;
await host.updateComplete;
const scrollContainer = host.scrollContainer;
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true });
scrollContainer.addEventListener('touchend', this.handleTouchEnd);
}
hostDisconnected(): void {
const host = this.host;
const scrollContainer = host.scrollContainer;
scrollContainer.removeEventListener('scroll', this.handleScroll);
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
scrollContainer.removeEventListener('touchstart', this.handleTouchStart);
scrollContainer.removeEventListener('touchend', this.handleTouchEnd);
}
handleScroll() {
if (!this.scrolling) {
this.scrolling = true;
this.host.requestUpdate();
}
this.handleScrollEnd();
}
@debounce(100)
handleScrollEnd() {
if (!this.pointers.size) {
this.scrolling = false;
this.host.scrollContainer.dispatchEvent(
new CustomEvent('scrollend', {
bubbles: false,
cancelable: false
})
);
this.host.requestUpdate();
} else {
this.handleScrollEnd();
}
}
handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'touch') {
return;
}
const scrollContainer = this.host.scrollContainer;
this.pointers.add(event.pointerId);
scrollContainer.setPointerCapture(event.pointerId);
if (this.mouseDragging && this.pointers.size === 1) {
event.preventDefault();
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();
}
this.handleDrag(event);
}
}
handlePointerUp(event: PointerEvent) {
const host = this.host;
const scrollContainer = host.scrollContainer;
this.pointers.delete(event.pointerId);
scrollContainer.releasePointerCapture(event.pointerId);
if (this.pointers.size === 0) {
this.handleDragEnd();
}
}
handleTouchEnd(event: TouchEvent) {
for (const touch of event.changedTouches) {
this.pointers.delete(touch.identifier);
}
}
handleTouchStart(event: TouchEvent) {
for (const touch of event.touches) {
this.pointers.add(touch.identifier);
}
}
handleDragStart() {
const host = this.host;
this.dragging = true;
host.scrollContainer.style.setProperty('scroll-snap-type', 'unset');
host.requestUpdate();
}
handleDrag(event: PointerEvent) {
this.host.scrollContainer.scrollBy({
left: -event.movementX,
top: -event.movementY
});
}
async handleDragEnd() {
const host = this.host;
const scrollContainer = host.scrollContainer;
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
this.dragging = false;
const startLeft = scrollContainer.scrollLeft;
const startTop = scrollContainer.scrollTop;
scrollContainer.style.removeProperty('scroll-snap-type');
const finalLeft = scrollContainer.scrollLeft;
const finalTop = scrollContainer.scrollTop;
scrollContainer.style.setProperty('scroll-snap-type', 'unset');
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
if (this.scrolling) {
await waitForEvent(scrollContainer, 'scrollend');
}
scrollContainer.style.removeProperty('scroll-snap-type');
host.requestUpdate();
}
}

View File

@@ -158,6 +158,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();

View File

@@ -493,20 +493,22 @@ describe('<sl-color-picker>', () => {
expect(el.checkValidity()).to.be.true;
});
it.skip('should be invalid when required and empty', async () => {
it('should be invalid when required and empty', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
expect(el.checkValidity()).to.be.false;
});
it.skip('should be invalid when required and disabled is removed', async () => {
it('should be invalid when required and disabled is removed', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker disabled required></sl-color-picker> `);
el.disabled = false;
await el.updateComplete;
expect(el.checkValidity()).to.be.false;
});
it.skip('should receive the correct validation attributes ("states") when valid', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker required value="a"></sl-color-picker> `);
it('should receive the correct validation attributes ("states") when valid', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker required value="#fff"></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!;
const grid = el.shadowRoot!.querySelector('[part~="grid"]')!;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-required')).to.be.true;
@@ -516,18 +518,20 @@ describe('<sl-color-picker>', () => {
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.false;
// // TODO simulate user interaction
// el.focus();
// await sendKeys({ press: 'b' });
// await el.updateComplete;
await clickOnElement(trigger);
await aTimeout(500);
await clickOnElement(grid);
await el.updateComplete;
// expect(el.checkValidity()).to.be.true;
// expect(el.hasAttribute('data-user-invalid')).to.be.false;
// expect(el.hasAttribute('data-user-valid')).to.be.true;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.true;
});
it.skip('should receive the correct validation attributes ("states") when invalid', async () => {
it('should receive the correct validation attributes ("states") when invalid', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!;
const grid = el.shadowRoot!.querySelector('[part~="grid"]')!;
expect(el.hasAttribute('data-required')).to.be.true;
expect(el.hasAttribute('data-optional')).to.be.false;
@@ -536,14 +540,14 @@ describe('<sl-color-picker>', () => {
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.false;
// // TODO simulate user interaction
// el.focus();
// await sendKeys({ press: 'a' });
// await sendKeys({ press: 'Backspace' });
// await el.updateComplete;
await clickOnElement(trigger);
await aTimeout(500);
await clickOnElement(grid);
await el.updateComplete;
// expect(el.hasAttribute('data-user-invalid')).to.be.true;
// expect(el.hasAttribute('data-user-valid')).to.be.false;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.true;
});
});

View File

@@ -20,8 +20,10 @@ import ShoelaceElement from '../../internal/shoelace-element';
import styles from './color-picker.styles';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type SlChangeEvent from '../../events/sl-change';
import type SlDropdown from '../dropdown/dropdown';
import type SlInput from '../input/input';
import type SlInputEvent from '../../events/sl-input';
const hasEyeDropper = 'EyeDropper' in window;
@@ -417,7 +419,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
private handleInputChange(event: CustomEvent) {
private handleInputChange(event: SlChangeEvent) {
const target = event.target as HTMLInputElement;
const oldValue = this.value;
@@ -437,7 +439,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
private handleInputInput(event: CustomEvent) {
private handleInputInput(event: SlInputEvent) {
this.formControlController.updateValidity();
// Prevent the <sl-input>'s sl-input event from bubbling up
@@ -762,6 +764,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (!this.inline && !this.validity.valid) {

View File

@@ -2,6 +2,8 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import type SlDetails from './details';
import type SlHideEvent from '../../events/sl-hide';
import type SlShowEvent from '../../events/sl-show';
describe('<sl-details>', () => {
it('should be visible with the open attribute', async () => {
@@ -134,7 +136,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const showHandler = sinon.spy((event: CustomEvent) => event.preventDefault());
const showHandler = sinon.spy((event: SlShowEvent) => event.preventDefault());
el.addEventListener('sl-show', showHandler);
el.open = true;
@@ -153,7 +155,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const hideHandler = sinon.spy((event: CustomEvent) => event.preventDefault());
const hideHandler = sinon.spy((event: SlHideEvent) => event.preventDefault());
el.addEventListener('sl-hide', hideHandler);
el.open = false;

View File

@@ -6,7 +6,6 @@ import { getAnimation, setDefaultAnimation } from '../../utilities/animation-reg
import { getTabbableBoundary } from '../../internal/tabbable';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { scrollIntoView } from '../../internal/scroll';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
@@ -15,8 +14,8 @@ import type { CSSResultGroup } from 'lit';
import type SlButton from '../button/button';
import type SlIconButton from '../icon-button/icon-button';
import type SlMenu from '../menu/menu';
import type SlMenuItem from '../menu-item/menu-item';
import type SlPopup from '../popup/popup';
import type SlSelectEvent from '../../events/sl-select';
/**
* @summary Dropdowns expose additional content that "drops down" in a panel.
@@ -104,7 +103,6 @@ export default class SlDropdown extends ShoelaceElement {
connectedCallback() {
super.connectedCallback();
this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this);
this.handlePanelSelect = this.handlePanelSelect.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
@@ -201,12 +199,7 @@ export default class SlDropdown extends ShoelaceElement {
}
}
handleMenuItemActivate(event: CustomEvent) {
const item = event.target as SlMenuItem;
scrollIntoView(item, this.panel);
}
handlePanelSelect(event: CustomEvent) {
handlePanelSelect(event: SlSelectEvent) {
const target = event.target as HTMLElement;
// Hide the dropdown when a menu item is selected
@@ -342,7 +335,6 @@ export default class SlDropdown extends ShoelaceElement {
}
addOpenListeners() {
this.panel.addEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.addEventListener('sl-select', this.handlePanelSelect);
this.panel.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keydown', this.handleDocumentKeyDown);
@@ -351,7 +343,6 @@ export default class SlDropdown extends ShoelaceElement {
removeOpenListeners() {
if (this.panel) {
this.panel.removeEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
this.panel.removeEventListener('keydown', this.handleKeyDown);
}

View File

@@ -1,6 +1,8 @@
import { elementUpdated, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { registerIconLibrary } from '../../../dist/shoelace.js';
import type SlErrorEvent from '../../events/sl-error';
import type SlIcon from './icon';
import type SlLoadEvent from '../../events/sl-load';
const testLibraryIcons = {
'test-icon1': `
@@ -46,7 +48,7 @@ describe('<sl-icon>', () => {
it('renders pre-loaded system icons and emits sl-load event', async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="system"></sl-icon> `);
const listener = oneEvent(el, 'sl-load') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-load') as Promise<SlLoadEvent>;
el.name = 'check';
const ev = await listener;
@@ -93,6 +95,7 @@ describe('<sl-icon>', () => {
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(el.shadowRoot?.querySelector('svg')?.part.contains('svg')).to.be.true;
expect(el.shadowRoot?.querySelector('svg')?.getAttribute('id')).to.equal(fakeId);
});
});
@@ -100,7 +103,7 @@ describe('<sl-icon>', () => {
describe('new library', () => {
it('renders icons from the new library and emits sl-load event', async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="test-library"></sl-icon> `);
const listener = oneEvent(el, 'sl-load') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-load') as Promise<SlLoadEvent>;
el.name = 'test-icon1';
const ev = await listener;
@@ -129,7 +132,7 @@ describe('<sl-icon>', () => {
it('emits sl-error when the file cant be retrieved', async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="test-library"></sl-icon> `);
const listener = oneEvent(el, 'sl-error') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-error') as Promise<SlErrorEvent>;
el.name = 'bad-request';
const ev = await listener;
@@ -141,7 +144,7 @@ describe('<sl-icon>', () => {
it("emits sl-error when there isn't an svg element in the registered icon", async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="test-library"></sl-icon> `);
const listener = oneEvent(el, 'sl-error') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-error') as Promise<SlErrorEvent>;
el.name = 'bad-icon';
const ev = await listener;

View File

@@ -18,6 +18,8 @@ let parser: DOMParser;
*
* @event sl-load - Emitted when the icon has loaded.
* @event sl-error - Emitted when the icon fails to load due to an error.
*
* @csspart svg - The internal SVG element.
*/
@customElement('sl-icon')
export default class SlIcon extends ShoelaceElement {
@@ -102,6 +104,7 @@ export default class SlIcon extends ShoelaceElement {
const svgEl = doc.body.querySelector('svg');
if (svgEl !== null) {
svgEl.part.add('svg');
library?.mutator?.(svgEl);
this.svg = svgEl.outerHTML;
this.emit('sl-load');

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

@@ -281,12 +281,6 @@ export default css`
display: none;
}
/* Hide Firefox's clear button on date and time inputs */
.input--is-firefox input[type='date'],
.input--is-firefox input[type='time'] {
clip-path: inset(0 2em 0 0);
}
/* Hide the built-in number spinner */
.input--no-spin-buttons input[type='number']::-webkit-outer-spin-button,
.input--no-spin-buttons input[type='number']::-webkit-inner-spin-button {

View File

@@ -350,7 +350,7 @@ describe('<sl-input>', () => {
await el.updateComplete;
});
it('should not emit sl-change or sl-input when calling setinputText()', async () => {
it('should not emit sl-change or sl-input when calling setRangeText()', async () => {
const el = await fixture<SlInput>(html` <sl-input value="hi there"></sl-input> `);
el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted'));

View File

@@ -14,18 +14,6 @@ import styles from './input.styles';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
//
// It's currently impossible to hide Firefox's built-in clear icon when using <input type="date|time">, so we need this
// check to apply a clip-path to hide it. I know, I know…user agent sniffing is nasty but, if it fails, we only see a
// redundant clear icon so nothing important is breaking. The benefits outweigh the costs for this one. See the
// discussion at: https://github.com/shoelace-style/shoelace/pull/794
//
// Also note that we do the Chromium check first to prevent Chrome from logging a console notice as described here:
// https://github.com/shoelace-style/shoelace/issues/855
//
const isChromium = navigator.userAgentData?.brands.some(b => b.brand.includes('Chromium'));
const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
/**
* @summary Inputs collect data from the user.
* @documentation https://shoelace.style/components/input
@@ -156,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
@@ -389,6 +377,11 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
@@ -447,8 +440,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
'input--disabled': this.disabled,
'input--focused': this.hasFocus,
'input--empty': !this.value,
'input--no-spin-buttons': this.noSpinButtons,
'input--is-firefox': isFirefox
'input--no-spin-buttons': this.noSpinButtons
})}
>
<slot name="prefix" part="prefix" class="input__prefix"></slot>
@@ -469,9 +461,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
max=${ifDefined(this.max)}
step=${ifDefined(this.step as number)}
.value=${live(this.value)}
autocapitalize=${ifDefined(this.type === 'password' ? 'off' : this.autocapitalize)}
autocomplete=${ifDefined(this.type === 'password' ? 'off' : this.autocomplete)}
autocorrect=${ifDefined(this.type === 'password' ? 'off' : this.autocorrect)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${this.spellcheck}
pattern=${ifDefined(this.pattern)}

View File

@@ -60,7 +60,7 @@ export default css`
margin-inline-start: var(--sl-spacing-x-small);
}
:host(:focus) {
:host(:focus-visible) {
outline: none;
}
@@ -69,7 +69,7 @@ export default css`
color: var(--sl-color-neutral-1000);
}
:host(:focus) .menu-item {
:host(:focus-visible) .menu-item {
outline: none;
background-color: var(--sl-color-primary-600);
color: var(--sl-color-neutral-0);
@@ -93,7 +93,7 @@ export default css`
@media (forced-colors: active) {
:host(:hover:not([aria-disabled='true'])) .menu-item,
:host(:focus) .menu-item {
:host(:focus-visible) .menu-item {
outline: dashed 1px SelectedItem;
outline-offset: -1px;
}

View File

@@ -4,7 +4,7 @@ import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlMenu from './menu';
import type SlMenuItem from '../menu-item/menu-item';
import type SlSelectEvent from '../../events/sl-select';
describe('<sl-menu>', () => {
it('emits sl-select with the correct event detail when clicking an item', async () => {
@@ -17,8 +17,8 @@ describe('<sl-menu>', () => {
</sl-menu>
`);
const item2 = menu.querySelectorAll('sl-menu-item')[1];
const selectHandler = sinon.spy((event: CustomEvent) => {
const item = event.detail.item as SlMenuItem; // eslint-disable-line
const selectHandler = sinon.spy((event: SlSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect event detail emitted with sl-select');
}
@@ -40,8 +40,8 @@ describe('<sl-menu>', () => {
</sl-menu>
`);
const [item1, item2] = menu.querySelectorAll('sl-menu-item');
const selectHandler = sinon.spy((event: CustomEvent) => {
const item = event.detail.item as SlMenuItem; // eslint-disable-line
const selectHandler = sinon.spy((event: SlSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect item selected');
}

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

@@ -1,7 +1,8 @@
import { arrow, autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { offsetParent } from 'composed-offset-position';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './popup.styles';
import type { CSSResultGroup } from 'lit';
@@ -76,8 +77,8 @@ export default class SlPopup extends ShoelaceElement {
| 'left-end' = 'top';
/**
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if
* overflow is clipped, using a `fixed` position strategy can often workaround it.
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if overflow is
* clipped, using a `fixed` position strategy can often workaround it.
*/
@property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
@@ -365,10 +366,24 @@ export default class SlPopup extends ShoelaceElement {
);
}
//
// Use custom positioning logic if the strategy is absolute. Otherwise, fall back to the default logic.
//
// More info: https://github.com/shoelace-style/shoelace/issues/1135
//
const getOffsetParent =
this.strategy === 'absolute'
? (element: Element) => platform.getOffsetParent(element, offsetParent)
: platform.getOffsetParent;
computePosition(this.anchorEl, this.popup, {
placement: this.placement,
middleware,
strategy: this.strategy
strategy: this.strategy,
platform: {
...platform,
getOffsetParent
}
}).then(({ x, y, middlewareData, placement }) => {
//
// Even though we have our own localization utility, it uses different heuristics to determine RTL. Because of

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

@@ -3,6 +3,7 @@ import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlChangeEvent from '../../events/sl-change';
import type SlRadio from '../radio/radio';
import type SlRadioGroup from './radio-group';
@@ -283,7 +284,7 @@ describe('when the value changes', () => {
`);
const radio = radioGroup.querySelector<SlRadio>('#radio-1')!;
setTimeout(() => radio.click());
const event = (await oneEvent(radioGroup, 'sl-change')) as CustomEvent;
const event = (await oneEvent(radioGroup, 'sl-change')) as SlChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
@@ -298,7 +299,7 @@ describe('when the value changes', () => {
const radio = radioGroup.querySelector<SlRadio>('#radio-1')!;
radio.focus();
setTimeout(() => sendKeys({ press: ' ' }));
const event = (await oneEvent(radioGroup, 'sl-change')) as CustomEvent;
const event = (await oneEvent(radioGroup, 'sl-change')) as SlChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});

View File

@@ -197,27 +197,35 @@ 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));
radios.forEach(radio => (radio.checked = radio.value === this.value));
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());
}
}
@@ -252,12 +260,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
return true;
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message = '') {
this.customValidityMessage = message;
this.errorMessage = message;
this.validationInput.setCustomValidity(message);
this.formControlController.updateValidity();
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
@@ -279,6 +284,14 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
return isValid;
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message = '') {
this.customValidityMessage = message;
this.errorMessage = message;
this.validationInput.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');

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;
}
@@ -254,6 +255,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();

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

@@ -85,6 +85,8 @@ export default css`
.select__value-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0;

View File

@@ -162,6 +162,32 @@ describe('<sl-select>', () => {
await el.updateComplete;
});
it('should emit sl-change and sl-input with the correct validation message when the value changes', async () => {
const el = await fixture<SlSelect>(html`
<sl-select required>
<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>
`);
const option2 = el.querySelectorAll('sl-option')[1];
const handler = sinon.spy((event: CustomEvent) => {
if (el.validationMessage) {
expect.fail(`Validation message should be empty when ${event.type} is emitted and a value is set`);
}
});
el.addEventListener('sl-change', handler);
el.addEventListener('sl-input', handler);
await clickOnElement(el);
await aTimeout(500);
await clickOnElement(option2);
await el.updateComplete;
expect(handler).to.be.calledTwice;
});
});
it('should open the listbox when any letter key is pressed with sl-select is on focus', async () => {

View File

@@ -19,6 +19,7 @@ import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type SlOption from '../option/option';
import type SlPopup from '../popup/popup';
import type SlRemoveEvent from '../../events/sl-remove';
/**
* @summary Selects allow you to choose items from a menu of predefined options.
@@ -58,6 +59,10 @@ import type SlPopup from '../popup/popup';
* @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.
*/
@@ -252,8 +257,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.setSelectedOptions(this.currentOption);
}
this.emit('sl-input');
this.emit('sl-change');
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
if (!this.multiple) {
this.hide();
@@ -377,9 +385,13 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
if (this.value !== '') {
this.setSelectedOptions([]);
this.displayInput.focus({ preventScroll: true });
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
// Emit after update
this.updateComplete.then(() => {
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
});
}
}
@@ -405,8 +417,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
if (this.value !== oldValue) {
this.emit('sl-input');
this.emit('sl-change');
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
if (!this.multiple) {
@@ -422,27 +437,28 @@ 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: CustomEvent, option: SlOption) {
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
event.stopPropagation();
if (!this.disabled) {
this.toggleOptionSelection(option, false);
this.emit('sl-input');
this.emit('sl-change');
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
}
@@ -624,6 +640,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
return this.valueInput.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.valueInput.reportValidity();
@@ -741,10 +762,16 @@ 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
@sl-remove=${(event: CustomEvent) => this.handleTagRemove(event, option)}
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
>
${option.getTextLabel()}
</sl-tag>

View File

@@ -34,7 +34,6 @@ export default css`
.spinner__track {
stroke: var(--track-color);
transform-origin: 0% 0%;
mix-blend-mode: multiply;
}
.spinner__indicator {

View File

@@ -163,6 +163,11 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();

View File

@@ -9,16 +9,13 @@ import type { HTMLTemplateResult } from 'lit';
import type SlTab from '../tab/tab';
import type SlTabGroup from './tab-group';
import type SlTabPanel from '../tab-panel/tab-panel';
import type SlTabShowEvent from '../../events/sl-tab-show';
interface ClientRectangles {
body?: DOMRect;
navigation?: DOMRect;
}
interface CustomEventPayload {
name: string;
}
const waitForScrollButtonsToBeRendered = async (tabGroup: SlTabGroup): Promise<void> => {
await waitUntil(() => {
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
@@ -57,9 +54,9 @@ const expectOnlyOneTabPanelToBeActive = async (container: HTMLElement, dataTestI
expect(activeTabPanels[0]).to.have.attribute('data-testid', dataTestIdOfActiveTab);
};
const expectPromiseToHaveName = async (showEventPromise: Promise<CustomEvent>, expectedName: string) => {
const expectPromiseToHaveName = async (showEventPromise: Promise<SlTabShowEvent>, expectedName: string) => {
const showEvent = await showEventPromise;
expect((showEvent.detail as CustomEventPayload).name).to.equal(expectedName);
expect(showEvent.detail.name).to.equal(expectedName);
};
const waitForHeaderToBeActive = async (container: HTMLElement, headerTestId: string): Promise<SlTab> => {
@@ -306,7 +303,7 @@ describe('<sl-tab-group>', () => {
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<CustomEvent>;
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<SlTabShowEvent>;
await action();
expect(customHeader).to.have.attribute('active');
@@ -405,7 +402,7 @@ describe('<sl-tab-group>', () => {
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<CustomEvent>;
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<SlTabShowEvent>;
await sendKeys({ press: 'ArrowRight' });
await aTimeout(0);
expect(generalHeader).to.have.attribute('active');

View File

@@ -316,6 +316,9 @@ export default class SlTabGroup extends ShoelaceElement {
this.tabs = this.getAllTabs({ includeDisabled: false });
this.panels = this.getAllPanels();
this.syncIndicator();
// After updating, show or hide scroll controls as needed
this.updateComplete.then(() => this.updateScrollControls());
}
@watch('noScrollControls', { waitUntilFirstUpdate: true })

View File

@@ -1,6 +1,8 @@
import { expect, fixture, html } from '@open-wc/testing';
import sinon from 'sinon';
import type SlIconButton from '../icon-button/icon-button';
import type SlTab from './tab';
import type SlTabGroup from '../tab-group/tab-group';
describe('<sl-tab>', () => {
it('passes accessibility test', async () => {
@@ -88,17 +90,31 @@ describe('<sl-tab>', () => {
});
describe('closable', () => {
it('should emit close event when close button clicked', async () => {
const el = await fixture<SlTab>(html` <sl-tab closable>Test</sl-tab> `);
it('should emit close event when the close button is clicked', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" closable>General</sl-tab>
<sl-tab slot="nav" panel="custom" closable>Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const closeButton = tabGroup
.querySelectorAll('sl-tab')[0]!
.shadowRoot!.querySelector<SlIconButton>('[part~="close-button"]')!;
const closeButton = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="close-button"]')!;
const spy = sinon.spy();
const handleClose = sinon.spy();
const handleTabShow = sinon.spy();
el.addEventListener('sl-close', spy, { once: true });
tabGroup.addEventListener('sl-close', handleClose, { once: true });
// The sl-tab-show event shouldn't be emitted when clicking the close button
tabGroup.addEventListener('sl-tab-show', handleTabShow);
closeButton.click();
await closeButton?.updateComplete;
expect(spy.called).to.equal(true);
expect(handleClose.called).to.equal(true);
expect(handleTabShow.called).to.equal(false);
});
});
});

View File

@@ -53,7 +53,8 @@ export default class SlTab extends ShoelaceElement {
this.setAttribute('role', 'tab');
}
private handleCloseClick() {
private handleCloseClick(event: Event) {
event.stopPropagation();
this.emit('sl-close');
}

View File

@@ -281,6 +281,11 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();

View File

@@ -1,4 +1,5 @@
import { expect, fixture, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing';
import { aTimeout, expect, fixture, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlTree from './tree';
@@ -433,7 +434,7 @@ describe('<sl-tree>', () => {
const expandButton: HTMLElement = node.shadowRoot!.querySelector('.tree-item__expand-button')!;
// Act
expandButton.click();
await clickOnElement(expandButton);
await el.updateComplete;
// Assert
@@ -453,10 +454,10 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
node0.click();
await clickOnElement(node0);
await el.updateComplete;
node1.click();
await clickOnElement(node1);
await el.updateComplete;
// Assert
@@ -474,10 +475,10 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
node0.click();
await clickOnElement(node0);
await el.updateComplete;
node1.click();
await clickOnElement(node1);
await el.updateComplete;
// Assert
@@ -492,7 +493,7 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
parentNode.click();
await clickOnElement(parentNode);
await parentNode.updateComplete;
// Assert
@@ -511,10 +512,10 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
node0.click();
await clickOnElement(node0);
await el.updateComplete;
node1.click();
await clickOnElement(node1);
await el.updateComplete;
// Assert
@@ -529,7 +530,7 @@ describe('<sl-tree>', () => {
const parentNode = el.children[2] as SlTreeItem;
// Act
parentNode.click();
await clickOnElement(parentNode);
await el.updateComplete;
// Assert
@@ -549,7 +550,10 @@ describe('<sl-tree>', () => {
const childNode = parentNode.children[0] as SlTreeItem;
// Act
childNode.click();
parentNode.expanded = true;
await parentNode.updateComplete;
await aTimeout(300);
await clickOnElement(childNode);
await el.updateComplete;
// Assert
@@ -572,9 +576,9 @@ describe('<sl-tree>', () => {
const node = el.children[0] as SlTreeItem;
// Act
node.click();
await clickOnElement(node);
await el.updateComplete;
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert
@@ -598,9 +602,9 @@ describe('<sl-tree>', () => {
const node = el.children[0] as SlTreeItem;
// Act
node.click();
await clickOnElement(node);
await el.updateComplete;
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert
@@ -621,7 +625,7 @@ describe('<sl-tree>', () => {
const node = el.querySelector<SlTreeItem>('#expandable')!;
// Act
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert
@@ -643,9 +647,9 @@ describe('<sl-tree>', () => {
const node = el.children[0] as SlTreeItem;
// Act
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert

View File

@@ -54,7 +54,7 @@ function syncCheckboxes(changedTreeItem: SlTreeItem, initialSync = false) {
* @status stable
* @since 2.0
*
* @event {{ selection: TreeItem[] }} sl-selection-change - Emitted when a tree item is selected or deselected.
* @event {{ selection: SlTreeItem[] }} sl-selection-change - Emitted when a tree item is selected or deselected.
*
* @slot - The default slot.
* @slot expand-icon - The icon to show when the tree item is expanded. Works best with `<sl-icon>`.
@@ -90,6 +90,7 @@ export default class SlTree extends ShoelaceElement {
private lastFocusedItem: SlTreeItem;
private readonly localize = new LocalizeController(this);
private mutationObserver: MutationObserver;
private clickTarget: SlTreeItem | null = null;
async connectedCallback() {
super.connectedCallback();
@@ -292,13 +293,20 @@ export default class SlTree extends ShoelaceElement {
}
private handleClick(event: Event) {
const target = event.target as HTMLElement;
const target = event.target as SlTreeItem;
const treeItem = target.closest('sl-tree-item')!;
const isExpandButton = event
.composedPath()
.some((el: HTMLElement) => el?.classList?.contains('tree-item__expand-button'));
if (!treeItem || treeItem.disabled) {
//
// Don't Do anything if there's no tree item, if it's disabled, or if the click doesn't match the initial target
// from mousedown. The latter case prevents the user from starting a click on one item and ending it on another,
// causing the parent node to collapse.
//
// See https://github.com/shoelace-style/shoelace/issues/1082
//
if (!treeItem || treeItem.disabled || target !== this.clickTarget) {
return;
}
@@ -309,6 +317,11 @@ export default class SlTree extends ShoelaceElement {
}
}
handleMouseDown(event: MouseEvent) {
// Record the click target so we know which item the click initially targeted
this.clickTarget = event.target as SlTreeItem;
}
private handleFocusOut(event: FocusEvent) {
const relatedTarget = event.relatedTarget as HTMLElement;
@@ -392,7 +405,13 @@ export default class SlTree extends ShoelaceElement {
render() {
return html`
<div part="base" class="tree" @click=${this.handleClick} @keydown=${this.handleKeyDown}>
<div
part="base"
class="tree"
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mousedown=${this.handleMouseDown}
>
<slot @slotchange=${this.handleSlotChange}></slot>
<slot name="expand-icon" hidden aria-hidden="true"> </slot>
<slot name="collapse-icon" hidden aria-hidden="true"> </slot>

34
src/events/events.ts Normal file
View File

@@ -0,0 +1,34 @@
export { default as SlAfterCollapseEvent } from './sl-after-collapse';
export { default as SlAfterExpandEvent } from './sl-after-expand';
export { default as SlAfterHideEvent } from './sl-after-hide';
export { default as SlAfterShowEvent } from './sl-after-show';
export { default as SlBlurEvent } from './sl-blur';
export { default as SlCancelEvent } from './sl-cancel';
export { default as SlChangeEvent } from './sl-change';
export { default as SlClearEvent } from './sl-clear';
export { default as SlCloseEvent } from './sl-close';
export { default as SlCollapseEvent } from './sl-collapse';
export { default as SlErrorEvent } from './sl-error';
export { default as SlExpandEvent } from './sl-expand';
export { default as SlFinishEvent } from './sl-finish';
export { default as SlFocusEvent } from './sl-focus';
export { default as SlHideEvent } from './sl-hide';
export { default as SlHoverEvent } from './sl-hover';
export { default as SlInitialFocusEvent } from './sl-initial-focus';
export { default as SlInputEvent } from './sl-input';
export { default as SlInvalidEvent } from './sl-invalid';
export { default as SlLazyChangeEvent } from './sl-lazy-change';
export { default as SlLazyLoadEvent } from './sl-lazy-load';
export { default as SlLoadEvent } from './sl-load';
export { default as SlMutationEvent } from './sl-mutation';
export { default as SlRemoveEvent } from './sl-remove';
export { default as SlRepositionEvent } from './sl-reposition';
export { default as SlRequestCloseEvent } from './sl-request-close';
export { default as SlResizeEvent } from './sl-resize';
export { default as SlSelectEvent } from './sl-select';
export { default as SlSelectionChangeEvent } from './sl-selection-change';
export { default as SlShowEvent } from './sl-show';
export { default as SlSlideChange } from './sl-slide-change';
export { default as SlStartEvent } from './sl-start';
export { default as SlTabHideEvent } from './sl-tab-hide';
export { default as SlTabShowEvent } from './sl-tab-show';

View File

@@ -0,0 +1,9 @@
type SlAfterCollapseEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-after-collapse': SlAfterCollapseEvent;
}
}
export default SlAfterCollapseEvent;

View File

@@ -0,0 +1,9 @@
type SlAfterExpandEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-after-expand': SlAfterExpandEvent;
}
}
export default SlAfterExpandEvent;

View File

@@ -0,0 +1,9 @@
type SlAfterHideEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-after-hide': SlAfterHideEvent;
}
}
export default SlAfterHideEvent;

View File

@@ -0,0 +1,9 @@
type SlAfterShowEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-after-show': SlAfterShowEvent;
}
}
export default SlAfterShowEvent;

9
src/events/sl-blur.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlBlurEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-blur': SlBlurEvent;
}
}
export default SlBlurEvent;

9
src/events/sl-cancel.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlCancelEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-cancel': SlCancelEvent;
}
}
export default SlCancelEvent;

9
src/events/sl-change.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlChangeEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-change': SlChangeEvent;
}
}
export default SlChangeEvent;

9
src/events/sl-clear.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlClearEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-clear': SlClearEvent;
}
}
export default SlClearEvent;

9
src/events/sl-close.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlCloseEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-close': SlCloseEvent;
}
}
export default SlCloseEvent;

View File

@@ -0,0 +1,9 @@
type SlCollapseEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-collapse': SlCollapseEvent;
}
}
export default SlCollapseEvent;

9
src/events/sl-error.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlErrorEvent = CustomEvent<{ status?: number }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-error': SlErrorEvent;
}
}
export default SlErrorEvent;

9
src/events/sl-expand.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlExpandEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-expand': SlExpandEvent;
}
}
export default SlExpandEvent;

9
src/events/sl-finish.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlFinishEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-finish': SlFinishEvent;
}
}
export default SlFinishEvent;

9
src/events/sl-focus.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlFocusEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-focus': SlFocusEvent;
}
}
export default SlFocusEvent;

9
src/events/sl-hide.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlHideEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-hide': SlHideEvent;
}
}
export default SlHideEvent;

12
src/events/sl-hover.ts Normal file
View File

@@ -0,0 +1,12 @@
type SlHoverEvent = CustomEvent<{
phase: 'start' | 'move' | 'end';
value: number;
}>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-hover': SlHoverEvent;
}
}
export default SlHoverEvent;

View File

@@ -0,0 +1,9 @@
type SlInitialFocusEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-initial-focus': SlInitialFocusEvent;
}
}
export default SlInitialFocusEvent;

9
src/events/sl-input.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlInputEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-input': SlInputEvent;
}
}
export default SlInputEvent;

9
src/events/sl-invalid.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlInvalidEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-invalid': SlInvalidEvent;
}
}
export default SlInvalidEvent;

View File

@@ -0,0 +1,9 @@
type SlLazyChangeEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-lazy-change': SlLazyChangeEvent;
}
}
export default SlLazyChangeEvent;

View File

@@ -0,0 +1,9 @@
type SlLazyLoadEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-lazy-load': SlLazyLoadEvent;
}
}
export default SlLazyLoadEvent;

9
src/events/sl-load.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlLoadEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-load': SlLoadEvent;
}
}
export default SlLoadEvent;

View File

@@ -0,0 +1,9 @@
type SlMutationEvent = CustomEvent<{ mutationList: MutationRecord[] }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-mutation': SlMutationEvent;
}
}
export default SlMutationEvent;

9
src/events/sl-remove.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlRemoveEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-remove': SlRemoveEvent;
}
}
export default SlRemoveEvent;

View File

@@ -0,0 +1,9 @@
type SlRepositionEvent = CustomEvent<Record<PropertyKey, never>>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-reposition': SlRepositionEvent;
}
}
export default SlRepositionEvent;

View File

@@ -0,0 +1,9 @@
type SlRequestCloseEvent = CustomEvent<{ source: 'close-button' | 'keyboard' | 'overlay' }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-request-close': SlRequestCloseEvent;
}
}
export default SlRequestCloseEvent;

9
src/events/sl-resize.ts Normal file
View File

@@ -0,0 +1,9 @@
type SlResizeEvent = CustomEvent<{ entries: ResizeObserverEntry[] }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-resize': SlResizeEvent;
}
}
export default SlResizeEvent;

11
src/events/sl-select.ts Normal file
View File

@@ -0,0 +1,11 @@
import type SlMenuItem from '../components/menu-item/menu-item';
type SlSelectEvent = CustomEvent<{ item: SlMenuItem }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-select': SlSelectEvent;
}
}
export default SlSelectEvent;

View File

@@ -0,0 +1,11 @@
import type SlTreeItem from '../components/tree-item/tree-item';
type SlSelectionChangeEvent = CustomEvent<{ selection: SlTreeItem[] }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-selection-change': SlSelectionChangeEvent;
}
}
export default SlSelectionChangeEvent;

Some files were not shown because too many files have changed in this diff Show More