diff --git a/.gitignore b/.gitignore index addd1e5f80..7d3fb38b93 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ docs/dist docs/search.json dist -examples node_modules src/react .vscode/settings.json diff --git a/cspell.json b/cspell.json index 5a76ee66d3..e511c3dd4d 100644 --- a/cspell.json +++ b/cspell.json @@ -9,9 +9,13 @@ "atrule", "autocorrect", "autofix", + "autoload", + "autoloader", + "autoloading", "autoplay", "bezier", "boxicons", + "CACHEABLE", "callout", "callouts", "chatbubble", @@ -27,6 +31,7 @@ "Consolas", "contenteditable", "copydir", + "Cotte", "coverpage", "crossorigin", "crutchcorn", @@ -35,6 +40,7 @@ "datetime", "describedby", "Docsify", + "dogfood", "dropdowns", "easings", "enterkeyhint", @@ -53,6 +59,7 @@ "FOUC", "FOUCE", "fullscreen", + "gestern", "giga", "globby", "Grayscale", @@ -70,10 +77,12 @@ "jsonata", "keydown", "keyframes", + "Kool", "labelledby", "Laravel", "LaViska", "listbox", + "listitem", "litelement", "lowercasing", "Lucide", @@ -103,12 +112,18 @@ "reregister", "resizer", "resizers", + "retargeted", + "RETRYABLE", "rgba", "roadmap", "Roboto", + "roledescription", + "Sapan", "saturationl", "Schilp", "scrollbars", + "scrollend", + "scroller", "Segoe", "semibold", "slotchange", @@ -121,6 +136,7 @@ "tabpanel", "templating", "tera", + "testid", "textareas", "textfield", "tinycolor", @@ -143,6 +159,7 @@ "ignorePaths": [ "package.json", "package-lock.json", + "docs/assets/examples/include.html", ".vscode/**", "src/translations/!(en).ts", "**/*.min.js" diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 5152602667..0ead4016c3 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,6 +1,8 @@ - Getting Started - [Overview](/) + - [Installation](/getting-started/installation) + - [Usage](/getting-started/usage) - [Contributing](/teamshares/contributing) - [Recipe Book](/teamshares/recipes) - [Changelog](/teamshares/changelog) @@ -25,6 +27,8 @@ - [Button](/components/button) - [Button Group](/components/button-group) - [Card](/components/card) + - [Carousel](/components/carousel) + - [Carousel Item](/components/carousel-item) - [Checkbox](/components/checkbox) - [Details](/components/details) diff --git a/docs/assets/examples/carousel/field.jpg b/docs/assets/examples/carousel/field.jpg new file mode 100644 index 0000000000..a742b19688 Binary files /dev/null and b/docs/assets/examples/carousel/field.jpg differ diff --git a/docs/assets/examples/carousel/mountains.jpg b/docs/assets/examples/carousel/mountains.jpg new file mode 100644 index 0000000000..17a7ea3cc1 Binary files /dev/null and b/docs/assets/examples/carousel/mountains.jpg differ diff --git a/docs/assets/examples/carousel/sunset.jpg b/docs/assets/examples/carousel/sunset.jpg new file mode 100644 index 0000000000..811b0df2bb Binary files /dev/null and b/docs/assets/examples/carousel/sunset.jpg differ diff --git a/docs/assets/examples/carousel/valley.jpg b/docs/assets/examples/carousel/valley.jpg new file mode 100644 index 0000000000..bd575fbf09 Binary files /dev/null and b/docs/assets/examples/carousel/valley.jpg differ diff --git a/docs/assets/examples/carousel/waterfall.jpg b/docs/assets/examples/carousel/waterfall.jpg new file mode 100644 index 0000000000..b8fa264cf4 Binary files /dev/null and b/docs/assets/examples/carousel/waterfall.jpg differ diff --git a/docs/assets/plugins/metadata/metadata.js b/docs/assets/plugins/metadata/metadata.js index 5b089f4e0a..e03959cd15 100644 --- a/docs/assets/plugins/metadata/metadata.js +++ b/docs/assets/plugins/metadata/metadata.js @@ -332,8 +332,9 @@ version.classList.add('sidebar-version'); version.textContent = `Version ${metadata.package.version} `; const upstreamLink = document.createElement('a'); + upstreamLink.className = 'sidebar-version-link'; upstreamLink.href = `https://shoelace.style/resources/changelog?id=_${metadata.package.upstreamVersion}`; - upstreamLink.textContent = `(Shoelace upstream: ${metadata.package.upstreamVersion})`; + upstreamLink.textContent = `(Shoelace: ${metadata.package.upstreamVersion})`; version.appendChild(upstreamLink); target.appendChild(version); @@ -440,6 +441,9 @@ result += ` ## Importing + If you're using the autoloader or the traditional loader, you can ignore this section. Otherwise, feel free to + use any of the following snippets to [cherry pick](getting-started/installation#cherry-picking) this component. + Script Import diff --git a/docs/assets/styles/docs.css b/docs/assets/styles/docs.css index 49b19dea22..fa1207d043 100644 --- a/docs/assets/styles/docs.css +++ b/docs/assets/styles/docs.css @@ -8,6 +8,17 @@ html { box-sizing: inherit; } +/* Show custom elements only after they're registered */ +:not(:defined), +:not(:defined) * { + opacity: 0; +} + +:defined { + opacity: 1; + transition: 0.1s opacity; +} + body { font-family: var(--sl-font-sans); font-size: var(--sl-font-size-medium); @@ -32,95 +43,280 @@ strong { /* Teamshares typography styles, added for examples on Typography styles page */ .ts-heading-1 { - font-size: var(--ts-font-7xl); /* 72px */ - font-weight: var(--ts-font-bold); /* 700 */ - line-height: var(--ts-leading-none); /* 1 */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-7xl); + /* 72px */ + font-weight: var(--ts-font-bold); + /* 700 */ + line-height: var(--ts-leading-none); + /* 1 */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-heading-2 { - font-size: var(--ts-font-6xl); /* 60px */ - font-weight: var(--ts-font-bold); /* 700 */ - line-height: var(--ts-leading-none); /* 1 */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-6xl); + /* 60px */ + font-weight: var(--ts-font-bold); + /* 700 */ + line-height: var(--ts-leading-none); + /* 1 */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-heading-3 { - font-size: var(--ts-font-5xl); /* 48px */ - font-weight: var(--ts-font-bold); /* 700 */ - line-height: var(--ts-leading-none); /* 1 */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-5xl); + /* 48px */ + font-weight: var(--ts-font-bold); + /* 700 */ + line-height: var(--ts-leading-none); + /* 1 */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-heading-4 { - font-size: var(--ts-font-4xl); /* 36px */ - font-weight: var(--ts-font-bold); /* 700 */ - line-height: var(--ts-leading-tight); /* 1.25 */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-4xl); + /* 36px */ + font-weight: var(--ts-font-bold); + /* 700 */ + line-height: var(--ts-leading-tight); + /* 1.25 */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-heading-5 { - font-size: var(--ts-font-2xl); /* 24px */ - font-weight: var(--ts-font-bold); /* 700 */ - line-height: var(--ts-leading-7); /* 1.75rem * 28px */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-2xl); + /* 24px */ + font-weight: var(--ts-font-bold); + /* 700 */ + line-height: var(--ts-leading-7); + /* 1.75rem * 28px */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-heading-6 { - font-size: var(--ts-font-xl); /* 20px */ - font-weight: var(--ts-font-medium); /* 500 */ - line-height: var(--ts-leading-6); /* 1.5rem * 24px */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-xl); + /* 20px */ + font-weight: var(--ts-font-medium); + /* 500 */ + line-height: var(--ts-leading-6); + /* 1.5rem * 24px */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-heading-7 { - font-size: var(--ts-font-base); /* 16px */ - font-weight: var(--ts-font-semibold); /* 600 */ - line-height: var(--ts-leading-5); /* 1.25rem * 20px */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-base); + /* 16px */ + font-weight: var(--ts-font-semibold); + /* 600 */ + line-height: var(--ts-leading-5); + /* 1.25rem * 20px */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-heading-8 { - font-size: var(--ts-font-sm); /* 14px */ - font-weight: var(--ts-font-semibold); /* 600 */ - line-height: var(--ts-leading-5); /* 1.25rem * 20px */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-sm); + /* 14px */ + font-weight: var(--ts-font-semibold); + /* 600 */ + line-height: var(--ts-leading-5); + /* 1.25rem * 20px */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .ts-subheading { - font-size: var(--ts-font-xs); /* 12px */ - font-weight: var(--ts-font-semibold); /* 600 */ - line-height: var(--ts-leading-4); /* 1rem * 16px */ - letter-spacing: var(--ts-tracking-normal); /* normal */ + font-size: var(--ts-font-xs); + /* 12px */ + font-weight: var(--ts-font-semibold); + /* 600 */ + line-height: var(--ts-leading-4); + /* 1rem * 16px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ text-transform: uppercase; } .ts-body-large { - font-size: var(--ts-font-xl); /* 20px */ - font-weight: var(--ts-font-normal); /* 400 */ - line-height: var(--ts-leading-7); /* 1.75rem * 28px */ - letter-spacing: var(--ts-tracking-normal); /* normal */ + font-size: var(--ts-font-xl); + /* 20px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-7); + /* 1.75rem * 28px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ } .ts-body-1 { - font-size: var(--ts-font-base); /* 16px */ - font-weight: var(--ts-font-normal); /* 400 */ - line-height: var(--ts-leading-6); /* 1.5rem * 24px */ - letter-spacing: var(--ts-tracking-normal); /* normal */ + font-size: var(--ts-font-base); + /* 16px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-6); + /* 1.5rem * 24px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ } .ts-body-2 { - font-size: var(--ts-font-sm); /* 14px */ - font-weight: var(--ts-font-normal); /* 400 */ - line-height: var(--ts-leading-5); /* 1.25rem * 20px */ - letter-spacing: var(--ts-tracking-normal); /* normal */ + font-size: var(--ts-font-sm); + /* 14px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-5); + /* 1.25rem * 20px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ } .ts-body-3 { - font-size: var(--ts-font-xs); /* 12px */ - font-weight: var(--ts-font-normal); /* 400 */ - line-height: var(--ts-leading-4); /* 1rem * 16px */ - letter-spacing: var(--ts-tracking-normal); /* normal */ + font-size: var(--ts-font-xs); + /* 12px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-4); + /* 1rem * 16px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ +} + +/* Teamshares text color styles, added for examples on Color styles page */ +.ts-text-default { + color: var(--ts-color-text-default); +} + +.ts-text-subdued { + color: var(--ts-color-text-subdued); +} + +.ts-text-light { + color: var(--ts-color-text-light); +} + +.ts-text-light-subdued { + color: var(--ts-color-text-light-subdued); +} + +.ts-text-error { + color: var(--ts-color-text-danger); +} + +.ts-text-error-light { + color: var(--ts-color-text-danger-light); +} + +.ts-text-success { + color: var(--ts-color-text-success); +} + +.ts-text-success-light { + color: var(--ts-color-text-success-light); +} + +.ts-text-link { + color: var(--ts-color-link); + text-decoration: none; + border-bottom: 1px solid var(--ts-color-link); + transition: 150ms ease-in-out; +} + +.ts-text-link:hover { + color: var(--ts-color-link); + text-decoration: none; + border-bottom: 2px solid var(--ts-color-link); +} + +.ts-text-link-light { + color: var(--ts-color-link-light); + text-decoration: none; + border-bottom: 1px solid var(--ts-color-link-light); + transition: 150ms ease-in-out; +} + +.ts-text-link-light:hover { + color: var(--ts-color-link-light); + text-decoration: none; + border-bottom: 2px solid var(--ts-color-link-light); +} + +/* Dark background example */ +.dark-background { + background-color: var(--ts-color-blue-900); + padding: 1rem; + margin: 1rem 0; +} + +.ts-heading-8 { + font-size: var(--ts-font-sm); + /* 14px */ + font-weight: var(--ts-font-semibold); + /* 600 */ + line-height: var(--ts-leading-5); + /* 1.25rem * 20px */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ +} + +.ts-subheading { + font-size: var(--ts-font-xs); + /* 12px */ + font-weight: var(--ts-font-semibold); + /* 600 */ + line-height: var(--ts-leading-4); + /* 1rem * 16px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ + text-transform: uppercase; +} + +.ts-body-large { + font-size: var(--ts-font-xl); + /* 20px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-7); + /* 1.75rem * 28px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ +} + +.ts-body-1 { + font-size: var(--ts-font-base); + /* 16px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-6); + /* 1.5rem * 24px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ +} + +.ts-body-2 { + font-size: var(--ts-font-sm); + /* 14px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-5); + /* 1.25rem * 20px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ +} + +.ts-body-3 { + font-size: var(--ts-font-xs); + /* 12px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-4); + /* 1rem * 16px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ } /* Teamshares text color styles, added for examples on Color styles page */ @@ -208,6 +404,10 @@ strong { margin: -1rem 0 0.6rem 0; } +.sidebar .sidebar-version-link { + color: var(--sl-color-primary-700); +} + .sidebar-buttons { display: flex; justify-content: space-between; @@ -486,10 +686,14 @@ sl-alert.do-dont::part(message) { padding-left: 0; /* ts-body-large */ - font-size: var(--ts-font-xl); /* 20px */ - font-weight: var(--ts-font-normal); /* 400 */ - line-height: var(--ts-leading-7); /* 1.75rem * 28px */ - letter-spacing: var(--ts-tracking-normal); /* normal */ + font-size: var(--ts-font-xl); + /* 20px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-7); + /* 1.75rem * 28px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ } .markdown-section blockquote { @@ -543,10 +747,14 @@ sl-alert.do-dont::part(message) { .markdown-section h1 { /* ts-heading-4 */ - font-size: var(--ts-font-4xl); /* 36px */ - font-weight: var(--ts-font-bold); /* 700 */ - line-height: var(--ts-leading-tight); /* 1.25 */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-4xl); + /* 36px */ + font-weight: var(--ts-font-bold); + /* 700 */ + line-height: var(--ts-leading-tight); + /* 1.25 */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ } .markdown-section h2 { @@ -915,19 +1123,27 @@ body[data-page^='/tokens/'] .table-wrapper td:first-child code { div.panel-content h2 { border: none; /* ts-heading-6 */ - font-size: var(--ts-font-xl); /* 20px */ - font-weight: var(--ts-font-medium); /* 500 */ - line-height: var(--ts-leading-6); /* 1.5rem * 24px */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-xl); + /* 20px */ + font-weight: var(--ts-font-medium); + /* 500 */ + line-height: var(--ts-leading-6); + /* 1.5rem * 24px */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ margin: 2rem 0 0.5rem; } div.panel-content h3 { /* ts-heading-7 */ - font-size: var(--ts-font-base); /* 16px */ - font-weight: var(--ts-font-semibold); /* 600 */ - line-height: var(--ts-leading-5); /* 1.25rem * 20px */ - letter-spacing: var(--ts-tracking-tight); /* -0.025em */ + font-size: var(--ts-font-base); + /* 16px */ + font-weight: var(--ts-font-semibold); + /* 600 */ + line-height: var(--ts-leading-5); + /* 1.25rem * 20px */ + letter-spacing: var(--ts-tracking-tight); + /* -0.025em */ margin: 2rem 0 0.5rem; } @@ -938,26 +1154,33 @@ div.panel-content h3 { .color-palette__name { /* ts-body-1 */ - font-size: var(--ts-font-base); /* 16px */ - font-weight: var(--ts-font-normal); /* 400 */ - line-height: var(--ts-leading-6); /* 1.5rem * 24px */ - letter-spacing: var(--ts-tracking-normal); /* normal */ + font-size: var(--ts-font-base); + /* 16px */ + font-weight: var(--ts-font-normal); + /* 400 */ + line-height: var(--ts-leading-6); + /* 1.5rem * 24px */ + letter-spacing: var(--ts-tracking-normal); + /* normal */ } .color-tokens code { display: inline-block; - font-size: var(--ts-font-xs); /* 12px */ + font-size: var(--ts-font-xs); + /* 12px */ font-weight: var(--ts-font-normal); } .color-palette__example span:first-of-type { - font-size: var(--ts-font-base); /* 16px */ + font-size: var(--ts-font-base); + /* 16px */ font-weight: var(--ts-font-semibold); } .color-palette__example span { display: block; - font-size: var(--ts-font-xs); /* 12px */ + font-size: var(--ts-font-xs); + /* 12px */ font-weight: var(--ts-font-medium); } diff --git a/docs/components/carousel-item.md b/docs/components/carousel-item.md new file mode 100644 index 0000000000..795453de02 --- /dev/null +++ b/docs/components/carousel-item.md @@ -0,0 +1,95 @@ +# Carousel Item + +[component-header:sl-carousel-item] + +```html preview + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + +``` + +```pug slim +sl-carousel pagination="true" + 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 + 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 + 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 + 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 + img alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash" src="/assets/examples/carousel/valley.jpg" +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + +); +``` + +?> Additional demonstrations can be found in the [carousel examples](/components/carousel). + +[component-metadata:sl-carousel-item] diff --git a/docs/components/carousel.md b/docs/components/carousel.md new file mode 100644 index 0000000000..351e33e8e9 --- /dev/null +++ b/docs/components/carousel.md @@ -0,0 +1,1542 @@ +# Carousel + +[component-header:sl-carousel] + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +``` + +```pug slim +sl-carousel pagination="true" navigation="true" mouse-dragging="true" loop="true" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + <> + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + +); +``` + +## Examples + +### Pagination + +Use the `pagination` attribute to show the total number of slides and the current slide as a set of interactive dots. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +``` + +```pug slim +sl-carousel pagination="true" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +); +``` + +### Navigation + +Use the `navigation` attribute to show previous and next buttons. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +``` + +```pug slim +sl-carousel navigation="true" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +); +``` + +### Looping + +By default, the carousel will not advanced beyond the first and last slides. You can change this behavior and force the carousel to "wrap" with the `loop` attribute. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +``` + +```pug slim +sl-carousel loop="true" navigation="true" pagination="true" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +); +``` + +### Autoplay + +The carousel will automatically advance when the `autoplay` attribute is used. To change how long a slide is shown before advancing, set `autoplay-interval` to the desired number of milliseconds. For best results, use the `loop` attribute when autoplay is enabled. Note that autoplay will pause while the user interacts with the carousel. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +``` + +```pug slim +sl-carousel autoplay="true" loop="true" pagination="true" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +); +``` + +### Mouse Dragging + +The carousel uses [scroll snap](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Scroll_Snap) to position slides at various snap positions. This allows users to scroll through the slides very naturally, especially on touch devices. Unfortunately, desktop users won't be able to click and drag with a mouse, which can feel unnatural. Adding the `mouse-dragging` attribute can help with this. + +This example is best demonstrated using a mouse. Try clicking and dragging the slide to move it. Then toggle the switch and try again. + +```html preview +
+ + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + + + + Enable mouse dragging +
+ + +``` + +```pug slim +div class="mouse-dragging" + sl-carousel pagination="true" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" + sl-divider + sl-switch Enable mouse dragging + +javascript + const container = document.querySelector(.mouse-dragging); + const carousel = container.querySelector(sl-carousel); + const toggle = container.querySelector(sl-switch); + + toggle.addEventListener(sl-change, () => { + carousel.toggleAttribute(mouse-dragging, toggle.checked); + }); +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlSwitch } from '@teamshares/shoelace/dist/react'; + +const App = () => { + const [isEnabled, setIsEnabled] = useState(false); + + return ( + <> + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + + + + setIsEnabled(!isEnabled)}> + Enable mouse dragging + + + ); +}; +``` + +### Multiple Slides Per View + +The `slides-per-view` attribute makes it possible to display multiple slides at a time. You can also use the `slides-per-move` attribute to advance more than once slide at a time, if desired. + +```html preview + + Slide 1 + Slide 2 + Slide 3 + Slide 4 + Slide 5 + Slide 6 + +``` + +```pug slim +sl-carousel navigation="true" pagination="true" slides-per-page="2" slides-per-move="2" + sl-carousel-item style="background: var(--sl-color-red-200);" Slide 1 + sl-carousel-item style="background: var(--sl-color-orange-200);" Slide 2 + sl-carousel-item style="background: var(--sl-color-yellow-200);" Slide 3 + sl-carousel-item style="background: var(--sl-color-green-200);" Slide 4 + sl-carousel-item style="background: var(--sl-color-blue-200);" Slide 5 + sl-carousel-item style="background: var(--sl-color-violet-200);" Slide 6 +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + + Slide 1 + Slide 2 + Slide 3 + Slide 4 + Slide 5 + Slide 6 + +); +``` + +### Adding and Removing Slides + +The content of the carousel can be changed by adding or removing carousel items. The carousel will update itself automatically. + +```html preview + + Slide 1 + Slide 2 + Slide 3 + + + + + + + +``` + +```pug slim +sl-carousel class="dynamic-carousel" pagination="true" navigation="true" + sl-carousel-item style="background: var(--sl-color-red-200)" Slide 1 + sl-carousel-item style="background: var(--sl-color-orange-200)" Slide 2 + sl-carousel-item style="background: var(--sl-color-yellow-200)" Slide 3 +div class="carousel-options" + sl-button#dynamic-add Add slide + sl-button#dynamic-remove Remove slide + +css + .dynamic-carousel { + --aspect-ratio: 3 / 2; + } + .dynamic-carousel ~ .carousel-options { + display: flex; + justify-content: center; + gap: var(--sl-spacing-x-small); + margin-top: var(--sl-spacing-large); + } + .dynamic-carousel sl-carousel-item { + flex: 0 0 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--sl-font-size-2x-large); + } + +javascript + (() => { + const dynamicCarousel = document.querySelector(.dynamic-carousel); + const dynamicAdd = document.querySelector(#dynamic-add); + const dynamicRemove = document.querySelector(#dynamic-remove); + const colors = [red, orange, yellow, green, blue, violet]; + let colorIndex = 2; + + const addSlide = () => { + const slide = document.createElement(sl-carousel-item); + const color = colors[++colorIndex % colors.length]; + slide.innerText = `Slide ${dynamicCarousel.children.length + 1}`; + slide.style.setProperty(background, `var(--sl-color-${color}-200)`); + dynamicCarousel.appendChild(slide); + dynamicRemove.disabled = false; + }; + + const removeSlide = () => { + const slide = dynamicCarousel.children[dynamicCarousel.children.length - 1]; + const numSlides = dynamicCarousel.querySelectorAll(sl-carousel-item).length; + if (numSlides > 1) { + slide.remove(); + colorIndex--; + } + dynamicRemove.disabled = numSlides - 1 <= 1; + }; + + dynamicAdd.addEventListener(click, addSlide); + dynamicRemove.addEventListener(click, removeSlide); + })(); +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const css = ` + .dynamic-carousel { + --aspect-ratio: 3 / 2; + } + + .dynamic-carousel ~ .carousel-options { + display: flex; + justify-content: center; + margin-top: var(--sl-spacing-large); + } + + .dynamic-carousel sl-carousel-item { + flex: 0 0 100%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: var(--sl-font-size-2x-large); + } +`; + +const App = () => { + const [slides, setSlides] = useState(['#204ed8', '#be133d', '#6e28d9']); + const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'violet']; + + const addSlide = () => { + setSlides([...slides, getRandomColor()]); + }; + + const removeSlide = () => { + setSlides(slides.slice(0, -1)); + }; + + return ( + <> + + {slides.map((color, i) => ( + + Slide {i} + + ))} + + +
+ Add slide + Remove slide +
+ + + + ); +}; +``` + +### Vertical Scrolling + +Setting the `orientation` attribute to `vertical` will render the carousel in a vertical layout. If the content of your slides vary in height, you will need to set amn explicit `height` or `max-height` on the carousel using CSS. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + +``` + +```pug slim +sl-carousel class="vertical" pagination="true" orientation="vertical" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" + +css + .vertical { + max-height: 400px; + } + .vertical::part(base) { + grid-template-areas: slides slides pagination; + } + .vertical::part(pagination) { + flex-direction: column; + } + .vertical::part(navigation) { + transform: rotate(90deg); + display: flex; + } +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@teamshares/shoelace/dist/react'; + +const css = ` + .vertical { + max-height: 400px; + } + + .vertical::part(base) { + grid-template-areas: 'slides slides pagination'; + } + + .vertical::part(pagination) { + flex-direction: column; + } + + .vertical::part(navigation) { + transform: rotate(90deg); + display: flex; + } +`; + +const App = () => ( + <> + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + + +); +``` + +### Aspect Ratio + +Use the `--aspect-ratio` custom property to customize the size of the carousel's viewport. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + + + + + 1/1 + 3/2 + 16/9 + + + +``` + +```pug slim +sl-carousel class="aspect-ratio" navigation="true" pagination="true" style="--aspect-ratio: 3/2;" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +sl-divider +sl-select label="Aspect ratio" name="aspect" value="3/2" + sl-option value="1/1" 1/1 + sl-option value="3/2" 3/2 + sl-option value="16/9" 16/9 + +javascript + (() => { + const carousel = document.querySelector(sl-carousel.aspect-ratio); + const aspectRatio = document.querySelector(sl-select[name=aspect]); + + aspectRatio.addEventListener(sl-change, () => { + carousel.style.setProperty(--aspect-ratio, aspectRatio.value); + }); + })(); +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlSelect, SlOption } from '@teamshares/shoelace/dist/react'; + +const App = () => { + const [aspectRatio, setAspectRatio] = useState('3/2'); + + return ( + <> + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + + + + setAspectRatio(event.target.value)} + > + 1 / 1 + 3 / 2 + 16 / 9 + + + + + ); +}; +``` + +### Scroll Hint + +Use the `--scroll-hint` custom property to add inline padding in horizontal carousels and block padding in vertical carousels. This will make the closest slides slightly visible, hinting that there are more items in the carousel. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + +``` + +```pug slim +sl-carousel class="scroll-hint" pagination="true" style="--scroll-hint: 10%;" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlRange } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + <> + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + +); +``` + +### Gallery Example + +The carousel has a robust API that makes it possible to extend and customize. This example syncs the active slide with a set of thumbnails, effectively creating a gallery-style carousel. + +```html preview + + + The sun shines on the mountains and trees (by Adam Kool on Unsplash) + + + A waterfall in the middle of a forest (by Thomas Kelly on Unsplash) + + + The sun is setting over a lavender field (by Leonard Cotte on Unsplash) + + + A field of grass with the sun setting in the background (by Sapan Patel on Unsplash) + + + A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash) + + + +
+
+ Thumbnail by 1 + Thumbnail by 2 + Thumbnail by 3 + Thumbnail by 4 + Thumbnail by 5 +
+
+ + + + +``` + +```pug slim +sl-carousel class="carousel-thumbnails" navigation="true" loop="true" + sl-carousel-item + img alt="The sun shines on the mountains and trees (by Adam Kool on Unsplash)" src="/assets/examples/carousel/mountains.jpg" + sl-carousel-item + img alt="A waterfall in the middle of a forest (by Thomas Kelly on Unsplash)" src="/assets/examples/carousel/waterfall.jpg" + sl-carousel-item + img alt="The sun is setting over a lavender field (by Leonard Cotte on Unsplash)" src="/assets/examples/carousel/sunset.jpg" + sl-carousel-item + img alt="A field of grass with the sun setting in the background (by Sapan Patel on Unsplash)" src="/assets/examples/carousel/field.jpg" + sl-carousel-item + img alt="A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash)" src="/assets/examples/carousel/valley.jpg" +div class="thumbnails" + div class="thumbnails__scroller" + img alt="Thumbnail by 1" class="thumbnails__image active" src="/assets/examples/carousel/mountains.jpg" + img alt="Thumbnail by 2" class="thumbnails__image" src="/assets/examples/carousel/waterfall.jpg" + img alt="Thumbnail by 3" class="thumbnails__image" src="/assets/examples/carousel/sunset.jpg" + img alt="Thumbnail by 4" class="thumbnails__image" src="/assets/examples/carousel/field.jpg" + img alt="Thumbnail by 5" class="thumbnails__image" src="/assets/examples/carousel/valley.jpg" + +css + .carousel-thumbnails { + --slide-aspect-ratio: 3 / 2; + } + .thumbnails { + display: flex; + justify-content: center; + } + .thumbnails__scroller { + display: flex; + gap: var(--sl-spacing-small); + overflow-x: auto; + scrollbar-width: none; + scroll-behavior: smooth; + scroll-padding: var(--sl-spacing-small); + } + .thumbnails__scroller::-webkit-scrollbar { + display: none; + } + .thumbnails__image { + width: 64px; + height: 64px; + object-fit: cover; + + opacity: 0.3; + will-change: opacity; + transition: 250ms opacity; + + cursor: pointer; + } + .thumbnails__image.active { + opacity: 1; + } + +javascript + (() => { + const carousel = document.querySelector(.carousel-thumbnails); + const scroller = document.querySelector(.thumbnails__scroller); + const thumbnails = document.querySelectorAll(.thumbnails__image); + + scroller.addEventListener(click, e => { + const target = e.target; + + if (target.matches(.thumbnails__image)) { + const index = [...thumbnails].indexOf(target); + carousel.goToSlide(index); + } + }); + + carousel.addEventListener(sl-slide-change, e => { + const slideIndex = e.detail.index; + + [...thumbnails].forEach((thumb, i) => { + thumb.classList.toggle(active, i === slideIndex); + if (i === slideIndex) { + thumb.scrollIntoView({ + block: nearest + }); + } + }); + }); + })(); +``` + +```jsx react +import { useRef } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlRange } from '@teamshares/shoelace/dist/react'; + +const css = ` + .carousel-thumbnails { + --slide-aspect-ratio: 3 / 2; + } + + .thumbnails { + display: flex; + justify-content: center; + } + + .thumbnails__scroller { + display: flex; + gap: var(--sl-spacing-small); + overflow-x: auto; + scrollbar-width: none; + scroll-behavior: smooth; + scroll-padding: var(--sl-spacing-small); + } + + .thumbnails__scroller::-webkit-scrollbar { + display: none; + } + + .thumbnails__image { + width: 64px; + height: 64px; + object-fit: cover; + + opacity: 0.3; + will-change: opacity; + transition: 250ms opacity; + + cursor: pointer; + } + + .thumbnails__image.active { + opacity: 1; + } +`; + +const images = [ + { + src: '/assets/examples/carousel/mountains.jpg', + alt: 'The sun shines on the mountains and trees (by Adam Kool on Unsplash' + }, + { + src: '/assets/examples/carousel/waterfall.jpg', + alt: 'A waterfall in the middle of a forest (by Thomas Kelly on Unsplash' + }, + { + src: '/assets/examples/carousel/sunset.jpg', + alt: 'The sun is setting over a lavender field (by Leonard Cotte on Unsplash' + }, + { + src: '/assets/examples/carousel/field.jpg', + alt: 'A field of grass with the sun setting in the background (by Sapan Patel on Unsplash' + }, + { + src: '/assets/examples/carousel/valley.jpg', + alt: 'A scenic view of a mountain with clouds rolling in (by V2osk on Unsplash' + } +]; + +const App = () => { + const carouselRef = useRef(); + const thumbnailsRef = useRef(); + const [currentSlide, setCurrentSlide] = useState(0); + + useEffect(() => { + const thumbnails = Array.from(thumbnailsRef.current.querySelectorAll('.thumbnails__image')); + + thumbnails[currentSlide]..scrollIntoView({ + block: 'nearest' + }); + }, [currentSlide]); + + const handleThumbnailClick = (index) => { + carouselRef.current.goToSlide(index); + } + + const handleSlideChange = (event) => { + const slideIndex = e.detail.index; + setCurrentSlide(slideIndex); + } + + return ( + <> + + {images.map({ src, alt }) => ( + + {alt} + + )} + + +
+
+ {images.map({ src, alt }, i) => ( + {`Thumbnail handleThumbnailClick(i)} + src={src} + /> + )} +
+
+ + + ); +}; +``` + +[component-metadata:sl-carousel] diff --git a/docs/components/checkbox.md b/docs/components/checkbox.md index b6ee400ad5..840cd3bfcd 100644 --- a/docs/components/checkbox.md +++ b/docs/components/checkbox.md @@ -162,7 +162,8 @@ Use the `setCustomValidity()` method to set a custom validation message. This wi const errorMessage = `Do not forget to check me!`; // Set initial validity as soon as the element is defined - customElements.whenDefined('sl-checkbox').then(() => { + customElements.whenDefined('sl-checkbox').then(async () => { + await checkbox.updateComplete; checkbox.setCustomValidity(errorMessage); }); diff --git a/docs/components/dropdown.md b/docs/components/dropdown.md index 2538877710..cbe3b3b757 100644 --- a/docs/components/dropdown.md +++ b/docs/components/dropdown.md @@ -83,7 +83,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 -
+
+ +
+
@@ -41,24 +44,22 @@ Teamshares has enabled the free version Font Awesome's icons, with plans to add sl-icon library="fa-free" name="user" ``` -For the time being, you can use the name of the icon without a prefix, e.g. `face-smile`. Search the Font Awesome site via the form input below. - - +For stroke-based icons, you can use the name of the icon without a prefix, e.g. `face-smile`. Solid icons require an `fas-` prefix. Note that some icons only exist in one style. ```html preview + + + + ``` ```pug slim sl-icon library="fa-free" name="face-smile" +sl-icon library="fa-free" name="fas-face-smile" +sl-icon library="fa-free" name="user" +sl-icon library="fa-free" name="fas-user" +sl-icon library="fa-free" name="fas-users" ``` ```jsx react @@ -67,6 +68,18 @@ import { SlIcon } from '@teamshares/shoelace/dist/react'; const App = () => ; ``` +Search the Font Awesome site via the form input below (will open a popup window). + + + ## Examples ### Colors @@ -588,7 +601,7 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github ``` -## 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. @@ -778,7 +791,19 @@ If you want to change the icons Shoelace uses internally, you can register an ic const onFaSearch = () => { const query = faIconSearchInput.value; if (query) { - window.open(`https://fontawesome.com/search?q=${query}&o=r&m=free`) + const searchPopup = window.open(`https://fontawesome.com/search?q=${query}&o=r&m=free`, 'fontAwesomeSearch', 'popup'); + if (!searchPopup) { + const alert = Object.assign(document.createElement('sl-alert'), { + variant: 'warning', + closable: true, + duration: 5000, + innerHTML: ` + + Please enable popups on this page to see the Font Awesome search results. + ` + }); + document.body.append(alert); + } } } diff --git a/docs/components/qr-code.md b/docs/components/qr-code.md index 219d90f16e..1b2ae80993 100644 --- a/docs/components/qr-code.md +++ b/docs/components/qr-code.md @@ -17,8 +17,10 @@ QR codes are useful for providing small pieces of information to users who can q const qrCode = container.querySelector('sl-qr-code'); const input = container.querySelector('sl-input'); - input.value = qrCode.value; - input.addEventListener('sl-input', () => (qrCode.value = input.value)); + customElements.whenDefined('sl-qr-code').then(() => { + input.value = qrCode.value; + input.addEventListener('sl-input', () => (qrCode.value = input.value)); + }); ``` @@ -98,3 +98,18 @@ When binding complex data such as objects and arrays, use the `.prop` modifier t ``` ?> Are you using Shoelace with Vue? [Help us improve this page!](https://github.com/shoelace-style/shoelace/blob/next/docs/frameworks/vue.md) + +### Slots + +To use Shoelace components with slots, follow the Vue documentation on using [slots with custom elements](https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue). + +Here is an example: + +```html + + This drawer slides in from the start. +
+ Close +
+
+``` diff --git a/docs/getting-started/form-controls.md b/docs/getting-started/form-controls.md index 0e1efd8ca0..288ff3995d 100644 --- a/docs/getting-started/form-controls.md +++ b/docs/getting-started/form-controls.md @@ -489,7 +489,7 @@ To disable the browser's error messages, you need to cancel the `sl-invalid` eve At this time, using [`HTMLFormElement.elements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) will not return Shoelace form controls because the browser is unaware of their status as custom element form controls. Fortunately, Shoelace provides an `elements()` function that does something very similar. However, instead of returning an [`HTMLFormControlsCollection`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection), it returns an array of HTML and Shoelace form controls in the order they appear in the DOM. ```js -import { getFormControls } from '@shoelace-style/shoelace/dist/utilities/form.js'; +import { getFormControls } from '@teamshares/shoelace/dist/utilities/form.js'; const form = document.querySelector('#my-form'); const formControls = getFormControls(form); diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index e045bc58a2..8e5e0787d1 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,32 +1,50 @@ # Installation -You can use Shoelace via CDN or by installing it locally. You can also [cherry pick](#cherry-picking) individual components for faster load times. - -If you're using a framework, make sure to check out the pages for [React](/frameworks/react), [Vue](/frameworks/vue), and [Angular](/frameworks/angular). +You can load Shoelace via CDN or by installing it locally. If you're using a framework, make sure to check out the pages for [React](/frameworks/react), [Vue](/frameworks/vue), and [Angular](/frameworks/angular) for additional information. ## CDN Installation (Easiest) -The easiest way to install Shoelace is with the CDN. Just add the following tags to your page to get all components and the default light theme. + +Autoloader +Traditional Loader + + + +The experimental autoloader is the easiest and most efficient way to use Shoelace. A lightweight script watches the DOM for unregistered Shoelace elements and lazy loads them for you — even if they're added dynamically. + +While convenient, autoloading may lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/). The linked article describes some ways to alleviate it. + + +```html + + +``` + + + + + +The traditional CDN loader registers all Shoelace elements up front. Note that, if you're only using a handful of components, it will be much more efficient to stick with the autoloader. However, you can also [cherry pick](#cherry-picking) components if you want to load specific ones up front. ```html ``` -?> If you're only using a handful of components, it will be more efficient to [cherry pick](#cherry-picking) the ones you need. + + ### Dark Theme -If you prefer to use the [dark theme](/getting-started/themes#dark-theme) instead, use this code and add `` to the page. +The code above will load the light theme. If you want to use the [dark theme](/getting-started/themes#dark-theme) instead, update the stylesheet as shown below and add `` to your page. ```html - ``` ### Light & Dark Theme -If you want to load the light or dark theme based on the user's `prefers-color-scheme` setting, use this. The `media` attributes ensure that only the user's preferred theme stylesheet loads and the `onload` attribute sets the appropriate [theme class](/getting-started/themes) on the `` element. +If you want to load the light or dark theme based on the user's `prefers-color-scheme` setting, use the stylesheets below. The `media` attributes ensure that only the user's preferred theme stylesheet loads and the `onload` attribute sets the appropriate [theme class](/getting-started/themes) on the `` element. ```html - ``` Now you can [start using Shoelace!](/getting-started/usage) @@ -68,7 +85,7 @@ Alternatively, [you can use a bundler](#bundling). ## Setting the Base Path -Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` and will "just work" for most users. +Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` or `shoelace-autoloader.js` and will "just work" for most users. However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Shoelace, you'll need to set the base path. You can do this one of two ways. @@ -88,9 +105,7 @@ However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Sh ## Cherry Picking -The previous approach is the _easiest_ way to load Shoelace, but easy isn't always efficient. You'll incur the full size of the library even if you only use a handful of components. This is convenient for prototyping or if you're using most of the components, but it may result in longer load times in production. To improve this, you can cherry pick the components you need. - -Cherry picking can be done from your local install or [directly from the CDN](https://cdn.jsdelivr.net/npm/@teamshares/shoelace@%VERSION%/). This will limit the number of files the browser has to download and reduce the amount of bytes being transferred. The disadvantage is that you need to load components manually. +Cherry picking can be done from [the CDN](#cdn-installation-easiest) or your [local installation](#local-installation). This approach will load only the components you need up front, while limiting the number of files the browser has to download. The disadvantage is that you need to import each individual component. Here's an example that loads only the button component. Again, if you're not using a module resolver, you'll need to adjust the path to point to the folder Shoelace is in. diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 169a2bb01c..c3112f83cf 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -31,9 +31,10 @@ Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska). Add the following code to your page. + ```html - + ``` Now you have access to all of Shoelace's components! Try adding a button: @@ -42,7 +43,7 @@ Now you have access to all of Shoelace's components! Try adding a button: Click me ``` -?> This will load all of Shoelace's components, but you should probably only load the ones you're actually using. To learn how, or for other ways to install Shoelace, refer to the [installation instructions](getting-started/installation). +?> This will activate Shoelace's experimental autoloader, which registers components on the fly as you use them. To learn more about it, or for other ways to install Shoelace, refer to the [installation instructions](getting-started/installation). ## New to Web Components? diff --git a/docs/getting-started/usage.md b/docs/getting-started/usage.md index d1ae66499b..4b06780ee0 100644 --- a/docs/getting-started/usage.md +++ b/docs/getting-started/usage.md @@ -33,7 +33,9 @@ Refer to a component's documentation for a complete list of its properties. ## Events -You can listen for standard events such as `click`, `mouseover`, etc. as you normally would. In addition, some components emit custom events. These work the same way as standard events, but are prefixed with `sl-` to prevent collisions with standard events and other libraries. +You can listen for standard events such as `click`, `mouseover`, etc. as you normally would. However, it's important to note that many events emitted within a component's shadow root will be [retargeted](https://dom.spec.whatwg.org/#retarget) to the host element. This may result in, for example, multiple `click` handlers executing even if the user clicks just once. Furthermore, `event.target` will point to the host element, making things even more confusing. + +As a result, you should almost always listen for custom events instead. For example, instead of listening to `click` to determine when an `` gets toggled, listen to `sl-change`. ```html Check me @@ -46,7 +48,7 @@ You can listen for standard events such as `click`, `mouseover`, etc. as you nor ``` -Refer to a component's documentation for a complete list of its custom events. +All custom events are prefixed with `sl-` to prevent collisions with standard events and other libraries. Refer to a component's documentation for a complete list of its custom events. ## Methods @@ -164,7 +166,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'); diff --git a/docs/index.html b/docs/index.html index 6930f37212..fbbb00cb08 100644 --- a/docs/index.html +++ b/docs/index.html @@ -48,7 +48,7 @@ rel="stylesheet" /> - + ${match}`; + } + } } }; @@ -131,7 +147,6 @@ fs.mkdirSync(outdir, { recursive: true }); bs.init(browserSyncConfig, () => { const url = `http://localhost:${port}`; console.log(chalk.cyan(`Launched the Shoelace dev server at ${url} 🥾\n`)); - open(url); }); // Rebuild and reload when source files change diff --git a/scripts/make-slim-previews.js b/scripts/make-slim-previews.js index 9145a42da0..8f4e98eebb 100644 --- a/scripts/make-slim-previews.js +++ b/scripts/make-slim-previews.js @@ -4,13 +4,17 @@ import path from 'path'; /* This script generates slim template code blocks from the HTML examples in the markdown. -It should only need to be run once, since the slim examples will then be checked in. - */ +It should only need to be run once per file, since the slim examples will then be checked in. +For new files (new features, etc.), add the filename to the `files` array. +Usage: `node scripts/make-slim-previews.js` +The translation won't be perfect! You will probably need to adjust indentation and change "style." to "css" and "script." to "javascript" +*/ console.log('Make-slim-previews'); const docsDir = path.join('docs/components'); -const files = readdirSync(docsDir); +// const files = readdirSync(docsDir); /* The whole directory */ +const files = ['carousel-item.md']; /* A discreet set of files */ const excluded = ['animation', 'mutation-observer', 'popup', 'icon']; const errorFiles = {}; @@ -35,7 +39,8 @@ files.forEach(fileName => { contents = contents.replace(match, replacement); }); console.log(` done parsing ${nameSlug}.`); - writeFileSync(`${docsDir}/${nameSlug}-slim.md`, contents); + // writeFileSync(`${docsDir}/${nameSlug}-slim.md`, contents); /* The more cautious approach, makes a new copy with `-slim` appended */ + writeFileSync(`${docsDir}/${nameSlug}.md`, contents); console.log(` Wrote to ${nameSlug}.md`); } } catch (err) { diff --git a/src/components/animated-image/animated-image.test.ts b/src/components/animated-image/animated-image.test.ts index eab5e699fa..af9edbcaa3 100644 --- a/src/components/animated-image/animated-image.test.ts +++ b/src/components/animated-image/animated-image.test.ts @@ -1,9 +1,70 @@ -import { expect, fixture, html } from '@open-wc/testing'; +import { clickOnElement } from '../../internal/test'; +import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import type SlAnimatedImage from './animated-image'; describe('', () => { it('should render a component', async () => { - const el = await fixture(html` `); + const animatedImage = await fixture(html` `); - expect(el).to.exist; + expect(animatedImage).to.exist; + }); + + it('should render be accessible', async () => { + const animatedImage = await fixture(html` `); + + await expect(animatedImage).to.be.accessible(); + }); + + const files = ['docs/assets/images/walk.gif', 'docs/assets/images/tie.webp']; + + files.forEach((file: string) => { + it(`should load a ${file} without errors`, async () => { + const animatedImage = await fixture(html` `); + let errorCount = 0; + oneEvent(animatedImage, 'sl-error').then(() => errorCount++); + await loadImage(animatedImage, file); + + expect(errorCount).to.be.equal(0); + }); + + it(`should play ${file} on click`, async () => { + const animatedImage = await fixture(html` `); + await loadImage(animatedImage, file); + + expect(animatedImage.play).not.to.be.true; + + await clickOnElement(animatedImage); + + expect(animatedImage.play).to.be.true; + }); + + it(`should pause and resume ${file} on click`, async () => { + const animatedImage = await fixture(html` `); + await loadImage(animatedImage, file); + + animatedImage.play = true; + + await clickOnElement(animatedImage); + + expect(animatedImage.play).to.be.false; + + await clickOnElement(animatedImage); + + expect(animatedImage.play).to.be.true; + }); + }); + + it('should emit an error event on invalid url', async () => { + const animatedImage = await fixture(html` `); + + const errorPromise = oneEvent(animatedImage, 'sl-error'); + animatedImage.src = 'completelyWrong'; + + await errorPromise; }); }); +async function loadImage(animatedImage: SlAnimatedImage, file: string) { + const loadingPromise = oneEvent(animatedImage, 'sl-load'); + animatedImage.src = file; + await loadingPromise; +} diff --git a/src/components/animation/animation.test.ts b/src/components/animation/animation.test.ts new file mode 100644 index 0000000000..270a5ab6cb --- /dev/null +++ b/src/components/animation/animation.test.ts @@ -0,0 +1,81 @@ +import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing'; +import type SlAnimation from './animation'; + +describe('', () => { + const boxToAnimate = html`
`; + + it('renders', async () => { + const animationContainer = await fixture(html`${boxToAnimate}`); + + expect(animationContainer).to.exist; + }); + + it('is accessible', async () => { + const animationContainer = await fixture(html`${boxToAnimate}`); + + await expect(animationContainer).to.be.accessible(); + }); + + describe('animation start', () => { + it('does not start the animation by default', async () => { + const animationContainer = await fixture( + html`${boxToAnimate}` + ); + await aTimeout(0); + + expect(animationContainer.play).to.be.false; + }); + + it('emits the correct event on animation start', async () => { + const animationContainer = await fixture( + html`${boxToAnimate}` + ); + + const startPromise = oneEvent(animationContainer, 'sl-start'); + animationContainer.play = true; + return startPromise; + }); + }); + + it('emits the correct event on animation end', async () => { + const animationContainer = await fixture( + html`${boxToAnimate}` + ); + + const endPromise = oneEvent(animationContainer, 'sl-finish'); + animationContainer.iterations = 1; + animationContainer.play = true; + return endPromise; + }); + + it('can be finished by hand', async () => { + const animationContainer = await fixture( + html`${boxToAnimate}` + ); + + const endPromise = oneEvent(animationContainer, 'sl-finish'); + animationContainer.iterations = 1; + animationContainer.play = true; + + await aTimeout(0); + + animationContainer.finish(); + return endPromise; + }); + + it('can be cancelled', async () => { + const animationContainer = await fixture( + html`${boxToAnimate}` + ); + let animationHasFinished = false; + oneEvent(animationContainer, 'sl-finish').then(() => (animationHasFinished = true)); + const cancelPromise = oneEvent(animationContainer, 'sl-cancel'); + animationContainer.play = true; + + await aTimeout(0); + animationContainer.cancel(); + + await cancelPromise; + expect(animationHasFinished).to.be.false; + }); +}); diff --git a/src/components/avatar/avatar.test.ts b/src/components/avatar/avatar.test.ts index 969b01fb62..ad8ffc6feb 100644 --- a/src/components/avatar/avatar.test.ts +++ b/src/components/avatar/avatar.test.ts @@ -1,5 +1,4 @@ -import { expect, fixture, html, waitUntil } from '@open-wc/testing'; -import sinon from 'sinon'; +import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing'; import type SlAvatar from './avatar'; // The default avatar background just misses AA contrast, but the next step up is way too dark. Since avatars aren't @@ -73,6 +72,46 @@ describe('', () => { }); }); + describe('when image is present, the initials or icon part should not render', () => { + const initials = 'SL'; + const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + const label = 'Small transparent square'; + before(async () => { + el = await fixture( + html`` + ); + }); + + it('should pass accessibility tests', async () => { + /** + * The image element itself is ancillary, because it's parent container contains the + * aria-label which dictates what "sl-avatar" is. This also implies that label text will + * resolve to "" when not provided and ignored by readers. This is why we use alt="" on + * the image element to pass accessibility. + * https://html.spec.whatwg.org/multipage/images.html#ancillary-images + */ + await expect(el).to.be.accessible({ ignoredRules }); + }); + + it('renders "image" part, with src and a role of presentation', () => { + const part = el.shadowRoot!.querySelector('[part~="image"]')!; + + expect(part.getAttribute('src')).to.eq(image); + }); + + it('should not render the initials part', () => { + const part = el.shadowRoot!.querySelector('[part~="initials"]')!; + + expect(part).to.not.exist; + }); + + it('should not render the icon part', () => { + const slot = el.shadowRoot!.querySelector('slot[name=icon]')!; + + expect(slot).to.not.exist; + }); + }); + ['square', 'rounded', 'circle'].forEach(shape => { describe(`when passed a shape attribute ${shape}`, () => { before(async () => { @@ -113,23 +152,20 @@ describe('', () => { }); it('should not render the image when the image fails to load', async () => { - const errorHandler = sinon.spy(); - el = await fixture(html``); - el.addEventListener('error', errorHandler); el.image = 'bad_image'; - waitUntil(() => errorHandler.calledOnce); + await aTimeout(0); + + await waitUntil(() => el.shadowRoot!.querySelector('img') === null); expect(el.shadowRoot!.querySelector('img')).to.be.null; }); it('should show a valid image after being passed an invalid image initially', async () => { - const errorHandler = sinon.spy(); - el = await fixture(html``); - el.addEventListener('error', errorHandler); - el.image = 'bad_image'; - waitUntil(() => errorHandler.calledOnce); + + await aTimeout(0); + await waitUntil(() => el.shadowRoot!.querySelector('img') === null); el.image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; await el.updateComplete; diff --git a/src/components/avatar/avatar.ts b/src/components/avatar/avatar.ts index 97820ab159..287ea2d72c 100644 --- a/src/components/avatar/avatar.ts +++ b/src/components/avatar/avatar.ts @@ -52,6 +52,29 @@ export default class SlAvatar extends ShoelaceElement { } render() { + const avatarWithImage = html` + + `; + + let avatarWithoutImage = html``; + + if (this.initials) { + avatarWithoutImage = html`
${this.initials}
`; + } else { + avatarWithoutImage = html` + + `; + } + return html`
- ${this.initials - ? html`
${this.initials}
` - : html` - - `} - ${this.image && !this.hasError - ? html` - - ` - : ''} + ${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
`; } diff --git a/src/components/button-group/button-group.ts b/src/components/button-group/button-group.ts index c7184abde0..6d8dca903d 100644 --- a/src/components/button-group/button-group.ts +++ b/src/components/button-group/button-group.ts @@ -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'); } diff --git a/src/components/button/button.styles.ts b/src/components/button/button.styles.ts index 59b9cdd8cb..d8773386c9 100644 --- a/src/components/button/button.styles.ts +++ b/src/components/button/button.styles.ts @@ -343,22 +343,25 @@ export default css` */ .button--small { + height: auto; + min-height: var(--sl-input-height-small); font-size: var(--sl-button-font-size-small); - height: var(--sl-input-height-small); line-height: calc(var(--sl-input-height-small) - var(--sl-input-border-width) * 2); border-radius: var(--sl-input-height-small); } .button--medium { + height: auto; + min-height: var(--sl-input-height-medium); font-size: var(--sl-button-font-size-medium); - height: var(--sl-input-height-medium); line-height: calc(var(--sl-input-height-medium) - var(--sl-input-border-width) * 2); border-radius: var(--sl-input-height-medium); } .button--large { + height: auto; + min-height: var(--sl-input-height-large); font-size: var(--sl-button-font-size-large); - height: var(--sl-input-height-large); line-height: calc(var(--sl-input-height-large) - var(--sl-input-border-width) * 2); border-radius: var(--sl-input-height-large); } diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 62419e3c39..43e8b5618e 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -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()) { diff --git a/src/components/carousel-item/carousel-item.styles.ts b/src/components/carousel-item/carousel-item.styles.ts new file mode 100644 index 0000000000..6bb8efb4a8 --- /dev/null +++ b/src/components/carousel-item/carousel-item.styles.ts @@ -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; + } +`; diff --git a/src/components/carousel-item/carousel-item.test.ts b/src/components/carousel-item/carousel-item.test.ts new file mode 100644 index 0000000000..3cf84981d7 --- /dev/null +++ b/src/components/carousel-item/carousel-item.test.ts @@ -0,0 +1,17 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); + + it('should pass accessibility tests', async () => { + // Arrange + const el = await fixture(html`
`); + + // Assert + await expect(el).to.be.accessible(); + }); +}); diff --git a/src/components/carousel-item/carousel-item.ts b/src/components/carousel-item/carousel-item.ts new file mode 100644 index 0000000000..eda5ea73fb --- /dev/null +++ b/src/components/carousel-item/carousel-item.ts @@ -0,0 +1,40 @@ +import { customElement } from 'lit/decorators.js'; +import { html } from 'lit'; +import ShoelaceElement from '../../internal/shoelace-element'; +import styles from './carousel-item.styles'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary A carousel item represent a slide within a [carousel](/components/carousel). + * + * @since 2.0 + * @status experimental + * + * @slot - The carousel item's content.. + * + * @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default. + * + */ +@customElement('sl-carousel-item') +export default class SlCarouselItem extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + static isCarouselItem(node: Node) { + return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide'; + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'group'); + } + + render() { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-carousel-item': SlCarouselItem; + } +} diff --git a/src/components/carousel/autoplay-controller.ts b/src/components/carousel/autoplay-controller.ts new file mode 100644 index 0000000000..7fc6addac8 --- /dev/null +++ b/src/components/carousel/autoplay-controller.ts @@ -0,0 +1,73 @@ +import type { ReactiveController, ReactiveElement } from 'lit'; + +/** + * A controller that repeatedly calls the specified callback with the provided interval time. + * The timer is automatically paused while the user is interacting with the component. + */ +export class AutoplayController implements ReactiveController { + private host: ReactiveElement; + private timerId = 0; + private tickCallback: () => void; + private activeInteractions = 0; + + paused = false; + stopped = true; + + constructor(host: ReactiveElement, tickCallback: () => void) { + host.addController(this); + + this.host = host; + this.tickCallback = tickCallback; + } + + hostConnected(): void { + this.host.addEventListener('mouseenter', this.pause); + this.host.addEventListener('mouseleave', this.resume); + this.host.addEventListener('focusin', this.pause); + this.host.addEventListener('focusout', this.resume); + this.host.addEventListener('touchstart', this.pause, { passive: true }); + this.host.addEventListener('touchend', this.resume); + } + + hostDisconnected(): void { + this.stop(); + + this.host.removeEventListener('mouseenter', this.pause); + this.host.removeEventListener('mouseleave', this.resume); + this.host.removeEventListener('focusin', this.pause); + this.host.removeEventListener('focusout', this.resume); + this.host.removeEventListener('touchstart', this.pause); + this.host.removeEventListener('touchend', this.resume); + } + + start(interval: number) { + this.stop(); + + this.stopped = false; + this.timerId = window.setInterval(() => { + if (!this.paused) { + this.tickCallback(); + } + }, interval); + } + + stop() { + clearInterval(this.timerId); + this.stopped = true; + this.host.requestUpdate(); + } + + pause = () => { + if (!this.activeInteractions++) { + this.paused = true; + this.host.requestUpdate(); + } + }; + + resume = () => { + if (!--this.activeInteractions) { + this.paused = false; + this.host.requestUpdate(); + } + }; +} diff --git a/src/components/carousel/carousel.styles.ts b/src/components/carousel/carousel.styles.ts new file mode 100644 index 0000000000..ce6d2206e2 --- /dev/null +++ b/src/components/carousel/carousel.styles.ts @@ -0,0 +1,160 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + --slide-gap: var(--sl-spacing-medium, 1rem); + --aspect-ratio: 16 / 9; + --scroll-hint: 0px; + + display: flex; + } + + .carousel { + display: grid; + grid-template-columns: min-content 1fr min-content; + grid-template-rows: 1fr min-content; + grid-template-areas: + '. slides .' + '. pagination .'; + gap: var(--sl-spacing-medium); + align-items: center; + min-height: 100%; + min-width: 100%; + position: relative; + } + + .carousel__pagination { + grid-area: pagination; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--sl-spacing-small); + } + + .carousel__slides { + grid-area: slides; + + display: grid; + height: 100%; + width: 100%; + align-items: center; + justify-items: center; + overflow: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + aspect-ratio: calc(var(--aspect-ratio) * var(--slides-per-page)); + border-radius: var(--sl-border-radius-small); + + --slide-size: calc((100% - (var(--slides-per-page) - 1) * var(--slide-gap)) / var(--slides-per-page)); + } + + @media (prefers-reduced-motion) { + :where(.carousel__slides) { + scroll-behavior: auto; + } + } + + .carousel__slides--horizontal { + grid-auto-flow: column; + grid-auto-columns: var(--slide-size); + grid-auto-rows: 100%; + column-gap: var(--slide-gap); + scroll-snap-type: x mandatory; + scroll-padding-inline: var(--scroll-hint); + padding-inline: var(--scroll-hint); + overflow-y: hidden; + } + + .carousel__slides--vertical { + grid-auto-flow: row; + grid-auto-columns: 100%; + grid-auto-rows: var(--slide-size); + row-gap: var(--slide-gap); + scroll-snap-type: y mandatory; + scroll-padding-block: var(--scroll-hint); + padding-block: var(--scroll-hint); + overflow-x: hidden; + } + + .carousel__slides--dragging, + .carousel__slides--dropping { + scroll-snap-type: unset; + } + + :host([vertical]) ::slotted(sl-carousel-item) { + height: 100%; + } + + .carousel__slides::-webkit-scrollbar { + display: none; + } + + .carousel__navigation { + grid-area: navigation; + display: contents; + font-size: var(--sl-font-size-x-large); + } + + .carousel__navigation-button { + flex: 0 0 auto; + display: flex; + align-items: center; + background: none; + border: none; + border-radius: var(--sl-border-radius-small); + font-size: inherit; + color: var(--sl-color-neutral-600); + padding: var(--sl-spacing-x-small); + cursor: pointer; + transition: var(--sl-transition-medium) color; + appearance: none; + } + + .carousel__navigation-button--disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .carousel__navigation-button--disabled::part(base) { + pointer-events: none; + } + + .carousel__navigation-button--previous { + grid-column: 1; + grid-row: 1; + } + + .carousel__navigation-button--next { + grid-column: 3; + grid-row: 1; + } + + .carousel__pagination-item { + display: block; + cursor: pointer; + background: none; + border: 0; + border-radius: var(--sl-border-radius-circle); + width: var(--sl-spacing-small); + height: var(--sl-spacing-small); + background-color: var(--sl-color-neutral-300); + padding: 0; + margin: 0; + } + + .carousel__pagination-item--active { + background-color: var(--sl-color-neutral-700); + transform: scale(1.2); + } + + /* Focus styles */ + .carousel__slides:focus-visible, + .carousel__navigation-button:focus-visible, + .carousel__pagination-item:focus-visible { + outline: var(--sl-focus-ring); + outline-offset: var(--sl-focus-ring-offset); + } +`; diff --git a/src/components/carousel/carousel.test.ts b/src/components/carousel/carousel.test.ts new file mode 100644 index 0000000000..1b02a82557 --- /dev/null +++ b/src/components/carousel/carousel.test.ts @@ -0,0 +1,588 @@ +import { clickOnElement } from '../../internal/test'; +import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import sinon from 'sinon'; +import type SlCarousel from './carousel'; + +describe('', () => { + it('should render a carousel with default configuration', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + + // Assert + expect(el).to.exist; + expect(el).to.have.attribute('role', 'region'); + expect(el).to.have.attribute('aria-label', 'Carousel'); + expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist; + expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist; + }); + + describe('when `autoplay` attribute is provided', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ + now: new Date() + }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should scroll forwards every `autoplay-interval` milliseconds', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + sinon.stub(el, 'next'); + + await el.updateComplete; + + // Act + el.dispatchEvent(new Event('mouseenter')); + await el.updateComplete; + clock.next(); + clock.next(); + + // Assert + expect(el.next).not.to.have.been.called; + }); + + it('should not resume if the user is still interacting', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + sinon.stub(el, 'next'); + + await el.updateComplete; + + // Act + el.dispatchEvent(new Event('mouseenter')); + el.dispatchEvent(new Event('focusin')); + await el.updateComplete; + + el.dispatchEvent(new Event('mouseleave')); + await el.updateComplete; + + clock.next(); + clock.next(); + + // Assert + expect(el.next).not.to.have.been.called; + }); + }); + + describe('when `loop` attribute is provided', () => { + it('should create clones of the first and last slides', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + + // 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(html` + + Node 1 + Node 2 + Node 3 + + `); + + // 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` + + Node 1 + Node 2 + Node 3 + + `); + + // 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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` + + Node 1 + Node 2 + Node 3 + + `); + + // 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(html` + + Node 1 + Node 2 + Node 3 + + `); + + // 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(html` + + Node 1 + Node 2 + Node 3 + Node 4 + + `); + + // 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(html` + + Node 1 + Node 2 + + `); + + // 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(html` + + Node 1 + Node 2 + + `); + + // 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + + // 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(html` + + Node 1 + Node 2 + Node 3 + + `); + + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + const pagination = el.shadowRoot!.querySelector('.carousel__pagination')!; + const navigation = el.shadowRoot!.querySelector('.carousel__navigation')!; + await el.updateComplete; + + // Assert + expect(el.scrollContainer).to.have.attribute('aria-busy', 'false'); + expect(el.scrollContainer).to.have.attribute('aria-atomic', 'true'); + + expect(pagination).to.have.attribute('role', 'tablist'); + expect(pagination).to.have.attribute('aria-controls', el.scrollContainer.id); + for (const paginationItem of pagination.querySelectorAll('.carousel__pagination-item')) { + expect(paginationItem).to.have.attribute('role', 'tab'); + expect(paginationItem).to.have.attribute('aria-selected'); + expect(paginationItem).to.have.attribute('aria-label'); + } + + for (const navigationItem of navigation.querySelectorAll('.carousel__navigation-item')) { + expect(navigationItem).to.have.attribute('aria-controls', el.scrollContainer.id); + expect(navigationItem).to.have.attribute('aria-disabled'); + expect(navigationItem).to.have.attribute('aria-label'); + } + + await expect(el).to.be.accessible(); + }); + + describe('when scrolling', () => { + it('should update aria-busy attribute', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + + 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'); + }); + }); + }); +}); diff --git a/src/components/carousel/carousel.ts b/src/components/carousel/carousel.ts new file mode 100644 index 0000000000..e05d008d02 --- /dev/null +++ b/src/components/carousel/carousel.ts @@ -0,0 +1,471 @@ +import '../icon/icon'; +import { AutoplayController } from './autoplay-controller'; +import { clamp } from 'src/internal/math'; +import { classMap } from 'lit/directives/class-map.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { html } from 'lit'; +import { LocalizeController } from '@shoelace-style/localize'; +import { map } from 'lit/directives/map.js'; +import { prefersReducedMotion } from '../../internal/animate'; +import { range } from 'lit/directives/range.js'; +import { ScrollController } from './scroll-controller'; +import { watch } from '../../internal/watch'; +import ShoelaceElement from '../../internal/shoelace-element'; +import SlCarouselItem from '../carousel-item/carousel-item'; +import styles from './carousel.styles'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis. + * + * @since 2.2 + * @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 `` elements. + * @slot next-icon - Optional next icon to use instead of the default. Works best with ``. + * @slot previous-icon - Optional previous icon to use instead of the default. Works best with ``. + * + * @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(); + private readonly localize = new LocalizeController(this); + private mutationObserver: MutationObserver; + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'region'); + this.setAttribute('aria-label', this.localize.term('carousel')); + + const intersectionObserver = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => { + entries.forEach(entry => { + // Store all the entries in a map to be processed when scrolling ends + this.intersectionObserverEntries.set(entry.target, entry); + + const slide = entry.target; + slide.toggleAttribute('inert', !entry.isIntersecting); + slide.classList.toggle('--in-view', entry.isIntersecting); + slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true'); + }); + }, + { + root: this, + threshold: 0.6 + } + ); + this.intersectionObserver = intersectionObserver; + + // Store the initial state of each slide + intersectionObserver.takeRecords().forEach(entry => { + this.intersectionObserverEntries.set(entry.target, entry); + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.intersectionObserver.disconnect(); + this.mutationObserver.disconnect(); + } + + protected firstUpdated(): void { + this.initializeSlides(); + this.mutationObserver = new MutationObserver(this.handleSlotChange.bind(this)); + this.mutationObserver.observe(this, { childList: true, subtree: false }); + } + + private getPageCount() { + return Math.ceil(this.getSlides().length / this.slidesPerPage); + } + + private getCurrentPage() { + return Math.floor(this.activeSlide / this.slidesPerPage); + } + + private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) { + return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone')); + } + + private handleKeyDown(event: KeyboardEvent) { + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { + const target = event.target as HTMLElement; + const isRtl = this.localize.dir() === 'rtl'; + const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null; + const isNext = + event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft'); + const isPrevious = + event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight'); + + event.preventDefault(); + + if (isPrevious) { + this.previous(); + } + + if (isNext) { + this.next(); + } + + if (event.key === 'Home') { + this.goToSlide(0); + } + + if (event.key === 'End') { + this.goToSlide(this.getSlides().length - 1); + } + + if (isFocusInPagination) { + this.updateComplete.then(() => { + const activePaginationItem = this.shadowRoot?.querySelector( + '[part~="pagination-item--active"]' + ); + + if (activePaginationItem) { + activePaginationItem.focus(); + } + }); + } + } + } + + private handleScrollEnd() { + const slides = this.getSlides(); + const entries = [...this.intersectionObserverEntries.values()]; + + const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting); + + if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) { + const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone')); + + // Scrolls to the original slide without animating, so the user won't notice that the position has changed + this.goToSlide(clonePosition, 'auto'); + + return; + } + + // Activate the first intersecting slide + if (firstIntersecting) { + this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem); + } + } + + private handleSlotChange(mutations: MutationRecord[]) { + const needsInitialization = mutations.some(mutation => + [...mutation.addedNodes, ...mutation.removedNodes].some( + node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone') + ) + ); + + // Reinitialize the carousel if a carousel item has been added or removed + if (needsInitialization) { + this.initializeSlides(); + } + this.requestUpdate(); + } + + @watch('loop', { waitUntilFirstUpdate: true }) + @watch('slidesPerPage', { waitUntilFirstUpdate: true }) + initializeSlides() { + const slides = this.getSlides(); + const intersectionObserver = this.intersectionObserver; + + this.intersectionObserverEntries.clear(); + + // Removes all the cloned elements from the carousel + this.getSlides({ excludeClones: false }).forEach((slide, index) => { + intersectionObserver.unobserve(slide); + + slide.classList.remove('--in-view'); + slide.classList.remove('--is-active'); + slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1)); + + if (slide.hasAttribute('data-clone')) { + slide.remove(); + } + }); + + if (this.loop) { + // Creates clones to be placed before and after the original elements to simulate infinite scrolling + const slidesPerPage = this.slidesPerPage; + const lastSlides = slides.slice(-slidesPerPage); + const firstSlides = slides.slice(0, slidesPerPage); + + lastSlides.reverse().forEach((slide, i) => { + const clone = slide.cloneNode(true) as HTMLElement; + clone.setAttribute('data-clone', String(slides.length - i - 1)); + this.prepend(clone); + }); + + firstSlides.forEach((slide, i) => { + const clone = slide.cloneNode(true) as HTMLElement; + clone.setAttribute('data-clone', String(i)); + this.append(clone); + }); + } + + this.getSlides({ excludeClones: false }).forEach(slide => { + intersectionObserver.observe(slide); + }); + + // Because the DOM may be changed, restore the scroll position to the active slide + this.goToSlide(this.activeSlide, 'auto'); + } + + @watch('activeSlide') + handelSlideChange() { + const slides = this.getSlides(); + slides.forEach((slide, i) => { + slide.classList.toggle('--is-active', i === this.activeSlide); + }); + + // Do not emit an event on first render + if (this.hasUpdated) { + this.emit('sl-slide-change', { + detail: { + index: this.activeSlide, + slide: slides[this.activeSlide] + } + }); + } + } + + @watch('slidesPerMove') + handleSlidesPerMoveChange() { + const slides = this.getSlides({ excludeClones: false }); + + const slidesPerMove = this.slidesPerMove; + slides.forEach((slide, i) => { + const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0; + if (shouldSnap) { + slide.style.removeProperty('scroll-snap-align'); + } else { + slide.style.setProperty('scroll-snap-align', 'none'); + } + }); + } + + @watch('autoplay') + handleAutoplayChange() { + this.autoplayController.stop(); + if (this.autoplay) { + this.autoplayController.start(this.autoplayInterval); + } + } + + @watch('mouseDragging') + handleMouseDraggingChange() { + this.scrollController.mouseDragging = this.mouseDragging; + } + + /** + * Move the carousel backward by `slides-per-move` slides. + * + * @param behavior - The behavior used for scrolling. + */ + previous(behavior: ScrollBehavior = 'smooth') { + this.goToSlide(this.activeSlide - this.slidesPerMove, behavior); + } + + /** + * Move the carousel forward by `slides-per-move` slides. + * + * @param behavior - The behavior used for scrolling. + */ + next(behavior: ScrollBehavior = 'smooth') { + this.goToSlide(this.activeSlide + this.slidesPerMove, behavior); + } + + /** + * Scrolls the carousel to the slide specified by `index`. + * + * @param index - The slide index. + * @param behavior - The behavior used for scrolling. + */ + goToSlide(index: number, behavior: ScrollBehavior = 'smooth') { + const { slidesPerPage, loop, scrollContainer } = this; + + const slides = this.getSlides(); + const slidesWithClones = this.getSlides({ excludeClones: false }); + + // Sets the next index without taking into account clones, if any. + const newActiveSlide = (index + slides.length) % slides.length; + this.activeSlide = newActiveSlide; + + // Get the index of the next slide. For looping carousel it adds `slidesPerPage` + // to normalize the starting index in order to ignore the first nth clones. + const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1); + const nextSlide = slidesWithClones[nextSlideIndex]; + + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const nextSlideRect = nextSlide.getBoundingClientRect(); + + scrollContainer.scrollTo({ + left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft, + top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop, + behavior: prefersReducedMotion() ? 'auto' : behavior + }); + } + + render() { + const { scrollController, slidesPerPage } = this; + const pagesCount = this.getPageCount(); + const currentPage = this.getCurrentPage(); + const prevEnabled = this.loop || currentPage > 0; + const nextEnabled = this.loop || currentPage < pagesCount - 1; + const isLtr = this.localize.dir() === 'ltr'; + + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-carousel': SlCarousel; + } +} diff --git a/src/components/carousel/scroll-controller.ts b/src/components/carousel/scroll-controller.ts new file mode 100644 index 0000000000..73bb5a1d55 --- /dev/null +++ b/src/components/carousel/scroll-controller.ts @@ -0,0 +1,178 @@ +import { debounce } from 'src/internal/debounce'; +import { prefersReducedMotion } from 'src/internal/animate'; +import { waitForEvent } from 'src/internal/event'; +import type { ReactiveController, ReactiveElement } from 'lit'; + +interface ScrollHost extends ReactiveElement { + scrollContainer: HTMLElement; +} + +/** + * A controller for handling scrolling and mouse dragging. + */ +export class ScrollController 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) { + // If no pointer is active in the scroll area then the scroll has ended + this.scrolling = false; + this.host.scrollContainer.dispatchEvent( + new CustomEvent('scrollend', { + bubbles: false, + cancelable: false + }) + ); + this.host.requestUpdate(); + } else { + // otherwise let's wait a bit more + this.handleScrollEnd(); + } + } + + handlePointerDown(event: PointerEvent) { + if (event.pointerType === 'touch') { + return; + } + + this.pointers.add(event.pointerId); + + const canDrag = this.mouseDragging && !this.dragging && event.button === 0; + if (canDrag) { + event.preventDefault(); + + this.host.scrollContainer.addEventListener('pointermove', this.handlePointerMove); + } + } + + handlePointerMove(event: PointerEvent) { + const scrollContainer = this.host.scrollContainer; + + const hasMoved = !!event.movementX || !!event.movementY; + if (!this.dragging && hasMoved) { + // Start dragging if it hasn't yet + scrollContainer.setPointerCapture(event.pointerId); + this.handleDragStart(); + } else if (scrollContainer.hasPointerCapture(event.pointerId)) { + // Ignore pointers that we are not tracking + this.handleDrag(event); + } + } + + handlePointerUp(event: PointerEvent) { + this.pointers.delete(event.pointerId); + this.host.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(); + } +} diff --git a/src/components/checkbox/checkbox.styles.ts b/src/components/checkbox/checkbox.styles.ts index 7aabed968e..8f08832473 100644 --- a/src/components/checkbox/checkbox.styles.ts +++ b/src/components/checkbox/checkbox.styles.ts @@ -11,7 +11,7 @@ export default css` .checkbox { position: relative; display: inline-flex; - align-items: top; + align-items: flex-start; font-family: var(--sl-input-font-family); font-weight: var(--sl-input-font-weight); color: var(--sl-input-label-color); diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index 10e99b11d4..11af9ff961 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -166,6 +166,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(); diff --git a/src/components/color-picker/color-picker.test.ts b/src/components/color-picker/color-picker.test.ts index 2c646c16ca..844b1a063c 100644 --- a/src/components/color-picker/color-picker.test.ts +++ b/src/components/color-picker/color-picker.test.ts @@ -493,20 +493,22 @@ describe('', () => { 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(html` `); 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(html` `); 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(html` `); + it('should receive the correct validation attributes ("states") when valid', async () => { + const el = await fixture(html` `); + 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('', () => { 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(html` `); + 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('', () => { 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; }); }); diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index afa085488e..aba32a2bc5 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -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 '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) { diff --git a/src/components/details/details.test.ts b/src/components/details/details.test.ts index 4d1f9d29af..b7d6774f13 100644 --- a/src/components/details/details.test.ts +++ b/src/components/details/details.test.ts @@ -2,8 +2,24 @@ 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('', () => { + describe('accessibility', () => { + it('should be accessible when closed', async () => { + const details = await fixture(html` Test text `); + + await expect(details).to.be.accessible(); + }); + + it('should be accessible when open', async () => { + const details = await fixture(html`Test text`); + + await expect(details).to.be.accessible(); + }); + }); + it('should be visible with the open attribute', async () => { const el = await fixture(html` @@ -134,7 +150,7 @@ describe('', () => { consequat. `); - 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 +169,7 @@ describe('', () => { consequat. `); - const hideHandler = sinon.spy((event: CustomEvent) => event.preventDefault()); + const hideHandler = sinon.spy((event: SlHideEvent) => event.preventDefault()); el.addEventListener('sl-hide', hideHandler); el.open = false; diff --git a/src/components/details/details.ts b/src/components/details/details.ts index 4bec707c9f..bd3e11012c 100644 --- a/src/components/details/details.ts +++ b/src/components/details/details.ts @@ -170,7 +170,7 @@ export default class SlDetails extends ShoelaceElement { 'details--rtl': isRtl })} > - + -
- +
+
`; diff --git a/src/components/dropdown/dropdown.ts b/src/components/dropdown/dropdown.ts index 4c98af7d8c..48b2e21d63 100644 --- a/src/components/dropdown/dropdown.ts +++ b/src/components/dropdown/dropdown.ts @@ -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); } diff --git a/src/components/icon/icon.test.ts b/src/components/icon/icon.test.ts index 8d97c00189..d366d8414f 100644 --- a/src/components/icon/icon.test.ts +++ b/src/components/icon/icon.test.ts @@ -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('', () => { it('renders pre-loaded system icons and emits sl-load event', async () => { const el = await fixture(html` `); - const listener = oneEvent(el, 'sl-load') as Promise; + const listener = oneEvent(el, 'sl-load') as Promise; el.name = 'check'; const ev = await listener; @@ -93,6 +95,7 @@ describe('', () => { 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('', () => { describe('new library', () => { it('renders icons from the new library and emits sl-load event', async () => { const el = await fixture(html` `); - const listener = oneEvent(el, 'sl-load') as Promise; + const listener = oneEvent(el, 'sl-load') as Promise; el.name = 'test-icon1'; const ev = await listener; @@ -129,7 +132,7 @@ describe('', () => { it('emits sl-error when the file cant be retrieved', async () => { const el = await fixture(html` `); - const listener = oneEvent(el, 'sl-error') as Promise; + const listener = oneEvent(el, 'sl-error') as Promise; el.name = 'bad-request'; const ev = await listener; @@ -141,7 +144,7 @@ describe('', () => { it("emits sl-error when there isn't an svg element in the registered icon", async () => { const el = await fixture(html` `); - const listener = oneEvent(el, 'sl-error') as Promise; + const listener = oneEvent(el, 'sl-error') as Promise; el.name = 'bad-icon'; const ev = await listener; diff --git a/src/components/icon/icon.ts b/src/components/icon/icon.ts index 9547aefe93..ca435731c1 100644 --- a/src/components/icon/icon.ts +++ b/src/components/icon/icon.ts @@ -1,14 +1,16 @@ import { customElement, property, state } from 'lit/decorators.js'; import { getIconLibrary, unwatchIcon, watchIcon } from './library'; -import { html } from 'lit'; -import { requestIcon } from './request'; -import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { watch } from '../../internal/watch'; import ShoelaceElement from '../../internal/shoelace-element'; import styles from './icon.styles'; import type { CSSResultGroup } from 'lit'; +const CACHEABLE_ERROR = Symbol(); +const RETRYABLE_ERROR = Symbol(); +type SVGResult = SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR; + let parser: DOMParser; +const iconCache = new Map>(); /** * @summary Icons are symbols that can be used to represent various options within an application. @@ -18,12 +20,44 @@ 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 { static styles: CSSResultGroup = styles; - @state() private svg = ''; + /** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */ + private static async resolveIcon(url: string): Promise { + let fileData: Response; + try { + fileData = await fetch(url, { mode: 'cors' }); + if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR; + } catch { + return RETRYABLE_ERROR; + } + + try { + const div = document.createElement('div'); + div.innerHTML = await fileData.text(); + + const svg = div.firstElementChild; + if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR; + + if (!parser) parser = new DOMParser(); + const doc = parser.parseFromString(svg.outerHTML, 'text/html'); + + const svgEl = doc.body.querySelector('svg'); + if (!svgEl) return CACHEABLE_ERROR; + + svgEl.part.add('svg'); + return document.adoptNode(svgEl); + } catch { + return CACHEABLE_ERROR; + } + } + + @state() private svg: SVGElement | null = null; /** The name of the icon to draw. Available names depend on the icon library being used. */ @property({ reflect: true }) name?: string; @@ -85,45 +119,42 @@ export default class SlIcon extends ShoelaceElement { const library = getIconLibrary(this.library); const url = this.getUrl(); - // Create an instance of the DOM parser. We do it here instead of top-level to support SSR while maintaining a - // single parser instance for optimal performance. - if (!parser) { - parser = new DOMParser(); + if (!url) { + this.svg = null; + return; + } + + let iconResolver = iconCache.get(url); + if (!iconResolver) { + iconResolver = SlIcon.resolveIcon(url); + iconCache.set(url, iconResolver); + } + + const svg = await iconResolver; + if (svg === RETRYABLE_ERROR) { + iconCache.delete(url); + } + + if (url !== this.getUrl()) { + // If the url has changed while fetching the icon, ignore this request + return; } - if (url) { - try { - const file = await requestIcon(url); - if (url !== this.getUrl()) { - // If the url has changed while fetching the icon, ignore this request - return; - } else if (file.ok) { - const doc = parser.parseFromString(file.svg, 'text/html'); - const svgEl = doc.body.querySelector('svg'); - - if (svgEl !== null) { - library?.mutator?.(svgEl); - this.svg = svgEl.outerHTML; - this.emit('sl-load'); - } else { - this.svg = ''; - this.emit('sl-error'); - } - } else { - this.svg = ''; - this.emit('sl-error'); - } - } catch { + switch (svg) { + case RETRYABLE_ERROR: + case CACHEABLE_ERROR: + this.svg = null; this.emit('sl-error'); - } - } else if (this.svg.length > 0) { - // If we can't resolve a URL and an icon was previously set, remove it - this.svg = ''; + break; + default: + this.svg = svg.cloneNode(true) as SVGElement; + library?.mutator?.(this.svg); + this.emit('sl-load'); } } render() { - return html` ${unsafeSVG(this.svg)} `; + return this.svg; } } diff --git a/src/components/icon/library.default.ts b/src/components/icon/library.default.ts index 28f3672c52..f001c61a8f 100644 --- a/src/components/icon/library.default.ts +++ b/src/components/icon/library.default.ts @@ -3,7 +3,7 @@ import type { IconLibrary } from './library'; const library: IconLibrary = { name: 'default', - resolver: name => `${getBasePath()}/assets/icons/${name}.svg` + resolver: name => getBasePath(`assets/icons/${name}.svg`) }; export default library; diff --git a/src/components/icon/request.ts b/src/components/icon/request.ts deleted file mode 100644 index 073b9ae588..0000000000 --- a/src/components/icon/request.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { requestInclude } from '../include/request'; - -type IconFile = - | { - ok: true; - status: number; - svg: string; - } - | { - ok: false; - status: number; - svg: null; - }; - -interface IconFileUnknown { - ok: boolean; - status: number; - svg: string | null; -} - -const iconFiles = new Map(); - -export async function requestIcon(url: string): Promise { - if (iconFiles.has(url)) { - return iconFiles.get(url)!; - } - const fileData = await requestInclude(url); - const iconFileData: IconFileUnknown = { - ok: fileData.ok, - status: fileData.status, - svg: null - }; - if (fileData.ok) { - const div = document.createElement('div'); - div.innerHTML = fileData.html; - const svg = div.firstElementChild; - iconFileData.svg = svg?.tagName.toLowerCase() === 'svg' ? svg.outerHTML : ''; - } - - iconFiles.set(url, iconFileData as IconFile); - return iconFileData as IconFile; -} diff --git a/src/components/include/request.ts b/src/components/include/request.ts index efcb818cd2..8985b6a921 100644 --- a/src/components/include/request.ts +++ b/src/components/include/request.ts @@ -4,20 +4,26 @@ interface IncludeFile { html: string; } -const includeFiles = new Map>(); +const includeFiles = new Map>(); /** Fetches an include file from a remote source. Caching is enabled so the origin is only pinged once. */ export function requestInclude(src: string, mode: 'cors' | 'no-cors' | 'same-origin' = 'cors'): Promise { - if (includeFiles.has(src)) { - return includeFiles.get(src)!; + const prev = includeFiles.get(src); + if (prev !== undefined) { + // Promise.resolve() transparently unboxes prev if it was a promise. + return Promise.resolve(prev); } const fileDataPromise = fetch(src, { mode: mode }).then(async response => { - return { + const res = { ok: response.ok, status: response.status, html: await response.text() }; + // Replace the cached promise with its result to avoid having buggy browser Promises retain memory as mentioned in #1284 and #1249 + includeFiles.set(src, res); + return res; }); + // Cache the promise to only fetch() once per src includeFiles.set(src, fileDataPromise); return fileDataPromise; } diff --git a/src/components/input/input.styles.ts b/src/components/input/input.styles.ts index 79870d093b..8a02483eea 100644 --- a/src/components/input/input.styles.ts +++ b/src/components/input/input.styles.ts @@ -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 { diff --git a/src/components/input/input.test.ts b/src/components/input/input.test.ts index c72840aaa4..879b0089ca 100644 --- a/src/components/input/input.test.ts +++ b/src/components/input/input.test.ts @@ -350,7 +350,7 @@ describe('', () => { 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(html` `); el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 7e13f0f3dc..54ac09e70f 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -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 , so we need this -// check to apply a clip-path to hide it. I know, I know…user agent sniffing is nasty but, if it fails, we only see a -// redundant clear icon so nothing important is breaking. The benefits outweigh the costs for this one. See the -// discussion at: https://github.com/shoelace-style/shoelace/pull/794 -// -// Also note that we do the Chromium check first to prevent Chrome from logging a console notice as described here: -// https://github.com/shoelace-style/shoelace/issues/855 -// -const isChromium = navigator.userAgentData?.brands.some(b => b.brand.includes('Chromium')); -const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox'); - /** * @summary Inputs collect data from the user. * @documentation https://shoelace.style/components/input @@ -156,10 +144,10 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont @property({ type: Number }) maxlength: number; /** The input's minimum value. Only applies to date and number input types. */ - @property({ type: Number }) min: number; + @property() min: number | string; /** The input's maximum value. Only applies to date and number input types. */ - @property({ type: Number }) max: number; + @property() max: number | string; /** * Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is @@ -202,13 +190,20 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont */ @property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; + // + // NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties + // can be set before the component is rendered. + // + /** Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. */ get valueAsDate() { - return this.input?.valueAsDate ?? null; + const input = document.createElement('input'); + input.type = 'date'; + input.value = this.value; + return input.valueAsDate; } set valueAsDate(newValue: Date | null) { - // We use an in-memory input instead of the one in the template because the property can be set before render const input = document.createElement('input'); input.type = 'date'; input.valueAsDate = newValue; @@ -217,11 +212,13 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont /** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */ get valueAsNumber() { - return this.input?.valueAsNumber ?? parseFloat(this.value); + const input = document.createElement('input'); + input.type = 'number'; + input.value = this.value; + return input.valueAsNumber; } set valueAsNumber(newValue: number) { - // We use an in-memory input instead of the one in the template because the property can be set before render const input = document.createElement('input'); input.type = 'number'; input.valueAsNumber = newValue; @@ -389,6 +386,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 +449,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 })} > @@ -469,9 +470,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont max=${ifDefined(this.max)} step=${ifDefined(this.step as number)} .value=${live(this.value)} - autocapitalize=${ifDefined(this.type === 'password' ? 'off' : this.autocapitalize)} - autocomplete=${ifDefined(this.type === 'password' ? 'off' : this.autocomplete)} - autocorrect=${ifDefined(this.type === 'password' ? 'off' : this.autocorrect)} + autocapitalize=${ifDefined(this.autocapitalize)} + autocomplete=${ifDefined(this.autocomplete)} + autocorrect=${ifDefined(this.autocorrect)} ?autofocus=${this.autofocus} spellcheck=${this.spellcheck} pattern=${ifDefined(this.pattern)} diff --git a/src/components/menu-item/menu-item.styles.ts b/src/components/menu-item/menu-item.styles.ts index 8ee1fef6d5..9f1c312c55 100644 --- a/src/components/menu-item/menu-item.styles.ts +++ b/src/components/menu-item/menu-item.styles.ts @@ -60,16 +60,16 @@ export default css` margin-inline-start: var(--sl-spacing-x-small); } - :host(:focus) { + :host(:focus-visible) { outline: none; } - :host(:hover:not([aria-disabled='true'])) .menu-item { + :host(:hover:not([aria-disabled='true'], :focus-visible)) .menu-item { background-color: var(--sl-color-neutral-100); color: var(--sl-color-neutral-1000); } - :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; } diff --git a/src/components/menu-item/menu-item.ts b/src/components/menu-item/menu-item.ts index dec93566c3..5198f44629 100644 --- a/src/components/menu-item/menu-item.ts +++ b/src/components/menu-item/menu-item.ts @@ -25,6 +25,7 @@ import type { CSSResultGroup } from 'lit'; * @csspart prefix - The prefix container. * @csspart label - The menu item label. * @csspart suffix - The suffix container. + * @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented). */ @customElement('sl-menu-item') export default class SlMenuItem extends ShoelaceElement { @@ -141,7 +142,7 @@ export default class SlMenuItem extends ShoelaceElement { - + diff --git a/src/components/menu/menu.test.ts b/src/components/menu/menu.test.ts index 358bb1289a..ea916fd423 100644 --- a/src/components/menu/menu.test.ts +++ b/src/components/menu/menu.test.ts @@ -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('', () => { it('emits sl-select with the correct event detail when clicking an item', async () => { @@ -17,8 +17,8 @@ describe('', () => { `); 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('', () => { `); 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'); } diff --git a/src/components/option/option.test.ts b/src/components/option/option.test.ts index f1cf464ebc..11661f40ef 100644 --- a/src/components/option/option.test.ts +++ b/src/components/option/option.test.ts @@ -41,4 +41,14 @@ describe('', () => { expect(slotChangeHandler).to.have.been.calledOnce; }); + + it('should convert non-string values to string', async () => { + const el = await fixture(html` Text `); + + // @ts-expect-error - intentional + el.value = 10; + await el.updateComplete; + + expect(el.value).to.equal('10'); + }); }); diff --git a/src/components/option/option.ts b/src/components/option/option.ts index 9b43e498f7..1cfccd141b 100644 --- a/src/components/option/option.ts +++ b/src/components/option/option.ts @@ -92,6 +92,12 @@ export default class SlOption extends ShoelaceElement { @watch('value') handleValueChange() { + // Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers + // instead of requiring them to cast the value to a string. + if (typeof this.value !== 'string') { + this.value = String(this.value); + } + if (this.value.includes(' ')) { console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this); this.value = this.value.replace(/ /g, '_'); diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts index 4c9f3507c1..abaadbd2eb 100644 --- a/src/components/popup/popup.ts +++ b/src/components/popup/popup.ts @@ -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 diff --git a/src/components/qr-code/qr-code.ts b/src/components/qr-code/qr-code.ts index 1beb1999db..1a9cb6f4f1 100644 --- a/src/components/qr-code/qr-code.ts +++ b/src/components/qr-code/qr-code.ts @@ -72,7 +72,7 @@ export default class SlQrCode extends ShoelaceElement { part="base" class="qr-code" role="img" - aria-label=${this.label.length > 0 ? this.label : this.value} + aria-label=${this.label?.length > 0 ? this.label : this.value} style=${styleMap({ width: `${this.size}px`, height: `${this.size}px` diff --git a/src/components/radio-button/radio-button.ts b/src/components/radio-button/radio-button.ts index 5b1b835eb9..9ee4d896cb 100644 --- a/src/components/radio-button/radio-button.ts +++ b/src/components/radio-button/radio-button.ts @@ -51,7 +51,10 @@ export default class SlRadioButton extends ShoelaceElement { /** Disables the radio button. */ @property({ type: Boolean, reflect: true }) disabled = false; - /** The radio button's size. */ + /** + * The radio button's size. When used inside a radio group, the size will be determined by the radio group's size so + * this attribute can typically be omitted. + */ @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; /** Draws a pill-style radio button with rounded edges. */ diff --git a/src/components/radio-group/radio-group.test.ts b/src/components/radio-group/radio-group.test.ts index a0307b56ba..760eca0bbd 100644 --- a/src/components/radio-group/radio-group.test.ts +++ b/src/components/radio-group/radio-group.test.ts @@ -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'; @@ -150,30 +151,30 @@ describe('', () => { expect(radioGroup.hasAttribute('data-user-invalid')).to.be.false; expect(radioGroup.hasAttribute('data-user-valid')).to.be.false; }); - }); - it('should show a constraint validation error when setCustomValidity() is called', async () => { - const form = await fixture(html` -
- - - - - Submit -
- `); - const button = form.querySelector('sl-button')!; - const radioGroup = form.querySelector('sl-radio-group')!; - const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault()); + it('should show a constraint validation error when setCustomValidity() is called', async () => { + const form = await fixture(html` +
+ + + + + Submit +
+ `); + const button = form.querySelector('sl-button')!; + const radioGroup = form.querySelector('sl-radio-group')!; + const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault()); - // Submitting the form after setting custom validity should not trigger the handler - radioGroup.setCustomValidity('Invalid selection'); - form.addEventListener('submit', submitHandler); - button.click(); + // Submitting the form after setting custom validity should not trigger the handler + radioGroup.setCustomValidity('Invalid selection'); + form.addEventListener('submit', submitHandler); + button.click(); - await aTimeout(100); + await aTimeout(100); - expect(submitHandler).to.not.have.been.called; + expect(submitHandler).to.not.have.been.called; + }); }); }); @@ -251,6 +252,53 @@ describe('when submitting a form', () => { }); }); +describe('when a size is applied', () => { + it('should apply the same size to all radios', async () => { + const radioGroup = await fixture(html` + + + + + `); + const [radio1, radio2] = radioGroup.querySelectorAll('sl-radio')!; + + expect(radio1.size).to.equal('large'); + expect(radio2.size).to.equal('large'); + }); + + it('should apply the same size to all radio buttons', async () => { + const radioGroup = await fixture(html` + + + + + `); + const [radio1, radio2] = radioGroup.querySelectorAll('sl-radio-button')!; + + expect(radio1.size).to.equal('large'); + expect(radio2.size).to.equal('large'); + }); + + it('should update the size of all radio buttons when size changes', async () => { + const radioGroup = await fixture(html` + + + + + `); + const [radio1, radio2] = radioGroup.querySelectorAll('sl-radio-button')!; + + expect(radio1.size).to.equal('small'); + expect(radio2.size).to.equal('small'); + + radioGroup.size = 'large'; + await radioGroup.updateComplete; + + expect(radio1.size).to.equal('large'); + expect(radio2.size).to.equal('large'); + }); +}); + describe('when the value changes', () => { it('should emit sl-change when toggled with the arrow keys', async () => { const radioGroup = await fixture(html` @@ -283,7 +331,7 @@ describe('when the value changes', () => { `); const radio = radioGroup.querySelector('#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 +346,7 @@ describe('when the value changes', () => { const radio = radioGroup.querySelector('#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'); }); diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index bce615e22c..ca6d9a5015 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -71,6 +71,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor /** The current value of the radio group, submitted as a name/value pair with form data. */ @property({ reflect: true }) value = ''; + /** The radio group's size. This size will be applied to all child radios and radio buttons. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; + /** * By default, form controls are associated with the nearest containing `
` element. This attribute allows you * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in @@ -196,42 +199,60 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor } } - private handleSlotChange() { - const radios = this.getAllRadios(); + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitInvalidEvent(event); + } - radios.forEach(radio => (radio.checked = radio.value === this.value)); + private syncRadios() { + if (customElements.get('sl-radio') || customElements.get('sl-radio-button')) { + const radios = this.getAllRadios(); - this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button'); + // Sync the checked state and size + radios.forEach(radio => { + radio.checked = radio.value === this.value; + radio.size = this.size; + }); - if (!radios.some(radio => radio.checked)) { - if (this.hasButtonGroup) { - const buttonRadio = radios[0].shadowRoot!.querySelector('button')!; - buttonRadio.tabIndex = 0; - } else { - radios[0].tabIndex = 0; + this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button'); + + if (!radios.some(radio => radio.checked)) { + if (this.hasButtonGroup) { + const buttonRadio = radios[0].shadowRoot?.querySelector('button'); + + if (buttonRadio) { + buttonRadio.tabIndex = 0; + } + } else { + radios[0].tabIndex = 0; + } } - } - if (this.hasButtonGroup) { - const buttonGroup = this.shadowRoot?.querySelector('sl-button-group'); + if (this.hasButtonGroup) { + const buttonGroup = this.shadowRoot?.querySelector('sl-button-group'); - if (buttonGroup) { - buttonGroup.disableRole = true; + if (buttonGroup) { + buttonGroup.disableRole = true; + } } + } else { + // Rerun this handler when or is registered + customElements.whenDefined('sl-radio').then(() => this.syncRadios()); + customElements.whenDefined('sl-radio-button').then(() => this.syncRadios()); } } - private handleInvalid(event: Event) { - this.formControlController.setValidity(false); - this.formControlController.emitInvalidEvent(event); - } - private updateCheckedRadio() { const radios = this.getAllRadios(); radios.forEach(radio => (radio.checked = radio.value === this.value)); this.formControlController.setValidity(this.validity.valid); } + @watch('size', { waitUntilFirstUpdate: true }) + handleSizeChange() { + this.syncRadios(); + } + @watch('value') handleValueChange() { if (this.hasUpdated) { @@ -252,12 +273,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor return true; } - /** Sets a custom validation message. Pass an empty string to restore validity. */ - setCustomValidity(message = '') { - this.customValidityMessage = message; - this.errorMessage = message; - this.validationInput.setCustomValidity(message); - this.formControlController.updateValidity(); + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); } /** Checks for validity and shows the browser's validation message if the control is invalid. */ @@ -279,6 +297,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'); @@ -289,7 +315,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor `; @@ -299,7 +325,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor part="form-control" class=${classMap({ 'form-control': true, - 'form-control--medium': true, + 'form-control--small': this.size === 'small', + 'form-control--medium': this.size === 'medium', + 'form-control--large': this.size === 'large', 'form-control--radio-group': true, 'form-control--has-label': hasLabel, 'form-control--has-help-text': hasHelpText diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index 2f6633af9e..a410a5f605 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -41,7 +41,10 @@ export default class SlRadio extends ShoelaceElement { /** The radio's value. When selected, the radio group will receive this value. */ @property() value: string; - /** The radio's size. */ + /** + * The radio's size. When used inside a radio group, the size will be determined by the radio group's size so this + * attribute can typically be omitted. + */ @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; /** Disables the radio. */ diff --git a/src/components/range/range.ts b/src/components/range/range.ts index 9e4ed1b526..bb125f1e78 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -1,5 +1,5 @@ import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { customElement, eventOptions, property, query, state } from 'lit/decorators.js'; import { defaultValue } from '../../internal/default-value'; import { FormControlController } from '../../internal/form'; import { HasSlotController } from '../../internal/slot'; @@ -156,6 +156,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont this.emit('sl-focus'); } + @eventOptions({ passive: true }) private handleThumbDragStart() { this.hasTooltip = true; } @@ -254,6 +255,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont return this.input.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.input.reportValidity(); diff --git a/src/components/rating/rating.styles.ts b/src/components/rating/rating.styles.ts index 6e91ddcec7..0caebbf03a 100644 --- a/src/components/rating/rating.styles.ts +++ b/src/components/rating/rating.styles.ts @@ -43,12 +43,19 @@ export default css` padding: var(--symbol-spacing); } - .rating__symbols--indicator { - position: absolute; - top: 0; - left: 0; + .rating__symbol--active, + .rating__partial--filled { color: var(--symbol-color-active); - pointer-events: none; + } + + .rating__partial-symbol-container { + position: relative; + } + + .rating__partial--filled { + position: absolute; + top: var(--symbol-spacing); + left: var(--symbol-spacing); } .rating__symbol { @@ -79,7 +86,7 @@ export default css` /* Forced colors mode */ @media (forced-colors: active) { - .rating__symbols--indicator { + .rating__symbol--active { color: SelectedItem; } } diff --git a/src/components/rating/rating.test.ts b/src/components/rating/rating.test.ts index 9636879269..e73217f791 100644 --- a/src/components/rating/rating.test.ts +++ b/src/components/rating/rating.test.ts @@ -79,6 +79,20 @@ describe('', () => { expect(el.value).to.equal(1); }); + it('should not emit sl-change when disabled', async () => { + const el = await fixture(html` `); + const lastSymbol = el.shadowRoot!.querySelector('.rating__symbol:last-child')!; + const changeHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + + await clickOnElement(lastSymbol); + await el.updateComplete; + + expect(changeHandler).to.not.have.been.called; + expect(el.value).to.equal(5); + }); + it('should not emit sl-change when the value is changed programmatically', async () => { const el = await fixture(html` `); el.addEventListener('sl-change', () => expect.fail('sl-change incorrectly emitted')); diff --git a/src/components/rating/rating.ts b/src/components/rating/rating.ts index c77959bbed..2697ec53ca 100644 --- a/src/components/rating/rating.ts +++ b/src/components/rating/rating.ts @@ -1,7 +1,7 @@ import '../icon/icon'; import { clamp } from '../../internal/math'; import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { customElement, eventOptions, property, query, state } from 'lit/decorators.js'; import { html } from 'lit'; import { LocalizeController } from '../../utilities/localize'; import { styleMap } from 'lit/directives/style-map.js'; @@ -89,6 +89,10 @@ export default class SlRating extends ShoelaceElement { } private handleClick(event: MouseEvent) { + if (this.disabled) { + return; + } + this.setValue(this.getValueFromMousePosition(event)); this.emit('sl-change'); } @@ -159,6 +163,7 @@ export default class SlRating extends ShoelaceElement { event.preventDefault(); } + @eventOptions({ passive: true }) private handleTouchMove(event: TouchEvent) { this.hoverValue = this.getValueFromTouchPosition(event); } @@ -244,43 +249,54 @@ export default class SlRating extends ShoelaceElement { @mousemove=${this.handleMouseMove} @touchmove=${this.handleTouchMove} > - + ${counter.map(index => { - // Users can click the current value to clear the rating. When this happens, we set this.isHovering to - // false to prevent the hover state from confusing them as they move the mouse out of the control. This - // extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol. - return html` - - ${unsafeHTML(this.getSymbol(index + 1))} - - `; - })} - + if (displayValue > index && displayValue < index + 1) { + // Users can click the current value to clear the rating. When this happens, we set this.isHovering to + // false to prevent the hover state from confusing them as they move the mouse out of the control. This + // extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol. + return html` + +
+ ${unsafeHTML(this.getSymbol(index + 1))} +
+
+ ${unsafeHTML(this.getSymbol(index + 1))} +
+
+ `; + } - - ${counter.map(index => { return html` index + 1 - ? 'none' - : isRtl - ? `inset(0 0 0 ${100 - ((displayValue - index) / 1) * 100}%)` - : `inset(0 ${100 - ((displayValue - index) / 1) * 100}% 0 0)` + 'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1, + 'rating__symbol--active': displayValue >= index + 1 })} role="presentation" + @mouseenter=${this.handleMouseEnter} > ${unsafeHTML(this.getSymbol(index + 1))} diff --git a/src/components/select/select.styles.ts b/src/components/select/select.styles.ts index fc99fa6717..51aae93edb 100644 --- a/src/components/select/select.styles.ts +++ b/src/components/select/select.styles.ts @@ -85,6 +85,8 @@ export default css` .select__value-input { position: absolute; + top: 0; + left: 0; width: 100%; height: 100%; padding: 0; diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index 099ded92d6..c8aebbd320 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -8,15 +8,31 @@ import type SlOption from '../option/option'; import type SlSelect from './select'; describe('', () => { - it('should pass accessibility tests', async () => { - const el = await fixture(html` - - Option 1 - Option 2 - Option 3 - - `); - await expect(el).to.be.accessible(); + describe('accessibility', () => { + it('should pass accessibility tests when closed', async () => { + const select = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + await expect(select).to.be.accessible(); + }); + + it('should pass accessibility tests when open', async () => { + const select = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + + await select.show(); + + await expect(select).to.be.accessible(); + }); }); it('should be disabled with the disabled attribute', async () => { @@ -162,6 +178,32 @@ describe('', () => { 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(html` + + Option 1 + Option 2 + Option 3 + + `); + 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 () => { diff --git a/src/components/select/select.ts b/src/components/select/select.ts index 23a0f7c4c3..dc17237951 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -19,6 +19,7 @@ import type { CSSResultGroup } from 'lit'; import type { ShoelaceFormControl } from '../../internal/shoelace-element'; import type SlOption from '../option/option'; import type SlPopup from '../popup/popup'; +import type SlRemoveEvent from '../../events/sl-remove'; /** * @summary Selects allow you to choose items from a menu of predefined options. @@ -58,6 +59,10 @@ import type SlPopup from '../popup/popup'; * @csspart listbox - The listbox container where options are slotted. * @csspart tags - The container that houses option tags when `multiselect` is used. * @csspart tag - The individual tags that represent each multiselect option. + * @csspart tag__base - The tag's base part. + * @csspart tag__content - The tag's content part. + * @csspart tag__remove-button - The tag's remove button. + * @csspart tag__remove-button__base - The tag's remove button base part. * @csspart clear-button - The clear button. * @csspart expand-icon - The container that wraps the expand icon. */ @@ -252,8 +257,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon this.setSelectedOptions(this.currentOption); } - this.emit('sl-input'); - this.emit('sl-change'); + // Emit after updating + this.updateComplete.then(() => { + this.emit('sl-input'); + this.emit('sl-change'); + }); if (!this.multiple) { this.hide(); @@ -377,9 +385,13 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon if (this.value !== '') { this.setSelectedOptions([]); this.displayInput.focus({ preventScroll: true }); - this.emit('sl-clear'); - this.emit('sl-input'); - this.emit('sl-change'); + + // Emit after update + this.updateComplete.then(() => { + this.emit('sl-clear'); + this.emit('sl-input'); + this.emit('sl-change'); + }); } } @@ -405,8 +417,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true })); if (this.value !== oldValue) { - this.emit('sl-input'); - this.emit('sl-change'); + // Emit after updating + this.updateComplete.then(() => { + this.emit('sl-input'); + this.emit('sl-change'); + }); } if (!this.multiple) { @@ -422,27 +437,28 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon const values: string[] = []; // Check for duplicate values in menu items - allOptions.forEach(option => { - if (values.includes(option.value)) { - console.error( - `An option with a duplicate value of "${option.value}" has been found in . All options must have unique values.`, - option - ); - } - values.push(option.value); - }); + if (customElements.get('sl-option')) { + allOptions.forEach(option => values.push(option.value)); - // Select only the options that match the new value - this.setSelectedOptions(allOptions.filter(el => value.includes(el.value))); + // Select only the options that match the new value + this.setSelectedOptions(allOptions.filter(el => value.includes(el.value))); + } else { + // Rerun this handler when is registered + customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange()); + } } - private handleTagRemove(event: CustomEvent, option: SlOption) { + private handleTagRemove(event: SlRemoveEvent, option: SlOption) { event.stopPropagation(); if (!this.disabled) { this.toggleOptionSelection(option, false); - this.emit('sl-input'); - this.emit('sl-change'); + + // Emit after updating + this.updateComplete.then(() => { + this.emit('sl-input'); + this.emit('sl-change'); + }); } } @@ -624,6 +640,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon return this.valueInput.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.valueInput.reportValidity(); @@ -741,10 +762,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon return html` this.handleTagRemove(event, option)} + @sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)} > ${option.getTextLabel()} @@ -794,7 +821,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon - + > + + diff --git a/src/components/spinner/spinner.styles.ts b/src/components/spinner/spinner.styles.ts index 21e474af24..22932b2628 100644 --- a/src/components/spinner/spinner.styles.ts +++ b/src/components/spinner/spinner.styles.ts @@ -34,7 +34,6 @@ export default css` .spinner__track { stroke: var(--track-color); transform-origin: 0% 0%; - mix-blend-mode: multiply; } .spinner__indicator { diff --git a/src/components/split-panel/split-panel.test.ts b/src/components/split-panel/split-panel.test.ts index 3798bce6df..e615088607 100644 --- a/src/components/split-panel/split-panel.test.ts +++ b/src/components/split-panel/split-panel.test.ts @@ -2,8 +2,17 @@ import { expect, fixture, html } from '@open-wc/testing'; describe('', () => { it('should render a component', async () => { - const el = await fixture(html` `); + const splitPanel = await fixture(html` `); - expect(el).to.exist; + expect(splitPanel).to.exist; + }); + + it('should be accessible', async () => { + const splitPanel = await fixture(html` +
Start
+
End
+
`); + + await expect(splitPanel).to.be.accessible(); }); }); diff --git a/src/components/split-panel/split-panel.ts b/src/components/split-panel/split-panel.ts index 6e0abb4858..a44d8c92eb 100644 --- a/src/components/split-panel/split-panel.ts +++ b/src/components/split-panel/split-panel.ts @@ -249,17 +249,21 @@ export default class SlSplitPanel extends ShoelaceElement { return html` - + > + + `; diff --git a/src/components/switch/switch.ts b/src/components/switch/switch.ts index 0a3dbaf995..06ddfafe16 100644 --- a/src/components/switch/switch.ts +++ b/src/components/switch/switch.ts @@ -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(); diff --git a/src/components/tab-group/tab-group.test.ts b/src/components/tab-group/tab-group.test.ts index aef6b15187..215928e8f1 100644 --- a/src/components/tab-group/tab-group.test.ts +++ b/src/components/tab-group/tab-group.test.ts @@ -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 => { 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, expectedName: string) => { +const expectPromiseToHaveName = async (showEventPromise: Promise, 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 => { @@ -306,7 +303,7 @@ describe('', () => { const customHeader = queryByTestId(tabGroup, 'custom-header'); expect(customHeader).not.to.have.attribute('active'); - const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise; + const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise; await action(); expect(customHeader).to.have.attribute('active'); @@ -405,7 +402,7 @@ describe('', () => { const customHeader = queryByTestId(tabGroup, 'custom-header'); expect(customHeader).not.to.have.attribute('active'); - const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise; + const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise; await sendKeys({ press: 'ArrowRight' }); await aTimeout(0); expect(generalHeader).to.have.attribute('active'); diff --git a/src/components/tab-group/tab-group.ts b/src/components/tab-group/tab-group.ts index 85152cd9af..9bf1e47511 100644 --- a/src/components/tab-group/tab-group.ts +++ b/src/components/tab-group/tab-group.ts @@ -70,6 +70,11 @@ export default class SlTabGroup extends ShoelaceElement { @property({ attribute: 'no-scroll-controls', type: Boolean }) noScrollControls = false; connectedCallback() { + const whenAllDefined = Promise.allSettled([ + customElements.whenDefined('sl-tab'), + customElements.whenDefined('sl-tab-panel') + ]); + super.connectedCallback(); this.resizeObserver = new ResizeObserver(() => { @@ -89,20 +94,24 @@ export default class SlTabGroup extends ShoelaceElement { } }); + // After the first update... this.updateComplete.then(() => { this.syncTabsAndPanels(); this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true }); this.resizeObserver.observe(this.nav); - // Set initial tab state when the tabs first become visible - const intersectionObserver = new IntersectionObserver((entries, observer) => { - if (entries[0].intersectionRatio > 0) { - this.setAriaLabels(); - this.setActiveTab(this.getActiveTab() ?? this.tabs[0], { emitEvents: false }); - observer.unobserve(entries[0].target); - } + // Wait for tabs and tab panels to be registered + whenAllDefined.then(() => { + // Set initial tab state when the tabs become visible + const intersectionObserver = new IntersectionObserver((entries, observer) => { + if (entries[0].intersectionRatio > 0) { + this.setAriaLabels(); + this.setActiveTab(this.getActiveTab() ?? this.tabs[0], { emitEvents: false }); + observer.unobserve(entries[0].target); + } + }); + intersectionObserver.observe(this.tabGroup); }); - intersectionObserver.observe(this.tabGroup); }); } @@ -316,6 +325,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 }) diff --git a/src/components/tab/tab.test.ts b/src/components/tab/tab.test.ts index 479de2df37..fa277344b9 100644 --- a/src/components/tab/tab.test.ts +++ b/src/components/tab/tab.test.ts @@ -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('', () => { it('passes accessibility test', async () => { @@ -88,17 +90,31 @@ describe('', () => { }); describe('closable', () => { - it('should emit close event when close button clicked', async () => { - const el = await fixture(html` Test `); - - const closeButton = el.shadowRoot!.querySelector('[part~="close-button"]')!; - const spy = sinon.spy(); - - el.addEventListener('sl-close', spy, { once: true }); + it('should emit close event when the close button is clicked', async () => { + const tabGroup = await fixture(html` + + General + Custom + This is the general tab panel. + This is the custom tab panel. + + `); + const closeButton = tabGroup + .querySelectorAll('sl-tab')[0]! + .shadowRoot!.querySelector('[part~="close-button"]')!; + + const handleClose = sinon.spy(); + const handleTabShow = sinon.spy(); + + 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); }); }); }); diff --git a/src/components/tab/tab.ts b/src/components/tab/tab.ts index 961d0c7358..9db04c5411 100644 --- a/src/components/tab/tab.ts +++ b/src/components/tab/tab.ts @@ -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'); } diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index d0fb1b3fe7..20ed4e8a4b 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -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(); diff --git a/src/components/tree-item/tree-item.styles.ts b/src/components/tree-item/tree-item.styles.ts index b7018c1648..b9ad162a24 100644 --- a/src/components/tree-item/tree-item.styles.ts +++ b/src/components/tree-item/tree-item.styles.ts @@ -26,7 +26,6 @@ export default css` color: var(--sl-color-neutral-700); cursor: pointer; user-select: none; - white-space: nowrap; } .tree-item__checkbox { @@ -39,7 +38,7 @@ export default css` font-family: var(--sl-font-sans); font-size: var(--sl-font-size-medium); font-weight: var(--sl-font-weight-normal); - line-height: var(--sl-line-height-normal); + line-height: var(--sl-line-height-dense); letter-spacing: var(--sl-letter-spacing-normal); } @@ -63,6 +62,7 @@ export default css` padding: var(--sl-spacing-x-small); width: 1rem; height: 1rem; + flex-shrink: 0; cursor: pointer; } diff --git a/src/components/tree-item/tree-item.ts b/src/components/tree-item/tree-item.ts index 25eabeea44..b00c968143 100644 --- a/src/components/tree-item/tree-item.ts +++ b/src/components/tree-item/tree-item.ts @@ -47,6 +47,14 @@ import type { CSSResultGroup, PropertyValueMap } from 'lit'; * @csspart expand-button - The container that wraps the tree item's expand button and spinner. * @csspart label - The tree item's label. * @csspart children - The container that wraps the tree item's nested children. + * @csspart checkbox - The checkbox that shows when using multiselect. + * @csspart checkbox__base - The checkbox's exported `base` part. + * @csspart checkbox__control - The checkbox's exported `control` part. + * @csspart checkbox__control--checked - The checkbox's exported `control--checked` part. + * @csspart checkbox__control--indeterminate - The checkbox's exported `control--indeterminate` part. + * @csspart checkbox__checked-icon - The checkbox's exported `checked-icon` part. + * @csspart checkbox__indeterminate-icon - The checkbox's exported `indeterminate-icon` part. + * @csspart checkbox__label - The checkbox's exported `label` part. */ @customElement('sl-tree-item') export default class SlTreeItem extends ShoelaceElement { @@ -258,17 +266,26 @@ export default class SlTreeItem extends ShoelaceElement { () => html` - - - `, - () => html` ` + tabindex="-1" + > + ` )} + + ', () => { const expandButton: HTMLElement = node.shadowRoot!.querySelector('.tree-item__expand-button')!; // Act - expandButton.click(); + await clickOnElement(expandButton); await el.updateComplete; // Assert @@ -453,10 +454,10 @@ describe('', () => { await el.updateComplete; // Act - node0.click(); + await clickOnElement(node0); await el.updateComplete; - node1.click(); + await clickOnElement(node1); await el.updateComplete; // Assert @@ -474,10 +475,10 @@ describe('', () => { await el.updateComplete; // Act - node0.click(); + await clickOnElement(node0); await el.updateComplete; - node1.click(); + await clickOnElement(node1); await el.updateComplete; // Assert @@ -492,7 +493,7 @@ describe('', () => { await el.updateComplete; // Act - parentNode.click(); + await clickOnElement(parentNode); await parentNode.updateComplete; // Assert @@ -511,10 +512,10 @@ describe('', () => { await el.updateComplete; // Act - node0.click(); + await clickOnElement(node0); await el.updateComplete; - node1.click(); + await clickOnElement(node1); await el.updateComplete; // Assert @@ -529,7 +530,7 @@ describe('', () => { const parentNode = el.children[2] as SlTreeItem; // Act - parentNode.click(); + await clickOnElement(parentNode); await el.updateComplete; // Assert @@ -549,7 +550,10 @@ describe('', () => { const childNode = parentNode.children[0] as SlTreeItem; // Act - childNode.click(); + parentNode.expanded = true; + await parentNode.updateComplete; + await aTimeout(300); + await clickOnElement(childNode); await el.updateComplete; // Assert @@ -572,9 +576,9 @@ describe('', () => { const node = el.children[0] as SlTreeItem; // Act - node.click(); + await clickOnElement(node); await el.updateComplete; - node.click(); + await clickOnElement(node); await Promise.all([node.updateComplete, el.updateComplete]); // Assert @@ -598,9 +602,9 @@ describe('', () => { const node = el.children[0] as SlTreeItem; // Act - node.click(); + await clickOnElement(node); await el.updateComplete; - node.click(); + await clickOnElement(node); await Promise.all([node.updateComplete, el.updateComplete]); // Assert @@ -621,7 +625,7 @@ describe('', () => { const node = el.querySelector('#expandable')!; // Act - node.click(); + await clickOnElement(node); await Promise.all([node.updateComplete, el.updateComplete]); // Assert @@ -643,9 +647,9 @@ describe('', () => { const node = el.children[0] as SlTreeItem; // Act - node.click(); + await clickOnElement(node); await Promise.all([node.updateComplete, el.updateComplete]); - node.click(); + await clickOnElement(node); await Promise.all([node.updateComplete, el.updateComplete]); // Assert diff --git a/src/components/tree/tree.ts b/src/components/tree/tree.ts index 8fac04a7d2..22f9c3ebf1 100644 --- a/src/components/tree/tree.ts +++ b/src/components/tree/tree.ts @@ -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 ``. @@ -90,6 +90,7 @@ export default class SlTree extends ShoelaceElement { private lastFocusedItem: SlTreeItem; private readonly localize = new LocalizeController(this); private mutationObserver: MutationObserver; + private clickTarget: SlTreeItem | null = null; async connectedCallback() { super.connectedCallback(); @@ -292,13 +293,20 @@ export default class SlTree extends ShoelaceElement { } private handleClick(event: Event) { - const target = event.target as HTMLElement; + const target = event.target as SlTreeItem; const treeItem = target.closest('sl-tree-item')!; const isExpandButton = event .composedPath() .some((el: HTMLElement) => el?.classList?.contains('tree-item__expand-button')); - if (!treeItem || treeItem.disabled) { + // + // Don't Do anything if there's no tree item, if it's disabled, or if the click doesn't match the initial target + // from mousedown. The latter case prevents the user from starting a click on one item and ending it on another, + // causing the parent node to collapse. + // + // See https://github.com/shoelace-style/shoelace/issues/1082 + // + if (!treeItem || treeItem.disabled || target !== this.clickTarget) { return; } @@ -309,6 +317,11 @@ export default class SlTree extends ShoelaceElement { } } + handleMouseDown(event: MouseEvent) { + // Record the click target so we know which item the click initially targeted + this.clickTarget = event.target as SlTreeItem; + } + private handleFocusOut(event: FocusEvent) { const relatedTarget = event.relatedTarget as HTMLElement; @@ -392,7 +405,13 @@ export default class SlTree extends ShoelaceElement { render() { return html` -
+
diff --git a/src/events/events.ts b/src/events/events.ts new file mode 100644 index 0000000000..03dc0d86da --- /dev/null +++ b/src/events/events.ts @@ -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'; diff --git a/src/events/sl-after-collapse.ts b/src/events/sl-after-collapse.ts new file mode 100644 index 0000000000..c3ddc7d163 --- /dev/null +++ b/src/events/sl-after-collapse.ts @@ -0,0 +1,9 @@ +type SlAfterCollapseEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-after-collapse': SlAfterCollapseEvent; + } +} + +export default SlAfterCollapseEvent; diff --git a/src/events/sl-after-expand.ts b/src/events/sl-after-expand.ts new file mode 100644 index 0000000000..91be5690ca --- /dev/null +++ b/src/events/sl-after-expand.ts @@ -0,0 +1,9 @@ +type SlAfterExpandEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-after-expand': SlAfterExpandEvent; + } +} + +export default SlAfterExpandEvent; diff --git a/src/events/sl-after-hide.ts b/src/events/sl-after-hide.ts new file mode 100644 index 0000000000..e507a3b970 --- /dev/null +++ b/src/events/sl-after-hide.ts @@ -0,0 +1,9 @@ +type SlAfterHideEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-after-hide': SlAfterHideEvent; + } +} + +export default SlAfterHideEvent; diff --git a/src/events/sl-after-show.ts b/src/events/sl-after-show.ts new file mode 100644 index 0000000000..9bc1813d57 --- /dev/null +++ b/src/events/sl-after-show.ts @@ -0,0 +1,9 @@ +type SlAfterShowEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-after-show': SlAfterShowEvent; + } +} + +export default SlAfterShowEvent; diff --git a/src/events/sl-blur.ts b/src/events/sl-blur.ts new file mode 100644 index 0000000000..e042cad99f --- /dev/null +++ b/src/events/sl-blur.ts @@ -0,0 +1,9 @@ +type SlBlurEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-blur': SlBlurEvent; + } +} + +export default SlBlurEvent; diff --git a/src/events/sl-cancel.ts b/src/events/sl-cancel.ts new file mode 100644 index 0000000000..8df4ef3dbc --- /dev/null +++ b/src/events/sl-cancel.ts @@ -0,0 +1,9 @@ +type SlCancelEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-cancel': SlCancelEvent; + } +} + +export default SlCancelEvent; diff --git a/src/events/sl-change.ts b/src/events/sl-change.ts new file mode 100644 index 0000000000..b3352c5616 --- /dev/null +++ b/src/events/sl-change.ts @@ -0,0 +1,9 @@ +type SlChangeEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-change': SlChangeEvent; + } +} + +export default SlChangeEvent; diff --git a/src/events/sl-clear.ts b/src/events/sl-clear.ts new file mode 100644 index 0000000000..17a0d6dcaf --- /dev/null +++ b/src/events/sl-clear.ts @@ -0,0 +1,9 @@ +type SlClearEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-clear': SlClearEvent; + } +} + +export default SlClearEvent; diff --git a/src/events/sl-close.ts b/src/events/sl-close.ts new file mode 100644 index 0000000000..5f72ea4396 --- /dev/null +++ b/src/events/sl-close.ts @@ -0,0 +1,9 @@ +type SlCloseEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-close': SlCloseEvent; + } +} + +export default SlCloseEvent; diff --git a/src/events/sl-collapse.ts b/src/events/sl-collapse.ts new file mode 100644 index 0000000000..194ec3b275 --- /dev/null +++ b/src/events/sl-collapse.ts @@ -0,0 +1,9 @@ +type SlCollapseEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-collapse': SlCollapseEvent; + } +} + +export default SlCollapseEvent; diff --git a/src/events/sl-error.ts b/src/events/sl-error.ts new file mode 100644 index 0000000000..c227504e02 --- /dev/null +++ b/src/events/sl-error.ts @@ -0,0 +1,9 @@ +type SlErrorEvent = CustomEvent<{ status?: number }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-error': SlErrorEvent; + } +} + +export default SlErrorEvent; diff --git a/src/events/sl-expand.ts b/src/events/sl-expand.ts new file mode 100644 index 0000000000..57b823a9bd --- /dev/null +++ b/src/events/sl-expand.ts @@ -0,0 +1,9 @@ +type SlExpandEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-expand': SlExpandEvent; + } +} + +export default SlExpandEvent; diff --git a/src/events/sl-finish.ts b/src/events/sl-finish.ts new file mode 100644 index 0000000000..91c5788cb9 --- /dev/null +++ b/src/events/sl-finish.ts @@ -0,0 +1,9 @@ +type SlFinishEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-finish': SlFinishEvent; + } +} + +export default SlFinishEvent; diff --git a/src/events/sl-focus.ts b/src/events/sl-focus.ts new file mode 100644 index 0000000000..438be58ed7 --- /dev/null +++ b/src/events/sl-focus.ts @@ -0,0 +1,9 @@ +type SlFocusEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-focus': SlFocusEvent; + } +} + +export default SlFocusEvent; diff --git a/src/events/sl-hide.ts b/src/events/sl-hide.ts new file mode 100644 index 0000000000..36b2f27173 --- /dev/null +++ b/src/events/sl-hide.ts @@ -0,0 +1,9 @@ +type SlHideEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-hide': SlHideEvent; + } +} + +export default SlHideEvent; diff --git a/src/events/sl-hover.ts b/src/events/sl-hover.ts new file mode 100644 index 0000000000..289f77b61c --- /dev/null +++ b/src/events/sl-hover.ts @@ -0,0 +1,12 @@ +type SlHoverEvent = CustomEvent<{ + phase: 'start' | 'move' | 'end'; + value: number; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-hover': SlHoverEvent; + } +} + +export default SlHoverEvent; diff --git a/src/events/sl-initial-focus.ts b/src/events/sl-initial-focus.ts new file mode 100644 index 0000000000..587e74cebd --- /dev/null +++ b/src/events/sl-initial-focus.ts @@ -0,0 +1,9 @@ +type SlInitialFocusEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-initial-focus': SlInitialFocusEvent; + } +} + +export default SlInitialFocusEvent; diff --git a/src/events/sl-input.ts b/src/events/sl-input.ts new file mode 100644 index 0000000000..98f4bed562 --- /dev/null +++ b/src/events/sl-input.ts @@ -0,0 +1,9 @@ +type SlInputEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-input': SlInputEvent; + } +} + +export default SlInputEvent; diff --git a/src/events/sl-invalid.ts b/src/events/sl-invalid.ts new file mode 100644 index 0000000000..33ccbbb242 --- /dev/null +++ b/src/events/sl-invalid.ts @@ -0,0 +1,9 @@ +type SlInvalidEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-invalid': SlInvalidEvent; + } +} + +export default SlInvalidEvent; diff --git a/src/events/sl-lazy-change.ts b/src/events/sl-lazy-change.ts new file mode 100644 index 0000000000..5cf215802e --- /dev/null +++ b/src/events/sl-lazy-change.ts @@ -0,0 +1,9 @@ +type SlLazyChangeEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-lazy-change': SlLazyChangeEvent; + } +} + +export default SlLazyChangeEvent; diff --git a/src/events/sl-lazy-load.ts b/src/events/sl-lazy-load.ts new file mode 100644 index 0000000000..2acfe34155 --- /dev/null +++ b/src/events/sl-lazy-load.ts @@ -0,0 +1,9 @@ +type SlLazyLoadEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-lazy-load': SlLazyLoadEvent; + } +} + +export default SlLazyLoadEvent; diff --git a/src/events/sl-load.ts b/src/events/sl-load.ts new file mode 100644 index 0000000000..4d5f76eef2 --- /dev/null +++ b/src/events/sl-load.ts @@ -0,0 +1,9 @@ +type SlLoadEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-load': SlLoadEvent; + } +} + +export default SlLoadEvent; diff --git a/src/events/sl-mutation.ts b/src/events/sl-mutation.ts new file mode 100644 index 0000000000..f9b80d0ad1 --- /dev/null +++ b/src/events/sl-mutation.ts @@ -0,0 +1,9 @@ +type SlMutationEvent = CustomEvent<{ mutationList: MutationRecord[] }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-mutation': SlMutationEvent; + } +} + +export default SlMutationEvent; diff --git a/src/events/sl-remove.ts b/src/events/sl-remove.ts new file mode 100644 index 0000000000..d135fd237f --- /dev/null +++ b/src/events/sl-remove.ts @@ -0,0 +1,9 @@ +type SlRemoveEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-remove': SlRemoveEvent; + } +} + +export default SlRemoveEvent; diff --git a/src/events/sl-reposition.ts b/src/events/sl-reposition.ts new file mode 100644 index 0000000000..25d60c5454 --- /dev/null +++ b/src/events/sl-reposition.ts @@ -0,0 +1,9 @@ +type SlRepositionEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-reposition': SlRepositionEvent; + } +} + +export default SlRepositionEvent; diff --git a/src/events/sl-request-close.ts b/src/events/sl-request-close.ts new file mode 100644 index 0000000000..c52c391a86 --- /dev/null +++ b/src/events/sl-request-close.ts @@ -0,0 +1,9 @@ +type SlRequestCloseEvent = CustomEvent<{ source: 'close-button' | 'keyboard' | 'overlay' }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-request-close': SlRequestCloseEvent; + } +} + +export default SlRequestCloseEvent; diff --git a/src/events/sl-resize.ts b/src/events/sl-resize.ts new file mode 100644 index 0000000000..a4fc9be3b0 --- /dev/null +++ b/src/events/sl-resize.ts @@ -0,0 +1,9 @@ +type SlResizeEvent = CustomEvent<{ entries: ResizeObserverEntry[] }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-resize': SlResizeEvent; + } +} + +export default SlResizeEvent; diff --git a/src/events/sl-select.ts b/src/events/sl-select.ts new file mode 100644 index 0000000000..cfed91c851 --- /dev/null +++ b/src/events/sl-select.ts @@ -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; diff --git a/src/events/sl-selection-change.ts b/src/events/sl-selection-change.ts new file mode 100644 index 0000000000..07920c5e9a --- /dev/null +++ b/src/events/sl-selection-change.ts @@ -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; diff --git a/src/events/sl-show.ts b/src/events/sl-show.ts new file mode 100644 index 0000000000..641fa0616b --- /dev/null +++ b/src/events/sl-show.ts @@ -0,0 +1,9 @@ +type SlShowEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-show': SlShowEvent; + } +} + +export default SlShowEvent; diff --git a/src/events/sl-slide-change.ts b/src/events/sl-slide-change.ts new file mode 100644 index 0000000000..cbbc2902a5 --- /dev/null +++ b/src/events/sl-slide-change.ts @@ -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; diff --git a/src/events/sl-start.ts b/src/events/sl-start.ts new file mode 100644 index 0000000000..6f6f81de57 --- /dev/null +++ b/src/events/sl-start.ts @@ -0,0 +1,9 @@ +type SlStartEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-start': SlStartEvent; + } +} + +export default SlStartEvent; diff --git a/src/events/sl-tab-hide.ts b/src/events/sl-tab-hide.ts new file mode 100644 index 0000000000..16c6a0b5d1 --- /dev/null +++ b/src/events/sl-tab-hide.ts @@ -0,0 +1,9 @@ +type SlTabHideEvent = CustomEvent<{ name: string }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-tab-hide': SlTabHideEvent; + } +} + +export default SlTabHideEvent; diff --git a/src/events/sl-tab-show.ts b/src/events/sl-tab-show.ts new file mode 100644 index 0000000000..59920eaaca --- /dev/null +++ b/src/events/sl-tab-show.ts @@ -0,0 +1,9 @@ +type SlTabShowEvent = CustomEvent<{ name: string }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-tab-show': SlTabShowEvent; + } +} + +export default SlTabShowEvent; diff --git a/src/internal/debounce.ts b/src/internal/debounce.ts new file mode 100644 index 0000000000..4646b3d469 --- /dev/null +++ b/src/internal/debounce.ts @@ -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 (_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, ...args: Parameters) { + clearTimeout(this[TIMER_ID_KEY]); + + this[TIMER_ID_KEY] = window.setTimeout(() => { + fn.apply(this, args); + }, delay); + }; + }; +}; diff --git a/src/internal/form.ts b/src/internal/form.ts index b5892c80c3..79c76b9af5 100644 --- a/src/internal/form.ts +++ b/src/internal/form.ts @@ -19,7 +19,7 @@ const reportValidityOverloads: WeakMap boolean> = new Wea // We store a Set of controls that users have interacted with. This allows us to determine the interaction state // without littering the DOM with additional data attributes. // -const userInteractedControls: Set = new Set(); +const userInteractedControls: WeakSet = new WeakSet(); // // We store a WeakMap of interactions for each form control so we can track when all conditions are met for validation. @@ -271,7 +271,7 @@ export class FormControlController implements ReactiveController { el.requestUpdate(); } - private doAction(type: 'submit' | 'reset', invoker?: HTMLInputElement | SlButton) { + private doAction(type: 'submit' | 'reset', submitter?: HTMLInputElement | SlButton) { if (this.form) { const button = document.createElement('button'); button.type = type; @@ -283,13 +283,13 @@ export class FormControlController implements ReactiveController { button.style.whiteSpace = 'nowrap'; // Pass name, value, and form attributes through to the temporary button - if (invoker) { - button.name = invoker.name; - button.value = invoker.value; + if (submitter) { + button.name = submitter.name; + button.value = submitter.value; ['formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget'].forEach(attr => { - if (invoker.hasAttribute(attr)) { - button.setAttribute(attr, invoker.getAttribute(attr)!); + if (submitter.hasAttribute(attr)) { + button.setAttribute(attr, submitter.getAttribute(attr)!); } }); } @@ -306,15 +306,15 @@ export class FormControlController implements ReactiveController { } /** Resets the form, restoring all the control to their default value */ - reset(invoker?: HTMLInputElement | SlButton) { - this.doAction('reset', invoker); + reset(submitter?: HTMLInputElement | SlButton) { + this.doAction('reset', submitter); } /** Submits the form, triggering validation and form data injection. */ - submit(invoker?: HTMLInputElement | SlButton) { + submit(submitter?: HTMLInputElement | SlButton) { // Calling form.submit() bypasses the submit event and constraint validation. To prevent this, we can inject a // native submit button into the form, "click" it, then remove it to simulate a standard form submission. - this.doAction('submit', invoker); + this.doAction('submit', submitter); } /** @@ -357,10 +357,11 @@ export class FormControlController implements ReactiveController { * event will be cancelled before being dispatched. */ emitInvalidEvent(originalInvalidEvent?: Event) { - const slInvalidEvent = new CustomEvent('sl-invalid', { + const slInvalidEvent = new CustomEvent>('sl-invalid', { bubbles: false, composed: false, - cancelable: true + cancelable: true, + detail: {} }); if (!originalInvalidEvent) { diff --git a/src/internal/scroll.ts b/src/internal/scroll.ts index 02dee14e30..9a1e299cff 100644 --- a/src/internal/scroll.ts +++ b/src/internal/scroll.ts @@ -32,7 +32,7 @@ export function unlockBodyScrolling(lockingEl: HTMLElement) { if (locks.size === 0) { document.body.classList.remove('sl-scroll-lock'); - document.body.style.removeProperty('--sl-scrollbar-width'); + document.body.style.removeProperty('--sl-scroll-lock-size'); } } diff --git a/src/internal/shoelace-element.ts b/src/internal/shoelace-element.ts index b4cac07ebe..962dcef98d 100644 --- a/src/internal/shoelace-element.ts +++ b/src/internal/shoelace-element.ts @@ -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 extends keyof GlobalEventHandlersEventMap + ? // ...where the event detail is an object... + GlobalEventHandlersEventMap[T] extends CustomEvent> + ? // ...that is non-empty... + GlobalEventHandlersEventMap[T] extends CustomEvent> + ? never + : // ...and has at least one non-optional property + Partial extends GlobalEventHandlersEventMap[T]['detail'] + ? never + : T + : never + : never; + +// The inverse of the above (match any type that doesn't match EventTypeRequiresDetail) +type EventTypeDoesNotRequireDetail = T extends keyof GlobalEventHandlersEventMap + ? GlobalEventHandlersEventMap[T] extends CustomEvent> + ? GlobalEventHandlersEventMap[T] extends CustomEvent> + ? T + : Partial 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]: true; +}; + +// `keyof EventTypesWithoutRequiredDetail` lists all registered event types that do NOT require detail +type EventTypesWithoutRequiredDetail = { + [EventType in keyof GlobalEventHandlersEventMap as EventTypeDoesNotRequireDetail]: true; +}; + +// Helper to make a specific property of an object non-optional +type WithRequired = 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 extends keyof GlobalEventHandlersEventMap + ? GlobalEventHandlersEventMap[T] extends CustomEvent> + ? GlobalEventHandlersEventMap[T] extends CustomEvent> + ? CustomEventInit + : Partial extends GlobalEventHandlersEventMap[T]['detail'] + ? CustomEventInit + : WithRequired, 'detail'> + : CustomEventInit + : CustomEventInit; + +// Given an event name string, get the type of the event +type GetCustomEventType = T extends keyof GlobalEventHandlersEventMap + ? GlobalEventHandlersEventMap[T] extends CustomEvent + ? GlobalEventHandlersEventMap[T] + : CustomEvent + : CustomEvent; + +// `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( + name: EventTypeDoesNotRequireDetail, + options?: SlEventInit | undefined + ): GetCustomEventType; + emit( + name: EventTypeRequiresDetail, + options: SlEventInit + ): GetCustomEventType; + emit( + name: T, + options?: SlEventInit | undefined + ): GetCustomEventType { 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; } } export interface ShoelaceFormControl extends ShoelaceElement { - // Standard form attributes + // Form attributes name: string; value: unknown; disabled?: boolean; @@ -31,22 +103,22 @@ export interface ShoelaceFormControl extends ShoelaceElement { defaultChecked?: boolean; form?: string; - // Standard validation attributes + // Constraint validation attributes pattern?: string; - min?: number | Date; - max?: number | Date; + min?: number | string | Date; + max?: number | string | Date; step?: number | 'any'; required?: boolean; minlength?: number; 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; } diff --git a/src/internal/test.ts b/src/internal/test.ts index e4dc703c7c..a1f10dad84 100644 --- a/src/internal/test.ts +++ b/src/internal/test.ts @@ -50,6 +50,7 @@ export async function clickOnElement( await sendMouse({ type: 'click', position: [clickX, clickY] }); } +/** A testing utility that moves the mouse onto an element. */ export async function moveMouseOnElement( /** The element to click */ el: Element, diff --git a/src/shoelace-autoloader.ts b/src/shoelace-autoloader.ts new file mode 100644 index 0000000000..d5de39baf2 --- /dev/null +++ b/src/shoelace-autoloader.ts @@ -0,0 +1,58 @@ +import { getBasePath } from './utilities/base-path'; + +const observer = new MutationObserver(mutations => { + for (const { addedNodes } of mutations) { + for (const node of addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + discover(node as Element); + } + } + } +}); + +/** + * Checks a node for undefined elements and attempts to register them. + */ +export async function discover(root: Element | ShadowRoot) { + const rootTagName = root instanceof Element ? root.tagName.toLowerCase() : ''; + const rootIsCustomElement = rootTagName?.includes('-'); + const tags = [...root.querySelectorAll(':not(:defined)')] + .map(el => el.tagName.toLowerCase()) + .filter(tag => tag.startsWith('sl-')); + + // If the root element is an undefined custom element, add it to the list + if (rootIsCustomElement && !customElements.get(rootTagName)) { + tags.push(rootTagName); + } + + // Make the list unique + const tagsToRegister = [...new Set(tags)]; + + await Promise.allSettled(tagsToRegister.map(tagName => register(tagName))); +} + +/** + * Registers an element by tag name. + */ +function register(tagName: string): Promise { + const tagWithoutPrefix = tagName.replace(/^sl-/i, ''); + const path = getBasePath(`components/${tagWithoutPrefix}/${tagWithoutPrefix}.js`); + + // If the element is already defined, there's nothing more to do + if (customElements.get(tagName)) { + return Promise.resolve(); + } + + // Register it + return new Promise((resolve, reject) => { + import(path) + .then(() => resolve()) + .catch(() => reject(new Error(`Unable to automatically load<${tagName}> from ${path}`))); + }); +} + +// Initial discovery +discover(document.body); + +// Listen for new undefined elements +observer.observe(document.body, { subtree: true, childList: true }); diff --git a/src/shoelace.ts b/src/shoelace.ts index a325a42cbe..ed52dba89f 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -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'; diff --git a/src/translations/da.ts b/src/translations/da.ts index 48f26a21fb..bd7c4f6319 100644 --- a/src/translations/da.ts +++ b/src/translations/da.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Dansk', $dir: 'ltr', + carousel: 'Karrusel', 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', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Scroll til start', selectAColorFromTheScreen: 'Vælg en farve fra skærmen', showPassword: 'Vis adgangskode', + slideNum: slide => `Slide ${slide}`, toggleColorFormat: 'Skift farveformat' }; diff --git a/src/translations/de.ts b/src/translations/de.ts index c65b92243f..cca5f24153 100644 --- a/src/translations/de.ts +++ b/src/translations/de.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Deutsch', $dir: 'ltr', + carousel: 'Karussell', 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', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Zum Anfang scrollen', selectAColorFromTheScreen: 'Wähle eine Farbe vom Bildschirm', showPassword: 'Passwort anzeigen', + slideNum: slide => `Gleiten ${slide}`, toggleColorFormat: 'Farbformat umschalten' }; diff --git a/src/translations/en.ts b/src/translations/en.ts index 9dd8511f07..0a52fa9e60 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'English', $dir: 'ltr', + carousel: 'Carousel', 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', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Scroll to start', selectAColorFromTheScreen: 'Select a color from the screen', showPassword: 'Show password', + slideNum: slide => `Slide ${slide}`, toggleColorFormat: 'Toggle color format' }; diff --git a/src/translations/es.ts b/src/translations/es.ts index c146923837..6e84617400 100644 --- a/src/translations/es.ts +++ b/src/translations/es.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Español', $dir: 'ltr', + carousel: 'Carrusel', 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', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Desplazarse al inicio', selectAColorFromTheScreen: 'Seleccione un color de la pantalla', showPassword: 'Mostrar contraseña', + slideNum: slide => `Diapositiva ${slide}`, toggleColorFormat: 'Alternar formato de color' }; diff --git a/src/translations/fa.ts b/src/translations/fa.ts index f4e7a7734a..8838545190 100644 --- a/src/translations/fa.ts +++ b/src/translations/fa.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'فارسی', $dir: 'rtl', + carousel: 'چرخ فلک', 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: 'تغییر اندازه', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'پیمایش به ابتدا', selectAColorFromTheScreen: 'انتخاب یک رنگ از صفحه نمایش', showPassword: 'نمایش رمز', + slideNum: slide => `اسلاید ${slide}`, toggleColorFormat: 'تغییر قالب رنگ' }; diff --git a/src/translations/fr.ts b/src/translations/fr.ts index 93bd5e9725..9e443bd2dd 100644 --- a/src/translations/fr.ts +++ b/src/translations/fr.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Français', $dir: 'ltr', + carousel: 'Carrousel', 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', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: `Faire défiler jusqu'au début`, selectAColorFromTheScreen: `Sélectionnez une couleur à l'écran`, showPassword: 'Montrer le mot de passe', + slideNum: slide => `Diapositive ${slide}`, toggleColorFormat: 'Changer le format de couleur' }; diff --git a/src/translations/he.ts b/src/translations/he.ts index bc50a2a823..e0dd1588c4 100644 --- a/src/translations/he.ts +++ b/src/translations/he.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'עברית', $dir: 'rtl', + carousel: 'קרוסלה', 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: 'שנה גודל', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'גלול להתחלה', selectAColorFromTheScreen: 'בחור צבע מהמסך', showPassword: 'הראה סיסמה', + slideNum: slide => `שקופית ${slide}`, toggleColorFormat: 'החלף פורמט צבע' }; diff --git a/src/translations/hu.ts b/src/translations/hu.ts index 8eec2fc4ca..ba77b8e145 100644 --- a/src/translations/hu.ts +++ b/src/translations/hu.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Magyar', $dir: 'ltr', + carousel: 'Körhinta', 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', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Görgessen az elejére', selectAColorFromTheScreen: 'Szín választása a képernyőről', showPassword: 'Jelszó megjelenítése', + slideNum: slide => `${slide}. dia`, toggleColorFormat: 'Színformátum változtatása' }; diff --git a/src/translations/ja.ts b/src/translations/ja.ts index f17635478b..9e7d06b9b8 100644 --- a/src/translations/ja.ts +++ b/src/translations/ja.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: '日本語', $dir: 'ltr', + carousel: 'カルーセル', 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: 'サイズ変更', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: '最初にスクロールする', selectAColorFromTheScreen: '画面から色を選択してください', showPassword: 'パスワードを表示', + slideNum: slide => `スライド ${slide}`, toggleColorFormat: '色のフォーマットを切り替える' }; diff --git a/src/translations/nl.ts b/src/translations/nl.ts index 6a7fe35409..6ec9048b36 100644 --- a/src/translations/nl.ts +++ b/src/translations/nl.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Nederlands', $dir: 'ltr', + carousel: 'Carrousel', clearEntry: 'Invoer wissen', close: 'Sluiten', copy: 'Kopiëren', + currentValue: 'Huidige waarde', + goToSlide: (slide, count) => `Ga naar slide ${slide} van ${count}`, + hidePassword: 'Verberg wachtwoord', + loading: 'Bezig met laden', + nextSlide: 'Volgende dia', numOptionsSelected: num => { if (num === 0) return 'Geen optie geselecteerd'; if (num === 1) return '1 optie geselecteerd'; return `${num} opties geselecteerd`; }, - currentValue: 'Huidige waarde', - hidePassword: 'Verberg wachtwoord', - loading: 'Bezig met laden', + previousSlide: 'Vorige dia', progress: 'Voortgang', remove: 'Verwijderen', resize: 'Formaat wijzigen', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Scroll naar begin', selectAColorFromTheScreen: 'Selecteer een kleur van het scherm', showPassword: 'Laat wachtwoord zien', + slideNum: slide => `Schuif ${slide}`, toggleColorFormat: 'Wissel kleurnotatie' }; diff --git a/src/translations/pl.ts b/src/translations/pl.ts index b34f8995c7..8b1a76b786 100644 --- a/src/translations/pl.ts +++ b/src/translations/pl.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Polski', $dir: 'ltr', + carousel: 'Karuzela', clearEntry: 'Wyczyść wpis', close: 'Zamknij', copy: 'Kopiuj', + currentValue: 'Aktualna wartość', + goToSlide: (slide, count) => `Przejdź do slajdu ${slide} z ${count}`, + hidePassword: 'Ukryj hasło', + loading: 'Ładowanie', + nextSlide: 'Następny slajd', numOptionsSelected: num => { if (num === 0) return 'Nie wybrano opcji'; if (num === 1) return 'Wybrano 1 opcję'; return `Wybrano ${num} opcje`; }, - currentValue: 'Aktualna wartość', - hidePassword: 'Ukryj hasło', - loading: 'Ładowanie', + previousSlide: 'Poprzedni slajd', progress: 'Postęp', remove: 'Usunąć', resize: 'Zmień rozmiar', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Przewiń do początku', selectAColorFromTheScreen: 'Próbkuj z ekranu', showPassword: 'Pokaż hasło', + slideNum: slide => `Slajd ${slide}`, toggleColorFormat: 'Przełącz format' }; diff --git a/src/translations/pt.ts b/src/translations/pt.ts index 3f62aa4503..921a05a1e7 100644 --- a/src/translations/pt.ts +++ b/src/translations/pt.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Português', $dir: 'ltr', + carousel: 'Carrossel', clearEntry: 'Limpar entrada', close: 'Fechar', copy: 'Copiar', + currentValue: 'Valor atual', + goToSlide: (slide, count) => `Vá para o slide ${slide} de ${count}`, + hidePassword: 'Esconder a senha', + loading: 'Carregando', + nextSlide: 'Próximo slide', numOptionsSelected: num => { if (num === 0) return 'Nenhuma opção selecionada'; if (num === 1) return '1 opção selecionada'; return `${num} opções selecionadas`; }, - currentValue: 'Valor atual', - hidePassword: 'Esconder a senha', - loading: 'Carregando', + previousSlide: 'Slide anterior', progress: 'Progresso', remove: 'Remover', resize: 'Mudar o tamanho', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Rolar até o começo', selectAColorFromTheScreen: 'Selecionar uma cor da tela', showPassword: 'Mostrar senhaShow password', + slideNum: slide => `Diapositivo ${slide}`, toggleColorFormat: 'Trocar o formato de cor' }; diff --git a/src/translations/ru.ts b/src/translations/ru.ts index 2d03690c79..b12086ca42 100644 --- a/src/translations/ru.ts +++ b/src/translations/ru.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Русский', $dir: 'ltr', + carousel: 'Карусель', clearEntry: 'Очистить запись', close: 'Закрыть', copy: 'Скопировать', + currentValue: 'Текущее значение', + goToSlide: (slide, count) => `Перейти к слайду ${slide} из ${count}`, + hidePassword: 'Скрыть пароль', + loading: 'Загрузка', + nextSlide: 'Следующий слайд', numOptionsSelected: num => { if (num === 0) return 'выбрано 0 вариантов'; if (num === 1) return 'Выбран 1 вариант'; return `выбрано ${num} варианта`; }, - currentValue: 'Текущее значение', - hidePassword: 'Скрыть пароль', - loading: 'Загрузка', + previousSlide: 'Предыдущий слайд', progress: 'Прогресс', remove: 'Удалить', resize: 'Изменить размер', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Пролистать к началу', selectAColorFromTheScreen: 'Выберите цвет на экране', showPassword: 'Показать пароль', + slideNum: slide => `Слайд ${slide}`, toggleColorFormat: 'Переключить цветовую модель' }; diff --git a/src/translations/sv.ts b/src/translations/sv.ts index 204d9574af..f892310e2b 100644 --- a/src/translations/sv.ts +++ b/src/translations/sv.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Svenska', $dir: 'ltr', + carousel: 'Karusell', clearEntry: 'Återställ val', close: 'Stäng', copy: 'Kopiera', + currentValue: 'Nuvarande värde', + goToSlide: (slide, count) => `Gå till bild ${slide} av ${count}`, + hidePassword: 'Dölj lösenord', + loading: 'Läser in', + nextSlide: 'Nästa bild', numOptionsSelected: num => { if (num === 0) return 'Inga alternativ har valts'; if (num === 1) return '1 alternativ valt'; return `${num} alternativ valda`; }, - currentValue: 'Nuvarande värde', - hidePassword: 'Dölj lösenord', - loading: 'Läser in', + previousSlide: 'Föregående bild', progress: 'Framsteg', remove: 'Ta bort', resize: 'Ändra storlek', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Skrolla till början', selectAColorFromTheScreen: 'Välj en färg från skärmen', showPassword: 'Visa lösenord', + slideNum: slide => `Bild ${slide}`, toggleColorFormat: 'Växla färgformat' }; diff --git a/src/translations/tr.ts b/src/translations/tr.ts index e0da1add93..533ae1bb2f 100644 --- a/src/translations/tr.ts +++ b/src/translations/tr.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: 'Türkçe', $dir: 'ltr', + carousel: 'Atlıkarınca', clearEntry: 'Girişi sil', close: 'Kapat', copy: 'Kopya', + currentValue: 'Mevcut değer', + goToSlide: (slide, count) => `${count} slayttan ${slide} slayta gidin`, + hidePassword: 'Şifreyi sakla', + loading: 'Yükleme', + nextSlide: 'Sonraki slayt', numOptionsSelected: num => { if (num === 0) return 'Hiçbir seçenek seçilmedi'; if (num === 1) return '1 seçenek seçildi'; return `${num} seçenek seçildi`; }, - currentValue: 'Mevcut değer', - hidePassword: 'Şifreyi sakla', - loading: 'Yükleme', + previousSlide: 'Bir onceki slayt', progress: 'İlerleme', remove: 'Kaldır', resize: 'Yeniden boyutlandır', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: 'Başa kay', selectAColorFromTheScreen: 'Ekrandan bir renk seçin', showPassword: 'Şifreyi göster', + slideNum: slide => `Slayt ${slide}`, toggleColorFormat: 'Renk biçimini değiştir' }; diff --git a/src/translations/zh-tw.ts b/src/translations/zh-tw.ts index 35dcaa4317..71cdc6efcd 100644 --- a/src/translations/zh-tw.ts +++ b/src/translations/zh-tw.ts @@ -6,17 +6,21 @@ const translation: Translation = { $name: '正體中文', $dir: 'ltr', + carousel: '旋轉木馬', 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: '調整大小', @@ -24,6 +28,7 @@ const translation: Translation = { scrollToStart: '捲至頁首', selectAColorFromTheScreen: '從螢幕中選擇一種顏色', showPassword: '顯示密碼', + slideNum: slide => `幻燈片 ${slide}`, toggleColorFormat: '切換顏色格式' }; diff --git a/src/utilities/base-path.ts b/src/utilities/base-path.ts index 7e193c4aea..71414d9a72 100644 --- a/src/utilities/base-path.ts +++ b/src/utilities/base-path.ts @@ -9,16 +9,18 @@ export function setBasePath(path: string) { * Gets the library's base path. * * The base path is used to load assets such as icons and images, so it needs to be set for components to work properly. - * By default, this script will look for a script ending in shoelace.js and set the base path to the directory that - * contains that file. To override this behavior, you can add the data-shoelace attribute to any script on the page - * (it probably makes the most sense to attach it to the Shoelace script, but it could also be on a bundle). The value - * can be a local folder or it can point to a CORS-enabled endpoint such as a CDN. + * By default, this script will look for a script ending in shoelace.js or shoelace-autoloader.js and set the base path + * to the directory that contains that file. To override this behavior, you can add the data-shoelace attribute to any + * script on the page (it probably makes the most sense to attach it to the Shoelace script, but it could also be on a + * bundle). The value can be a local folder or it can point to a CORS-enabled endpoint such as a CDN. * * * * Alternatively, you can set the base path manually using the exported setBasePath() function. + * + * @param subpath - An optional path to append to the base path. */ -export function getBasePath() { +export function getBasePath(subpath = '') { if (!basePath) { const scripts = [...document.getElementsByTagName('script')] as HTMLScriptElement[]; const configScript = scripts.find(script => script.hasAttribute('data-shoelace')); @@ -27,7 +29,9 @@ export function getBasePath() { // Use the data-shoelace attribute setBasePath(configScript.getAttribute('data-shoelace')!); } else { - const fallbackScript = scripts.find(s => /shoelace(\.min)?\.js($|\?)/.test(s.src)); + const fallbackScript = scripts.find(s => { + return /shoelace(\.min)?\.js($|\?)/.test(s.src) || /shoelace-autoloader(\.min)?\.js($|\?)/.test(s.src); + }); let path = ''; if (fallbackScript) { @@ -38,5 +42,6 @@ export function getBasePath() { } } - return basePath.replace(/\/$/, ''); + // Return the base path without a trailing slash. If one exists, append the subpath separated by a slash. + return basePath.replace(/\/$/, '') + (subpath ? `/${subpath.replace(/^\//, '')}` : ``); } diff --git a/src/utilities/localize.ts b/src/utilities/localize.ts index edc153a24f..e3298e6bf4 100644 --- a/src/utilities/localize.ts +++ b/src/utilities/localize.ts @@ -13,13 +13,17 @@ export interface Translation extends DefaultTranslation { $name: string; // e.g. English, Español $dir: 'ltr' | 'rtl'; + carousel: string; clearEntry: string; close: string; copy: string; - numOptionsSelected: (num: number) => string; currentValue: string; + goToSlide: (slide: number, count: number) => string; hidePassword: string; loading: string; + nextSlide: string; + numOptionsSelected: (num: number) => string; + previousSlide: string; progress: string; remove: string; resize: string; @@ -27,5 +31,6 @@ export interface Translation extends DefaultTranslation { scrollToStart: string; selectAColorFromTheScreen: string; showPassword: string; + slideNum: (slide: number) => string; toggleColorFormat: string; } diff --git a/tsconfig.json b/tsconfig.json index ec49f88bab..49de6876b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ "target": "es2017", + "module": "es2020", "lib": [ "dom", "dom.Iterable", diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 7dace6288f..b273756b77 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -5,7 +5,7 @@ import { playwrightLauncher } from '@web/test-runner-playwright'; export default { rootDir: '.', files: 'src/**/*.test.ts', // "default" group - concurrentBrowsers: 3, + concurrentBrowsers: 1, nodeResolve: true, testFramework: { config: {