kopia lustrzana https://github.com/shoelace-style/shoelace
Merge branch 'next' into autoload
commit
2371c5490f
|
@ -3,6 +3,5 @@
|
|||
docs/dist
|
||||
docs/search.json
|
||||
dist
|
||||
examples
|
||||
node_modules
|
||||
src/react
|
||||
|
|
10
cspell.json
10
cspell.json
|
@ -30,6 +30,7 @@
|
|||
"Consolas",
|
||||
"contenteditable",
|
||||
"copydir",
|
||||
"Cotte",
|
||||
"coverpage",
|
||||
"crossorigin",
|
||||
"crutchcorn",
|
||||
|
@ -56,6 +57,7 @@
|
|||
"FOUC",
|
||||
"FOUCE",
|
||||
"fullscreen",
|
||||
"gestern",
|
||||
"giga",
|
||||
"globby",
|
||||
"Grayscale",
|
||||
|
@ -73,10 +75,12 @@
|
|||
"jsonata",
|
||||
"keydown",
|
||||
"keyframes",
|
||||
"Kool",
|
||||
"labelledby",
|
||||
"Laravel",
|
||||
"LaViska",
|
||||
"listbox",
|
||||
"listitem",
|
||||
"litelement",
|
||||
"lowercasing",
|
||||
"Lucide",
|
||||
|
@ -109,9 +113,13 @@
|
|||
"rgba",
|
||||
"roadmap",
|
||||
"Roboto",
|
||||
"roledescription",
|
||||
"Sapan",
|
||||
"saturationl",
|
||||
"Schilp",
|
||||
"scrollbars",
|
||||
"scrollend",
|
||||
"scroller",
|
||||
"Segoe",
|
||||
"semibold",
|
||||
"slotchange",
|
||||
|
@ -124,6 +132,7 @@
|
|||
"tabpanel",
|
||||
"templating",
|
||||
"tera",
|
||||
"testid",
|
||||
"textareas",
|
||||
"textfield",
|
||||
"tinycolor",
|
||||
|
@ -146,6 +155,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)
|
||||
|
|
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 91 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 69 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 100 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 53 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 175 KiB |
|
@ -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]
|
Plik diff jest za duży
Load Diff
|
@ -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.
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -10,8 +10,20 @@ New versions of Shoelace are released as-needed and generally occur when a criti
|
|||
|
||||
- Added an experimental autoloader
|
||||
- Added the `subpath` argument to `getBasePath()` to make it easier to generate full paths to any file
|
||||
- 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)
|
||||
- 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)
|
||||
- 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.
|
||||
|
||||
|
@ -282,8 +294,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
|
||||
|
|
|
@ -10,10 +10,11 @@
|
|||
"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",
|
||||
|
|
|
@ -63,10 +63,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"
|
||||
},
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
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', 'listitem');
|
||||
this.setAttribute('aria-roledescription', 'slide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel-item': SlCarouselItem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
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;
|
||||
|
||||
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 = () => {
|
||||
this.paused = true;
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
|
||||
resume = () => {
|
||||
this.paused = false;
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
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;
|
||||
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));
|
||||
|
||||
--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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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-medium);
|
||||
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);
|
||||
will-change: transform;
|
||||
transition: var(--sl-transition-fast) ease-in;
|
||||
}
|
||||
|
||||
.carousel__pagination-item--active {
|
||||
background-color: var(--sl-color-neutral-600);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,601 @@
|
|||
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-roledescription', '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;
|
||||
});
|
||||
});
|
||||
|
||||
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-live', 'polite');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when autoplay is active', () => {
|
||||
it('should disable live announcement', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel autoplay>
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
expect(el.scrollContainer).to.have.attribute('aria-live', 'off');
|
||||
});
|
||||
|
||||
describe('and user is interacting with the carousel', () => {
|
||||
it('should enable live announcement', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel autoplay>
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
// Act
|
||||
el.dispatchEvent(new Event('focusin'));
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
expect(el.scrollContainer).to.have.attribute('aria-live', 'polite');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,421 @@
|
|||
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 { when } from 'lit/directives/when.js';
|
||||
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-roledescription', '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 getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel backward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
previous(behavior: ScrollBehavior = 'smooth') {
|
||||
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 slidesWithClones = this.getSlides({ excludeClones: false });
|
||||
const normalizedIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
|
||||
const slide = slidesWithClones[normalizedIndex];
|
||||
|
||||
this.scrollContainer.scrollTo({
|
||||
left: slide.offsetLeft,
|
||||
top: slide.offsetTop,
|
||||
behavior: prefersReducedMotion() ? 'auto' : behavior
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@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 => {
|
||||
intersectionObserver.unobserve(slide);
|
||||
|
||||
slide.classList.remove('--in-view');
|
||||
slide.classList.remove('--is-active');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private renderPagination = () => {
|
||||
const slides = this.getSlides();
|
||||
const slidesCount = slides.length;
|
||||
|
||||
const { activeSlide, slidesPerPage } = this;
|
||||
const pagesCount = Math.ceil(slidesCount / slidesPerPage);
|
||||
const currentPage = Math.floor(activeSlide / slidesPerPage);
|
||||
|
||||
return html`
|
||||
<nav part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
|
||||
${map(range(pagesCount), index => {
|
||||
const isActive = index === currentPage;
|
||||
return html`
|
||||
<button
|
||||
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
|
||||
class="${classMap({
|
||||
'carousel__pagination-item': true,
|
||||
'carousel__pagination-item--active': isActive
|
||||
})}"
|
||||
aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||
role="tab"
|
||||
@click="${() => this.goToSlide(index * slidesPerPage)}"
|
||||
></button>
|
||||
`;
|
||||
})}
|
||||
</nav>
|
||||
`;
|
||||
};
|
||||
|
||||
private renderNavigation = () => {
|
||||
const { loop, activeSlide } = this;
|
||||
const slides = this.getSlides();
|
||||
const slidesCount = slides.length;
|
||||
const prevEnabled = loop || activeSlide > 0;
|
||||
const nextEnabled = loop || activeSlide < slidesCount - 1;
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
return html`
|
||||
<nav part="navigation" class="carousel__navigation">
|
||||
<button
|
||||
@click="${prevEnabled ? () => this.previous() : null}"
|
||||
aria-disabled="${prevEnabled ? 'false' : 'true'}"
|
||||
aria-controls="scroll-container"
|
||||
class="${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--previous': true,
|
||||
'carousel__navigation-button--disabled': !prevEnabled
|
||||
})}"
|
||||
aria-label="${this.localize.term('previousSlide')}"
|
||||
part="navigation-button navigation-button--previous"
|
||||
>
|
||||
<slot name="previous-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="${nextEnabled ? () => this.next() : null}"
|
||||
aria-disabled="${nextEnabled ? 'false' : 'true'}"
|
||||
aria-controls="scroll-container"
|
||||
class="${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--next': true,
|
||||
'carousel__navigation-button--disabled': !nextEnabled
|
||||
})}"
|
||||
aria-label="${this.localize.term('nextSlide')}"
|
||||
part="navigation-button navigation-button--next"
|
||||
>
|
||||
<slot name="next-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
</nav>
|
||||
`;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { autoplayController, scrollController } = this;
|
||||
|
||||
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'
|
||||
})}"
|
||||
@scrollend="${this.handleScrollEnd}"
|
||||
role="list"
|
||||
tabindex="0"
|
||||
style="--slides-per-page: ${this.slidesPerPage};"
|
||||
aria-live="${!autoplayController.stopped && !autoplayController.paused ? 'off' : 'polite'}"
|
||||
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${when(this.navigation, this.renderNavigation)} ${when(this.pagination, this.renderPagination)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel': SlCarousel;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
|
|
@ -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
|
||||
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -257,12 +257,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. */
|
||||
|
@ -284,6 +281,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');
|
||||
|
|
|
@ -255,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();
|
||||
|
|
|
@ -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.
|
||||
|
@ -252,8 +253,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 +381,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 +413,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) {
|
||||
|
@ -441,13 +452,17 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
|||
}
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -629,6 +644,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();
|
||||
|
@ -749,7 +769,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
|||
?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();
|
||||
|
|
|
@ -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>`.
|
||||
|
|
|
@ -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';
|
|
@ -0,0 +1,9 @@
|
|||
type SlAfterCollapseEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-after-collapse': SlAfterCollapseEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlAfterCollapseEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlAfterExpandEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-after-expand': SlAfterExpandEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlAfterExpandEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlAfterHideEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-after-hide': SlAfterHideEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlAfterHideEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlAfterShowEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-after-show': SlAfterShowEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlAfterShowEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlBlurEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-blur': SlBlurEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlBlurEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlCancelEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-cancel': SlCancelEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlCancelEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlChangeEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-change': SlChangeEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlChangeEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlClearEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-clear': SlClearEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlClearEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlCloseEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-close': SlCloseEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlCloseEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlCollapseEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-collapse': SlCollapseEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlCollapseEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlErrorEvent = CustomEvent<{ status?: number }>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-error': SlErrorEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlErrorEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlExpandEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-expand': SlExpandEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlExpandEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlFinishEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-finish': SlFinishEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlFinishEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlFocusEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-focus': SlFocusEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlFocusEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlHideEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-hide': SlHideEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlHideEvent;
|
|
@ -0,0 +1,12 @@
|
|||
type SlHoverEvent = CustomEvent<{
|
||||
phase: 'start' | 'move' | 'end';
|
||||
value: number;
|
||||
}>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-hover': SlHoverEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlHoverEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlInitialFocusEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-initial-focus': SlInitialFocusEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlInitialFocusEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlInputEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-input': SlInputEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlInputEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlInvalidEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-invalid': SlInvalidEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlInvalidEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlLazyChangeEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-lazy-change': SlLazyChangeEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlLazyChangeEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlLazyLoadEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-lazy-load': SlLazyLoadEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlLazyLoadEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlLoadEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-load': SlLoadEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlLoadEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlMutationEvent = CustomEvent<{ mutationList: MutationRecord[] }>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-mutation': SlMutationEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlMutationEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlRemoveEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-remove': SlRemoveEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlRemoveEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlRepositionEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-reposition': SlRepositionEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlRepositionEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlRequestCloseEvent = CustomEvent<{ source: 'close-button' | 'keyboard' | 'overlay' }>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-request-close': SlRequestCloseEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlRequestCloseEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlResizeEvent = CustomEvent<{ entries: ResizeObserverEntry[] }>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-resize': SlResizeEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlResizeEvent;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,9 @@
|
|||
type SlShowEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-show': SlShowEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlShowEvent;
|
|
@ -0,0 +1,11 @@
|
|||
import type SlCarouselItem from '../components/carousel-item/carousel-item';
|
||||
|
||||
type SlSlideChange = CustomEvent<{ index: number; slide: SlCarouselItem }>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-slide-change': SlSlideChange;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlSlideChange;
|
|
@ -0,0 +1,9 @@
|
|||
type SlStartEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-start': SlStartEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlStartEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlTabHideEvent = CustomEvent<{ name: string }>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-tab-hide': SlTabHideEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlTabHideEvent;
|
|
@ -0,0 +1,9 @@
|
|||
type SlTabShowEvent = CustomEvent<{ name: string }>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-tab-show': SlTabShowEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlTabShowEvent;
|
|
@ -0,0 +1,30 @@
|
|||
// @debounce decorator
|
||||
//
|
||||
// Delays the execution until the provided delay in milliseconds has
|
||||
// passed since the last time the function has been called.
|
||||
//
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// @debounce(1000)
|
||||
// handleInput() {
|
||||
// ...
|
||||
// }
|
||||
//
|
||||
|
||||
// Each class instance will need to store its timer id, so this unique symbol will be used as property key.
|
||||
const TIMER_ID_KEY = Symbol();
|
||||
|
||||
export const debounce = (delay: number) => {
|
||||
return <T>(_target: T, _propertyKey: string, descriptor: PropertyDescriptor) => {
|
||||
const fn = descriptor.value as (this: T & { [TIMER_ID_KEY]: number }, ...args: unknown[]) => unknown;
|
||||
|
||||
descriptor.value = function (this: ThisParameterType<typeof fn>, ...args: Parameters<typeof fn>) {
|
||||
clearTimeout(this[TIMER_ID_KEY]);
|
||||
|
||||
this[TIMER_ID_KEY] = window.setTimeout(() => {
|
||||
fn.apply(this, args);
|
||||
}, delay);
|
||||
};
|
||||
};
|
||||
};
|
|
@ -357,10 +357,11 @@ export class FormControlController implements ReactiveController {
|
|||
* event will be cancelled before being dispatched.
|
||||
*/
|
||||
emitInvalidEvent(originalInvalidEvent?: Event) {
|
||||
const slInvalidEvent = new CustomEvent<void>('sl-invalid', {
|
||||
const slInvalidEvent = new CustomEvent<Record<PropertyKey, never>>('sl-invalid', {
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
cancelable: true
|
||||
cancelable: true,
|
||||
detail: {}
|
||||
});
|
||||
|
||||
if (!originalInvalidEvent) {
|
||||
|
|
|
@ -1,13 +1,85 @@
|
|||
import { LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
// Match event type name strings that are registered on GlobalEventHandlersEventMap...
|
||||
type EventTypeRequiresDetail<T> = T extends keyof GlobalEventHandlersEventMap
|
||||
? // ...where the event detail is an object...
|
||||
GlobalEventHandlersEventMap[T] extends CustomEvent<Record<PropertyKey, unknown>>
|
||||
? // ...that is non-empty...
|
||||
GlobalEventHandlersEventMap[T] extends CustomEvent<Record<PropertyKey, never>>
|
||||
? never
|
||||
: // ...and has at least one non-optional property
|
||||
Partial<GlobalEventHandlersEventMap[T]['detail']> extends GlobalEventHandlersEventMap[T]['detail']
|
||||
? never
|
||||
: T
|
||||
: never
|
||||
: never;
|
||||
|
||||
// The inverse of the above (match any type that doesn't match EventTypeRequiresDetail)
|
||||
type EventTypeDoesNotRequireDetail<T> = T extends keyof GlobalEventHandlersEventMap
|
||||
? GlobalEventHandlersEventMap[T] extends CustomEvent<Record<PropertyKey, unknown>>
|
||||
? GlobalEventHandlersEventMap[T] extends CustomEvent<Record<PropertyKey, never>>
|
||||
? T
|
||||
: Partial<GlobalEventHandlersEventMap[T]['detail']> extends GlobalEventHandlersEventMap[T]['detail']
|
||||
? T
|
||||
: never
|
||||
: T
|
||||
: T;
|
||||
|
||||
// `keyof EventTypesWithRequiredDetail` lists all registered event types that require detail
|
||||
type EventTypesWithRequiredDetail = {
|
||||
[EventType in keyof GlobalEventHandlersEventMap as EventTypeRequiresDetail<EventType>]: true;
|
||||
};
|
||||
|
||||
// `keyof EventTypesWithoutRequiredDetail` lists all registered event types that do NOT require detail
|
||||
type EventTypesWithoutRequiredDetail = {
|
||||
[EventType in keyof GlobalEventHandlersEventMap as EventTypeDoesNotRequireDetail<EventType>]: true;
|
||||
};
|
||||
|
||||
// Helper to make a specific property of an object non-optional
|
||||
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
||||
|
||||
// Given an event name string, get a valid type for the options to initialize the event that is more restrictive than
|
||||
// just CustomEventInit when appropriate (validate the type of the event detail, and require it to be provided if the
|
||||
// event requires it)
|
||||
type SlEventInit<T> = T extends keyof GlobalEventHandlersEventMap
|
||||
? GlobalEventHandlersEventMap[T] extends CustomEvent<Record<PropertyKey, unknown>>
|
||||
? GlobalEventHandlersEventMap[T] extends CustomEvent<Record<PropertyKey, never>>
|
||||
? CustomEventInit<GlobalEventHandlersEventMap[T]['detail']>
|
||||
: Partial<GlobalEventHandlersEventMap[T]['detail']> extends GlobalEventHandlersEventMap[T]['detail']
|
||||
? CustomEventInit<GlobalEventHandlersEventMap[T]['detail']>
|
||||
: WithRequired<CustomEventInit<GlobalEventHandlersEventMap[T]['detail']>, 'detail'>
|
||||
: CustomEventInit
|
||||
: CustomEventInit;
|
||||
|
||||
// Given an event name string, get the type of the event
|
||||
type GetCustomEventType<T> = T extends keyof GlobalEventHandlersEventMap
|
||||
? GlobalEventHandlersEventMap[T] extends CustomEvent<unknown>
|
||||
? GlobalEventHandlersEventMap[T]
|
||||
: CustomEvent<unknown>
|
||||
: CustomEvent<unknown>;
|
||||
|
||||
// `keyof ValidEventTypeMap` is equivalent to `keyof GlobalEventHandlersEventMap` but gives a nicer error message
|
||||
type ValidEventTypeMap = EventTypesWithRequiredDetail | EventTypesWithoutRequiredDetail;
|
||||
|
||||
export default class ShoelaceElement extends LitElement {
|
||||
// Make localization attributes reactive
|
||||
@property() dir: string;
|
||||
@property() lang: string;
|
||||
|
||||
/** Emits a custom event with more convenient defaults. */
|
||||
emit(name: string, options?: CustomEventInit) {
|
||||
emit<T extends string & keyof EventTypesWithoutRequiredDetail>(
|
||||
name: EventTypeDoesNotRequireDetail<T>,
|
||||
options?: SlEventInit<T> | undefined
|
||||
): GetCustomEventType<T>;
|
||||
emit<T extends string & keyof EventTypesWithRequiredDetail>(
|
||||
name: EventTypeRequiresDetail<T>,
|
||||
options: SlEventInit<T>
|
||||
): GetCustomEventType<T>;
|
||||
emit<T extends string & keyof ValidEventTypeMap>(
|
||||
name: T,
|
||||
options?: SlEventInit<T> | undefined
|
||||
): GetCustomEventType<T> {
|
||||
const event = new CustomEvent(name, {
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
|
@ -18,12 +90,12 @@ export default class ShoelaceElement extends LitElement {
|
|||
|
||||
this.dispatchEvent(event);
|
||||
|
||||
return event;
|
||||
return event as GetCustomEventType<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShoelaceFormControl extends ShoelaceElement {
|
||||
// Standard form attributes
|
||||
// Form attributes
|
||||
name: string;
|
||||
value: unknown;
|
||||
disabled?: boolean;
|
||||
|
@ -31,7 +103,7 @@ export interface ShoelaceFormControl extends ShoelaceElement {
|
|||
defaultChecked?: boolean;
|
||||
form?: string;
|
||||
|
||||
// Standard validation attributes
|
||||
// Constraint validation attributes
|
||||
pattern?: string;
|
||||
min?: number | Date;
|
||||
max?: number | Date;
|
||||
|
@ -40,13 +112,13 @@ export interface ShoelaceFormControl extends ShoelaceElement {
|
|||
minlength?: number;
|
||||
maxlength?: number;
|
||||
|
||||
// Validation properties
|
||||
// Form validation properties
|
||||
readonly validity: ValidityState;
|
||||
readonly validationMessage: string;
|
||||
|
||||
// Validation methods
|
||||
// Form validation methods
|
||||
checkValidity: () => boolean;
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
getForm: () => HTMLFormElement | null;
|
||||
reportValidity: () => boolean;
|
||||
setCustomValidity: (message: string) => void;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ export { default as SlBreadcrumbItem } from './components/breadcrumb-item/breadc
|
|||
export { default as SlButton } from './components/button/button';
|
||||
export { default as SlButtonGroup } from './components/button-group/button-group';
|
||||
export { default as SlCard } from './components/card/card';
|
||||
export { default as SlCarousel } from './components/carousel/carousel';
|
||||
export { default as SlCarouselItem } from './components/carousel-item/carousel-item';
|
||||
export { default as SlCheckbox } from './components/checkbox/checkbox';
|
||||
export { default as SlColorPicker } from './components/color-picker/color-picker';
|
||||
export { default as SlDetails } from './components/details/details';
|
||||
|
@ -60,3 +62,6 @@ export { default as SlOption } from './components/option/option';
|
|||
export * from './utilities/animation';
|
||||
export * from './utilities/base-path';
|
||||
export * from './utilities/icon-library';
|
||||
|
||||
// Events
|
||||
export * from './events/events';
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'Ryd indtastning',
|
||||
close: 'Luk',
|
||||
copy: 'Kopier',
|
||||
currentValue: 'Nuværende værdi',
|
||||
goToSlide: (slide, count) => `Gå til dias ${slide} af ${count}`,
|
||||
hidePassword: 'Skjul adgangskode',
|
||||
loading: 'Indlæser',
|
||||
nextSlide: 'Næste slide',
|
||||
numOptionsSelected: (num: number) => {
|
||||
if (num === 0) return 'Ingen valgt';
|
||||
if (num === 1) return '1 valgt';
|
||||
return `${num} valgt`;
|
||||
},
|
||||
currentValue: 'Nuværende værdi',
|
||||
hidePassword: 'Skjul adgangskode',
|
||||
loading: 'Indlæser',
|
||||
previousSlide: 'Forrige dias',
|
||||
progress: 'Status',
|
||||
remove: 'Fjern',
|
||||
resize: 'Tilpas størrelse',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'Eingabe löschen',
|
||||
close: 'Schließen',
|
||||
copy: 'Kopieren',
|
||||
currentValue: 'Aktueller Wert',
|
||||
goToSlide: (slide, count) => `Gehen Sie zu Folie ${slide} von ${count}`,
|
||||
hidePassword: 'Passwort verbergen',
|
||||
loading: 'Wird geladen',
|
||||
nextSlide: 'Nächste Folie',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'Keine Optionen ausgewählt';
|
||||
if (num === 1) return '1 Option ausgewählt';
|
||||
return `${num} Optionen ausgewählt`;
|
||||
},
|
||||
currentValue: 'Aktueller Wert',
|
||||
hidePassword: 'Passwort verbergen',
|
||||
loading: 'Wird geladen',
|
||||
previousSlide: 'Vorherige Folie',
|
||||
progress: 'Fortschritt',
|
||||
remove: 'Entfernen',
|
||||
resize: 'Größe ändern',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'Clear entry',
|
||||
close: 'Close',
|
||||
copy: 'Copy',
|
||||
currentValue: 'Current value',
|
||||
goToSlide: (slide, count) => `Go to slide ${slide} of ${count}`,
|
||||
hidePassword: 'Hide password',
|
||||
loading: 'Loading',
|
||||
nextSlide: 'Next slide',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'No options selected';
|
||||
if (num === 1) return '1 option selected';
|
||||
return `${num} options selected`;
|
||||
},
|
||||
currentValue: 'Current value',
|
||||
hidePassword: 'Hide password',
|
||||
loading: 'Loading',
|
||||
previousSlide: 'Previous slide',
|
||||
progress: 'Progress',
|
||||
remove: 'Remove',
|
||||
resize: 'Resize',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'Borrar entrada',
|
||||
close: 'Cerrar',
|
||||
copy: 'Copiar',
|
||||
currentValue: 'Valor actual',
|
||||
goToSlide: (slide, count) => `Ir a la diapositiva ${slide} de ${count}`,
|
||||
hidePassword: 'Ocultar contraseña',
|
||||
loading: 'Cargando',
|
||||
nextSlide: 'Siguiente diapositiva',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'No hay opciones seleccionadas';
|
||||
if (num === 1) return '1 opción seleccionada';
|
||||
return `${num} opción seleccionada`;
|
||||
},
|
||||
currentValue: 'Valor actual',
|
||||
hidePassword: 'Ocultar contraseña',
|
||||
loading: 'Cargando',
|
||||
previousSlide: 'Diapositiva anterior',
|
||||
progress: 'Progreso',
|
||||
remove: 'Eliminar',
|
||||
resize: 'Cambiar el tamaño',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'پاک کردن ورودی',
|
||||
close: 'بستن',
|
||||
copy: 'رونوشت',
|
||||
currentValue: 'مقدار فعلی',
|
||||
goToSlide: (slide, count) => `رفتن به اسلاید ${slide} از ${count}`,
|
||||
hidePassword: 'پنهان کردن رمز',
|
||||
loading: 'بارگذاری',
|
||||
nextSlide: 'اسلاید بعدی',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'هیچ گزینه ای انتخاب نشده است';
|
||||
if (num === 1) return '1 گزینه انتخاب شده است';
|
||||
return `${num} گزینه انتخاب شده است`;
|
||||
},
|
||||
currentValue: 'مقدار فعلی',
|
||||
hidePassword: 'پنهان کردن رمز',
|
||||
loading: 'بارگذاری',
|
||||
previousSlide: 'اسلاید قبلی',
|
||||
progress: 'پیشرفت',
|
||||
remove: 'حذف',
|
||||
resize: 'تغییر اندازه',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: `Effacer l'entrée`,
|
||||
close: 'Fermer',
|
||||
copy: 'Copier',
|
||||
currentValue: 'Valeur actuelle',
|
||||
goToSlide: (slide, count) => `Aller à la diapositive ${slide} de ${count}`,
|
||||
hidePassword: 'Masquer le mot de passe',
|
||||
loading: 'Chargement',
|
||||
nextSlide: 'Diapositive suivante',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'Aucune option sélectionnée';
|
||||
if (num === 1) return '1 option sélectionnée';
|
||||
return `${num} options sélectionnées`;
|
||||
},
|
||||
currentValue: 'Valeur actuelle',
|
||||
hidePassword: 'Masquer le mot de passe',
|
||||
loading: 'Chargement',
|
||||
previousSlide: 'Diapositive précédente',
|
||||
progress: 'Progrès',
|
||||
remove: 'Retirer',
|
||||
resize: 'Redimensionner',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'נקה קלט',
|
||||
close: 'סגור',
|
||||
copy: 'העתק',
|
||||
currentValue: 'ערך נוכחי',
|
||||
goToSlide: (slide, count) => `עבור לשקופית ${slide} של ${count}`,
|
||||
hidePassword: 'הסתר סיסמא',
|
||||
loading: 'טוען',
|
||||
nextSlide: 'Next slide',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'לא נבחרו אפשרויות';
|
||||
if (num === 1) return 'נבחרה אפשרות אחת';
|
||||
return `נבחרו ${num} אפשרויות`;
|
||||
},
|
||||
currentValue: 'ערך נוכחי',
|
||||
hidePassword: 'הסתר סיסמא',
|
||||
loading: 'טוען',
|
||||
previousSlide: 'Previous slide',
|
||||
progress: 'התקדמות',
|
||||
remove: 'לְהַסִיר',
|
||||
resize: 'שנה גודל',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'Bejegyzés törlése',
|
||||
close: 'Bezárás',
|
||||
copy: 'Másolás',
|
||||
currentValue: 'Aktuális érték',
|
||||
goToSlide: (slide, count) => `Ugrás a ${count}/${slide}. diára`,
|
||||
hidePassword: 'Jelszó elrejtése',
|
||||
loading: 'Betöltés',
|
||||
nextSlide: 'Következő dia',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'Nincsenek kiválasztva opciók';
|
||||
if (num === 1) return '1 lehetőség kiválasztva';
|
||||
return `${num} lehetőség kiválasztva`;
|
||||
},
|
||||
currentValue: 'Aktuális érték',
|
||||
hidePassword: 'Jelszó elrejtése',
|
||||
loading: 'Betöltés',
|
||||
previousSlide: 'Előző dia',
|
||||
progress: 'Folyamat',
|
||||
remove: 'Eltávolítás',
|
||||
resize: 'Átméretezés',
|
||||
|
|
|
@ -9,14 +9,17 @@ const translation: Translation = {
|
|||
clearEntry: 'クリアエントリ',
|
||||
close: '閉じる',
|
||||
copy: 'コピー',
|
||||
currentValue: '現在の価値',
|
||||
goToSlide: (slide, count) => `${count} 枚中 ${slide} 枚のスライドに移動`,
|
||||
hidePassword: 'パスワードを隠す',
|
||||
loading: '読み込み中',
|
||||
nextSlide: '次のスライド',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'オプションが選択されていません';
|
||||
if (num === 1) return '1 つのオプションが選択されました';
|
||||
return `${num} つのオプションが選択されました`;
|
||||
},
|
||||
currentValue: '現在の価値',
|
||||
hidePassword: 'パスワードを隠す',
|
||||
loading: '読み込み中',
|
||||
previousSlide: '前のスライド',
|
||||
progress: '進行',
|
||||
remove: '削除',
|
||||
resize: 'サイズ変更',
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue