mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-19 07:29:14 +00:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df25f8617b | ||
|
|
ad2099a27f | ||
|
|
708127f96d | ||
|
|
9deb51e95a | ||
|
|
67852ea657 | ||
|
|
7240f4f8f4 | ||
|
|
17ee89a5e8 | ||
|
|
f2177dccaf | ||
|
|
6aaf17b81a | ||
|
|
d113d13792 | ||
|
|
ab9cb5f185 | ||
|
|
76fd7aa28d | ||
|
|
8f17bf4e9d | ||
|
|
0f0f71af9b | ||
|
|
e624701022 | ||
|
|
4cedfc3201 | ||
|
|
d88d9fc81a | ||
|
|
051baa4ff5 | ||
|
|
57c3d7009b | ||
|
|
a27fd4d2e9 | ||
|
|
79ac425e2b | ||
|
|
857f318f9c | ||
|
|
c0966bf767 | ||
|
|
86cecc9e30 | ||
|
|
ec036d8e61 | ||
|
|
77b25f4581 | ||
|
|
a8d59b3329 | ||
|
|
5990fbd000 | ||
|
|
954d78dcd1 | ||
|
|
3ea31389dd | ||
|
|
d79799043a | ||
|
|
9f8ce58288 | ||
|
|
2371c5490f | ||
|
|
77abd42d66 | ||
|
|
218e78e947 | ||
|
|
8a1efac9b8 | ||
|
|
f9ae8327f6 | ||
|
|
7f3076d195 | ||
|
|
1fa79e64ae | ||
|
|
dde1010465 | ||
|
|
3a3a7347bc | ||
|
|
77c9750206 | ||
|
|
3d2e618be8 | ||
|
|
79feaae7fc | ||
|
|
b0f7dfb86b | ||
|
|
e1979b8f38 | ||
|
|
7e9ae32b9b | ||
|
|
480a1df246 | ||
|
|
ff798adb49 | ||
|
|
70a64262e9 | ||
|
|
5f65896150 | ||
|
|
c69db4919b | ||
|
|
a526e8a956 | ||
|
|
4970ba065e | ||
|
|
0292ed30c5 | ||
|
|
b64b1c2536 | ||
|
|
8f9eb012ba | ||
|
|
c8fd9f19d2 | ||
|
|
603aa93322 | ||
|
|
74203de094 | ||
|
|
4fa4682a45 | ||
|
|
34e0fb2fc1 | ||
|
|
50972f2b38 | ||
|
|
652ce6c9f1 | ||
|
|
8412b150b2 | ||
|
|
22b8ef4edf | ||
|
|
0865dede6f | ||
|
|
d638d811ad | ||
|
|
bc58472b7b | ||
|
|
226c856b1e | ||
|
|
a127b8722e | ||
|
|
9c573fb454 | ||
|
|
a346d18930 | ||
|
|
a32488baeb | ||
|
|
a4131caeda | ||
|
|
6c62a4f4c0 | ||
|
|
5b12de1edf | ||
|
|
f79a670ca3 | ||
|
|
d818980dea | ||
|
|
636f61006f | ||
|
|
d93e698baf | ||
|
|
f8d8291caa | ||
|
|
21bef1c2ea | ||
|
|
d6a7820a52 | ||
|
|
39ca1208f5 | ||
|
|
610a06bcb9 | ||
|
|
b8584c0581 | ||
|
|
ab19afeb66 | ||
|
|
41b5cb367f | ||
|
|
e65b09fdec | ||
|
|
15a4049a01 | ||
|
|
ce708fbba8 | ||
|
|
75bd7784fb | ||
|
|
6e092ccf7a | ||
|
|
b7b73ea3a9 | ||
|
|
9dab91e0d1 | ||
|
|
a3a802a37b | ||
|
|
358ad7bb30 | ||
|
|
0a555c53c7 | ||
|
|
48ccc95dd9 | ||
|
|
c6a6a77bbd |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,5 @@
|
||||
docs/dist
|
||||
docs/search.json
|
||||
dist
|
||||
examples
|
||||
node_modules
|
||||
src/react
|
||||
|
||||
14
cspell.json
14
cspell.json
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
docs/assets/examples/carousel/field.jpg
Normal file
BIN
docs/assets/examples/carousel/field.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/assets/examples/carousel/mountains.jpg
Normal file
BIN
docs/assets/examples/carousel/mountains.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/assets/examples/carousel/sunset.jpg
Normal file
BIN
docs/assets/examples/carousel/sunset.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/assets/examples/carousel/valley.jpg
Normal file
BIN
docs/assets/examples/carousel/valley.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/assets/examples/carousel/waterfall.jpg
Normal file
BIN
docs/assets/examples/carousel/waterfall.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
81
docs/components/carousel-item.md
Normal file
81
docs/components/carousel-item.md
Normal 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
1221
docs/components/carousel.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
59
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
26
src/components/carousel-item/carousel-item.styles.ts
Normal file
26
src/components/carousel-item/carousel-item.styles.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
17
src/components/carousel-item/carousel-item.test.ts
Normal file
17
src/components/carousel-item/carousel-item.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
src/components/carousel-item/carousel-item.ts
Normal file
40
src/components/carousel-item/carousel-item.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/components/carousel/autoplay-controller.ts
Normal file
73
src/components/carousel/autoplay-controller.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
160
src/components/carousel/carousel.styles.ts
Normal file
160
src/components/carousel/carousel.styles.ts
Normal 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);
|
||||
}
|
||||
`;
|
||||
588
src/components/carousel/carousel.test.ts
Normal file
588
src/components/carousel/carousel.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
468
src/components/carousel/carousel.ts
Normal file
468
src/components/carousel/carousel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
178
src/components/carousel/scroll-controller.ts
Normal file
178
src/components/carousel/scroll-controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '_');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ export default css`
|
||||
|
||||
.select__value-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,7 +34,6 @@ export default css`
|
||||
.spinner__track {
|
||||
stroke: var(--track-color);
|
||||
transform-origin: 0% 0%;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.spinner__indicator {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
34
src/events/events.ts
Normal 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';
|
||||
9
src/events/sl-after-collapse.ts
Normal file
9
src/events/sl-after-collapse.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlAfterCollapseEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-after-collapse': SlAfterCollapseEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlAfterCollapseEvent;
|
||||
9
src/events/sl-after-expand.ts
Normal file
9
src/events/sl-after-expand.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlAfterExpandEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-after-expand': SlAfterExpandEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlAfterExpandEvent;
|
||||
9
src/events/sl-after-hide.ts
Normal file
9
src/events/sl-after-hide.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlAfterHideEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-after-hide': SlAfterHideEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlAfterHideEvent;
|
||||
9
src/events/sl-after-show.ts
Normal file
9
src/events/sl-after-show.ts
Normal 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
9
src/events/sl-blur.ts
Normal 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
9
src/events/sl-cancel.ts
Normal 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
9
src/events/sl-change.ts
Normal 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
9
src/events/sl-clear.ts
Normal 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
9
src/events/sl-close.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlCloseEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-close': SlCloseEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlCloseEvent;
|
||||
9
src/events/sl-collapse.ts
Normal file
9
src/events/sl-collapse.ts
Normal 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
9
src/events/sl-error.ts
Normal 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
9
src/events/sl-expand.ts
Normal 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
9
src/events/sl-finish.ts
Normal 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
9
src/events/sl-focus.ts
Normal 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
9
src/events/sl-hide.ts
Normal 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
12
src/events/sl-hover.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
type SlHoverEvent = CustomEvent<{
|
||||
phase: 'start' | 'move' | 'end';
|
||||
value: number;
|
||||
}>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-hover': SlHoverEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlHoverEvent;
|
||||
9
src/events/sl-initial-focus.ts
Normal file
9
src/events/sl-initial-focus.ts
Normal 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
9
src/events/sl-input.ts
Normal 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
9
src/events/sl-invalid.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlInvalidEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-invalid': SlInvalidEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlInvalidEvent;
|
||||
9
src/events/sl-lazy-change.ts
Normal file
9
src/events/sl-lazy-change.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlLazyChangeEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-lazy-change': SlLazyChangeEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlLazyChangeEvent;
|
||||
9
src/events/sl-lazy-load.ts
Normal file
9
src/events/sl-lazy-load.ts
Normal 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
9
src/events/sl-load.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlLoadEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-load': SlLoadEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlLoadEvent;
|
||||
9
src/events/sl-mutation.ts
Normal file
9
src/events/sl-mutation.ts
Normal 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
9
src/events/sl-remove.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlRemoveEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-remove': SlRemoveEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlRemoveEvent;
|
||||
9
src/events/sl-reposition.ts
Normal file
9
src/events/sl-reposition.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type SlRepositionEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-reposition': SlRepositionEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlRepositionEvent;
|
||||
9
src/events/sl-request-close.ts
Normal file
9
src/events/sl-request-close.ts
Normal 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
9
src/events/sl-resize.ts
Normal 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
11
src/events/sl-select.ts
Normal 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;
|
||||
11
src/events/sl-selection-change.ts
Normal file
11
src/events/sl-selection-change.ts
Normal 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
Reference in New Issue
Block a user