Merge branch 'next' into autoload

autoload
Cory LaViska 2023-02-28 12:45:23 -05:00
commit 2371c5490f
108 zmienionych plików z 3641 dodań i 199 usunięć

1
.gitignore vendored
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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');

Wyświetl plik

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

55
package-lock.json wygenerowano
Wyświetl plik

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

Wyświetl plik

@ -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"
},

Wyświetl plik

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

Wyświetl plik

@ -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()) {

Wyświetl plik

@ -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;
}
`;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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();

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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');

Wyświetl plik

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

Wyświetl plik

@ -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'));

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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');

Wyświetl plik

@ -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();

Wyświetl plik

@ -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 () => {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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();

Wyświetl plik

@ -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');

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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();

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,9 @@
type SlTabHideEvent = CustomEvent<{ name: string }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-tab-hide': SlTabHideEvent;
}
}
export default SlTabHideEvent;

Wyświetl plik

@ -0,0 +1,9 @@
type SlTabShowEvent = CustomEvent<{ name: string }>;
declare global {
interface GlobalEventHandlersEventMap {
'sl-tab-show': SlTabShowEvent;
}
}
export default SlTabShowEvent;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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: 'تغییر اندازه',

Wyświetl plik

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

Wyświetl plik

@ -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: 'שנה גודל',

Wyświetl plik

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

Wyświetl plik

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