diff --git a/.gitignore b/.gitignore index 6db0b64a..b6f26d43 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,5 @@ docs/dist docs/search.json dist -examples node_modules src/react diff --git a/cspell.json b/cspell.json index 4ef02597..f778ba50 100644 --- a/cspell.json +++ b/cspell.json @@ -30,6 +30,7 @@ "Consolas", "contenteditable", "copydir", + "Cotte", "coverpage", "crossorigin", "crutchcorn", @@ -56,6 +57,7 @@ "FOUC", "FOUCE", "fullscreen", + "gestern", "giga", "globby", "Grayscale", @@ -73,10 +75,12 @@ "jsonata", "keydown", "keyframes", + "Kool", "labelledby", "Laravel", "LaViska", "listbox", + "listitem", "litelement", "lowercasing", "Lucide", @@ -109,9 +113,13 @@ "rgba", "roadmap", "Roboto", + "roledescription", + "Sapan", "saturationl", "Schilp", "scrollbars", + "scrollend", + "scroller", "Segoe", "semibold", "slotchange", @@ -124,6 +132,7 @@ "tabpanel", "templating", "tera", + "testid", "textareas", "textfield", "tinycolor", @@ -146,6 +155,7 @@ "ignorePaths": [ "package.json", "package-lock.json", + "docs/assets/examples/include.html", ".vscode/**", "src/translations/!(en).ts", "**/*.min.js" diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 995d5d7f..bf8bb0a9 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -31,6 +31,8 @@ - [Button](/components/button) - [Button Group](/components/button-group) - [Card](/components/card) + - [Carousel](/components/carousel) + - [Carousel Item](/components/carousel-item) - [Checkbox](/components/checkbox) - [Color Picker](/components/color-picker) - [Details](/components/details) diff --git a/docs/assets/examples/carousel/field.jpg b/docs/assets/examples/carousel/field.jpg new file mode 100644 index 00000000..a742b196 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 00000000..17a7ea3c 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 00000000..811b0df2 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 00000000..bd575fbf 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 00000000..b8fa264c Binary files /dev/null and b/docs/assets/examples/carousel/waterfall.jpg differ diff --git a/docs/components/carousel-item.md b/docs/components/carousel-item.md new file mode 100644 index 00000000..9f5b80fe --- /dev/null +++ b/docs/components/carousel-item.md @@ -0,0 +1,81 @@ +# 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 + + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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 00000000..5babd7b4 --- /dev/null +++ b/docs/components/carousel.md @@ -0,0 +1,1216 @@ +# 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) + + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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) + + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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) + + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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) + + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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) + + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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 +
+ + +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlSwitch } from '@shoelace-style/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 + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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 appending or removing carousel items. The carousel will update itself automatically. + +```html preview + + Slide 1 + Slide 2 + Slide 3 + + + + + + + +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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 make the carousel laying out vertically, making it +possible for the user to scroll it up and down. In case of heterogeneous content, for example images of different sizes, +it's important to specify a predefined height to the carousel through 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) + + + +``` + +```jsx react +import { SlCarousel, SlCarouselItem } from '@shoelace-style/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 + + + +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlSelect, SlOption } from '@shoelace-style/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` attribute to add inline padding in horizontal carousels and block padding in vertical carousels. Setting a padding 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) + + +``` + +```jsx react +import { useState } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlRange } from '@shoelace-style/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 +
+
+ + + + +``` + +```jsx react +import { useRef } from 'react'; +import { SlCarousel, SlCarouselItem, SlDivider, SlRange } from '@shoelace-style/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/dropdown.md b/docs/components/dropdown.md index 194d721d..9d723aa1 100644 --- a/docs/components/dropdown.md +++ b/docs/components/dropdown.md @@ -64,7 +64,7 @@ const App = () => ( ### Getting the Selected Item -When dropdowns are used with [menus](/components/menu), you can listen for the `sl-select` event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands. +When dropdowns are used with [menus](/components/menu), you can listen for the [`sl-select`](/components/menu#events) event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands. ```html preview ``` -## 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. diff --git a/docs/getting-started/usage.md b/docs/getting-started/usage.md index c990d974..adb47678 100644 --- a/docs/getting-started/usage.md +++ b/docs/getting-started/usage.md @@ -164,7 +164,7 @@ checkbox.checked = true; console.log(checkbox.hasAttribute('checked')); // false ``` -Most devs will expect this to be `true` instead of `false`, but the component hasn't had a chance to re-render yet so the attribute doesn't exist when `hasAttribute()` is called. Since changes are batched, we need to wait for the update before proceeding. This can be done using the `updateComplete` property, which is available on all Lit-based components. +Most developers will expect this to be `true` instead of `false`, but the component hasn't had a chance to re-render yet so the attribute doesn't exist when `hasAttribute()` is called. Since changes are batched, we need to wait for the update before proceeding. This can be done using the `updateComplete` property, which is available on all Lit-based components. ```js const checkbox = document.querySelector('sl-checkbox'); diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 9b60d16f..1ac8dfe9 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -10,8 +10,20 @@ New versions of Shoelace are released as-needed and generally occur when a criti - Added an experimental autoloader - Added the `subpath` argument to `getBasePath()` to make it easier to generate full paths to any file +- Added TypeScript types to all custom events [#1183](https://github.com/shoelace-style/shoelace/pull/1183) +- Added the `svg` part to `` +- Added the `getForm()` method to all form controls [#1180](https://github.com/shoelace-style/shoelace/issues/1180) - Fixed a bug in `` that caused the display label to render incorrectly in Chrome after form validation [#1197](https://github.com/shoelace-style/shoelace/discussions/1197) - Fixed a bug in `` that prevented users from applying their own value for `autocapitalize`, `autocomplete`, and `autocorrect` when using `type="password` [#1205](https://github.com/shoelace-style/shoelace/issues/1205) +- Fixed a bug in `` that prevented scroll controls from showing when dynamically adding tabs [#1208](https://github.com/shoelace-style/shoelace/issues/1208) +- Fixed a big in `` that caused the calendar icon to be clipped in Firefox [#1213](https://github.com/shoelace-style/shoelace/pull/1213) +- Fixed a bug in `` that caused `sl-tab-show` to be emitted when activating the close button +- Fixed a bug in `` that caused `--track-color` to be invisible with certain colors +- Fixed a bug in `` that caused the focus color to show when selecting menu items with a mouse or touch device +- Fixed a bug in `` that caused `sl-change` and `sl-input` to be emitted too early [#1201](https://github.com/shoelace-style/shoelace/issues/1201) +- Fixed a positioning edge case that caused `` to positioned nested popups incorrectly [#1135](https://github.com/shoelace-style/shoelace/issues/1135) +- Updated `@shoelace-style/localize` to 3.1.0 +- Updated `@floating-ui/dom` to 1.2.1 When using `` the default value for `autocapitalize`, `autocomplete`, and `autocorrect` may be affected due to the bug fixed in [#1205](https://github.com/shoelace-style/shoelace/issues/1205). For any affected users, setting these attributes to `off` will restore the previous behavior. @@ -282,8 +294,7 @@ This release removes the `` component. When this component - Fixed a bug in `` that prevented the keyboard from working when the component was nested in a shadow root [#871](https://github.com/shoelace-style/shoelace/issues/871) - Fixed a bug in `` that prevented the keyboard from working when the component was nested in a shadow root [#872](https://github.com/shoelace-style/shoelace/issues/872) - Fixed a bug in `` that allowed disabled tabs to erroneously receive focus -- Improved single selection in `` so nodes expand and collapse and rece - ive selection when clicking on the label +- Improved single selection in `` so nodes expand and collapse and receive selection when clicking on the label - Renamed `expanded-icon` and `collapsed-icon` slots to `expand-icon` and `collapse-icon` in the experimental `` and `` components - Improved RTL support for `` - Refactored components to extend from `ShoelaceElement` to make `dir` and `lang` reactive properties in all components diff --git a/package-lock.json b/package-lock.json index 054e91b0..57c3f7f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,11 @@ "license": "MIT", "dependencies": { "@ctrl/tinycolor": "^3.5.0", - "@floating-ui/dom": "^1.1.0", + "@floating-ui/dom": "^1.2.1", "@lit-labs/react": "^1.1.1", "@shoelace-style/animations": "^1.1.0", - "@shoelace-style/localize": "^3.0.4", + "@shoelace-style/localize": "^3.1.0", + "composed-offset-position": "^0.0.4", "lit": "^2.6.1", "qr-creator": "^1.0.0" }, @@ -1043,16 +1044,16 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz", - "integrity": "sha512-zbsLwtnHo84w1Kc8rScAo5GMk1GdecSlrflIbfnEBJwvTSj1SL6kkOYV+nHraMCPEy+RNZZUaZyL8JosDGCtGQ==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.1.tgz", + "integrity": "sha512-LSqwPZkK3rYfD7GKoIeExXOyYx6Q1O4iqZWwIehDNuv3Dv425FIAE8PRwtAx1imEolFTHgBEcoFHm9MDnYgPCg==" }, "node_modules/@floating-ui/dom": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.0.tgz", - "integrity": "sha512-TSogMPVxbRe77QCj1dt8NmRiJasPvuc+eT5jnJ6YpLqgOD2zXc5UA3S1qwybN+GVCDNdKfpKy1oj8RpzLJvh6A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.2.1.tgz", + "integrity": "sha512-Rt45SmRiV8eU+xXSB9t0uMYiQ/ZWGE/jumse2o3i5RGlyvcbqOF4q+1qBnzLE2kZ5JGhq0iMkcGXUKbFe7MpTA==", "dependencies": { - "@floating-ui/core": "^1.0.5" + "@floating-ui/core": "^1.2.1" } }, "node_modules/@gar/promisify": { @@ -1478,9 +1479,9 @@ } }, "node_modules/@shoelace-style/localize": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.4.tgz", - "integrity": "sha512-HFY90KD+b1Td2otSBryCOpQjBEArIwlV6Tv4J4rC/E/D5wof2eLF6JUVrbiRNn8GRmwATe4YDAEK7NUD08xO1w==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.1.0.tgz", + "integrity": "sha512-evGxn5wIQh1/Ks1RbZm7rY4DxPKAUnXKTixZNgnYV/N2V8Bbbvsi+S14gNa42SQNUJK5WooNtlar2B8cehEwZQ==" }, "node_modules/@sindresorhus/is": { "version": "0.7.0", @@ -4523,6 +4524,11 @@ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "node_modules/composed-offset-position": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.4.tgz", + "integrity": "sha512-vMlvu1RuNegVE0YsCDSV/X4X10j56mq7PCIyOKK74FxkXzGLwhOUmdkJLSdOBOMwWycobGUMgft2lp+YgTe8hw==" + }, "node_modules/compress-brotli": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.8.tgz", @@ -16270,16 +16276,16 @@ } }, "@floating-ui/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz", - "integrity": "sha512-zbsLwtnHo84w1Kc8rScAo5GMk1GdecSlrflIbfnEBJwvTSj1SL6kkOYV+nHraMCPEy+RNZZUaZyL8JosDGCtGQ==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.1.tgz", + "integrity": "sha512-LSqwPZkK3rYfD7GKoIeExXOyYx6Q1O4iqZWwIehDNuv3Dv425FIAE8PRwtAx1imEolFTHgBEcoFHm9MDnYgPCg==" }, "@floating-ui/dom": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.0.tgz", - "integrity": "sha512-TSogMPVxbRe77QCj1dt8NmRiJasPvuc+eT5jnJ6YpLqgOD2zXc5UA3S1qwybN+GVCDNdKfpKy1oj8RpzLJvh6A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.2.1.tgz", + "integrity": "sha512-Rt45SmRiV8eU+xXSB9t0uMYiQ/ZWGE/jumse2o3i5RGlyvcbqOF4q+1qBnzLE2kZ5JGhq0iMkcGXUKbFe7MpTA==", "requires": { - "@floating-ui/core": "^1.0.5" + "@floating-ui/core": "^1.2.1" } }, "@gar/promisify": { @@ -16623,9 +16629,9 @@ "integrity": "sha512-Be+cahtZyI2dPKRm8EZSx3YJQ+jLvEcn3xzRP7tM4tqBnvd/eW/64Xh0iOf0t2w5P8iJKfdBbpVNE9naCaOf2g==" }, "@shoelace-style/localize": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.0.4.tgz", - "integrity": "sha512-HFY90KD+b1Td2otSBryCOpQjBEArIwlV6Tv4J4rC/E/D5wof2eLF6JUVrbiRNn8GRmwATe4YDAEK7NUD08xO1w==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.1.0.tgz", + "integrity": "sha512-evGxn5wIQh1/Ks1RbZm7rY4DxPKAUnXKTixZNgnYV/N2V8Bbbvsi+S14gNa42SQNUJK5WooNtlar2B8cehEwZQ==" }, "@sindresorhus/is": { "version": "0.7.0", @@ -18941,6 +18947,11 @@ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "composed-offset-position": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.4.tgz", + "integrity": "sha512-vMlvu1RuNegVE0YsCDSV/X4X10j56mq7PCIyOKK74FxkXzGLwhOUmdkJLSdOBOMwWycobGUMgft2lp+YgTe8hw==" + }, "compress-brotli": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.8.tgz", diff --git a/package.json b/package.json index 1b296b7b..0cf9da79 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,11 @@ }, "dependencies": { "@ctrl/tinycolor": "^3.5.0", - "@floating-ui/dom": "^1.1.0", + "@floating-ui/dom": "^1.2.1", "@lit-labs/react": "^1.1.1", "@shoelace-style/animations": "^1.1.0", - "@shoelace-style/localize": "^3.0.4", + "@shoelace-style/localize": "^3.1.0", + "composed-offset-position": "^0.0.4", "lit": "^2.6.1", "qr-creator": "^1.0.0" }, diff --git a/src/components/button-group/button-group.ts b/src/components/button-group/button-group.ts index c7184abd..6d8dca90 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.ts b/src/components/button/button.ts index bbf5f4de..8c578f66 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 00000000..6bb8efb4 --- /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 00000000..3cf84981 --- /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 00000000..ed469332 --- /dev/null +++ b/src/components/carousel-item/carousel-item.ts @@ -0,0 +1,41 @@ +import { customElement } from 'lit/decorators.js'; +import { html } from 'lit'; +import ShoelaceElement from '../../internal/shoelace-element'; +import styles from './carousel-item.styles'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary A carousel item represent a slide within a [carousel](/components/carousel). + * + * @since 2.0 + * @status experimental + * + * @slot - The carousel item's content.. + * + * @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default. + * + */ +@customElement('sl-carousel-item') +export default class SlCarouselItem extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + static isCarouselItem(node: Node) { + return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide'; + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'listitem'); + this.setAttribute('aria-roledescription', 'slide'); + } + + render() { + return html` `; + } +} + +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 00000000..4cd97879 --- /dev/null +++ b/src/components/carousel/autoplay-controller.ts @@ -0,0 +1,68 @@ +import type { ReactiveController, ReactiveElement } from 'lit'; + +/** + * A controller that repeatedly calls the specified callback with the provided interval time. + * The timer is automatically paused while the user is interacting with the component. + */ +export class AutoplayController implements ReactiveController { + private host: ReactiveElement; + private timerId = 0; + private tickCallback: () => void; + + paused = false; + stopped = true; + + constructor(host: ReactiveElement, tickCallback: () => void) { + host.addController(this); + + this.host = host; + this.tickCallback = tickCallback; + } + + hostConnected(): void { + this.host.addEventListener('mouseenter', this.pause); + this.host.addEventListener('mouseleave', this.resume); + this.host.addEventListener('focusin', this.pause); + this.host.addEventListener('focusout', this.resume); + this.host.addEventListener('touchstart', this.pause, { passive: true }); + this.host.addEventListener('touchend', this.resume); + } + + hostDisconnected(): void { + this.stop(); + + this.host.removeEventListener('mouseenter', this.pause); + this.host.removeEventListener('mouseleave', this.resume); + this.host.removeEventListener('focusin', this.pause); + this.host.removeEventListener('focusout', this.resume); + this.host.removeEventListener('touchstart', this.pause); + this.host.removeEventListener('touchend', this.resume); + } + + start(interval: number) { + this.stop(); + + this.stopped = false; + this.timerId = window.setInterval(() => { + if (!this.paused) { + this.tickCallback(); + } + }, interval); + } + + stop() { + clearInterval(this.timerId); + this.stopped = true; + this.host.requestUpdate(); + } + + pause = () => { + this.paused = true; + this.host.requestUpdate(); + }; + + resume = () => { + this.paused = false; + this.host.requestUpdate(); + }; +} diff --git a/src/components/carousel/carousel.styles.ts b/src/components/carousel/carousel.styles.ts new file mode 100644 index 00000000..d6b31cac --- /dev/null +++ b/src/components/carousel/carousel.styles.ts @@ -0,0 +1,149 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + --slide-gap: var(--sl-spacing-medium, 1rem); + --aspect-ratio: 16 / 9; + --scroll-hint: 0px; + + display: flex; + } + + .carousel { + display: grid; + grid-template-columns: min-content 1fr min-content; + grid-template-rows: 1fr min-content; + grid-template-areas: + '. slides .' + '. pagination .'; + gap: var(--sl-spacing-medium); + align-items: center; + min-height: 100%; + min-width: 100%; + position: relative; + } + + .carousel__pagination { + grid-area: pagination; + + display: flex; + justify-content: center; + gap: var(--sl-spacing-small); + } + + .carousel__slides { + grid-area: slides; + + display: grid; + height: 100%; + width: 100%; + align-items: center; + justify-items: center; + overflow: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + aspect-ratio: calc(var(--aspect-ratio) * var(--slides-per-page)); + + --slide-size: calc((100% - (var(--slides-per-page) - 1) * var(--slide-gap)) / var(--slides-per-page)); + } + + @media (prefers-reduced-motion) { + :where(.carousel__slides) { + scroll-behavior: auto; + } + } + + .carousel__slides--horizontal { + grid-auto-flow: column; + grid-auto-columns: var(--slide-size); + grid-auto-rows: 100%; + column-gap: var(--slide-gap); + scroll-snap-type: x mandatory; + scroll-padding-inline: var(--scroll-hint); + padding-inline: var(--scroll-hint); + } + + .carousel__slides--vertical { + grid-auto-flow: row; + grid-auto-columns: 100%; + grid-auto-rows: var(--slide-size); + row-gap: var(--slide-gap); + scroll-snap-type: y mandatory; + scroll-padding-block: var(--scroll-hint); + padding-block: var(--scroll-hint); + } + + .carousel__slides--dragging, + .carousel__slides--dropping { + scroll-snap-type: unset; + } + + :host([vertical]) ::slotted(sl-carousel-item) { + height: 100%; + } + + .carousel__slides::-webkit-scrollbar { + display: none; + } + + .carousel__navigation { + grid-area: navigation; + display: contents; + font-size: var(--sl-font-size-x-large); + } + + .carousel__navigation-button { + flex: 0 0 auto; + display: flex; + align-items: center; + background: none; + border: none; + border-radius: var(--sl-border-radius-medium); + font-size: inherit; + color: var(--sl-color-neutral-600); + padding: var(--sl-spacing-x-small); + cursor: pointer; + transition: var(--sl-transition-medium) color; + appearance: none; + } + + .carousel__navigation-button--disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .carousel__navigation-button--disabled::part(base) { + pointer-events: none; + } + + .carousel__navigation-button--previous { + grid-column: 1; + grid-row: 1; + } + + .carousel__navigation-button--next { + grid-column: 3; + grid-row: 1; + } + + .carousel__pagination-item { + display: block; + cursor: pointer; + background: none; + border: 0; + border-radius: var(--sl-border-radius-circle); + width: var(--sl-spacing-small); + height: var(--sl-spacing-small); + background-color: var(--sl-color-neutral-300); + will-change: transform; + transition: var(--sl-transition-fast) ease-in; + } + + .carousel__pagination-item--active { + background-color: var(--sl-color-neutral-600); + transform: scale(1.2); + } +`; diff --git a/src/components/carousel/carousel.test.ts b/src/components/carousel/carousel.test.ts new file mode 100644 index 00000000..1f6b36c1 --- /dev/null +++ b/src/components/carousel/carousel.test.ts @@ -0,0 +1,601 @@ +import { clickOnElement } from '../../internal/test'; +import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import sinon from 'sinon'; +import type SlCarousel from './carousel'; + +describe('', () => { + 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-roledescription', 'carousel'); + expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist; + expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist; + }); + + describe('when `autoplay` attribute is provided', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ + now: new Date() + }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should scroll forwards every `autoplay-interval` milliseconds', async () => { + // Arrange + const el = await fixture(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; + }); + }); + + 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-live', 'polite'); + expect(el.scrollContainer).to.have.attribute('aria-atomic', 'true'); + + expect(pagination).to.have.attribute('role', 'tablist'); + expect(pagination).to.have.attribute('aria-controls', el.scrollContainer.id); + for (const paginationItem of pagination.querySelectorAll('.carousel__pagination-item')) { + expect(paginationItem).to.have.attribute('role', 'tab'); + expect(paginationItem).to.have.attribute('aria-selected'); + expect(paginationItem).to.have.attribute('aria-label'); + } + + for (const navigationItem of navigation.querySelectorAll('.carousel__navigation-item')) { + expect(navigationItem).to.have.attribute('aria-controls', el.scrollContainer.id); + expect(navigationItem).to.have.attribute('aria-disabled'); + expect(navigationItem).to.have.attribute('aria-label'); + } + + await expect(el).to.be.accessible(); + }); + + describe('when scrolling', () => { + it('should update aria-busy attribute', async () => { + // Arrange + const el = await fixture(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'); + }); + }); + + describe('when autoplay is active', () => { + it('should disable live announcement', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + + await el.updateComplete; + + // Assert + expect(el.scrollContainer).to.have.attribute('aria-live', 'off'); + }); + + describe('and user is interacting with the carousel', () => { + it('should enable live announcement', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + + await el.updateComplete; + + // Act + el.dispatchEvent(new Event('focusin')); + await el.updateComplete; + + // Assert + expect(el.scrollContainer).to.have.attribute('aria-live', 'polite'); + }); + }); + }); + }); +}); diff --git a/src/components/carousel/carousel.ts b/src/components/carousel/carousel.ts new file mode 100644 index 00000000..ab31cff9 --- /dev/null +++ b/src/components/carousel/carousel.ts @@ -0,0 +1,421 @@ +import '../icon/icon'; +import { AutoplayController } from './autoplay-controller'; +import { clamp } from 'src/internal/math'; +import { classMap } from 'lit/directives/class-map.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { html } from 'lit'; +import { LocalizeController } from '@shoelace-style/localize'; +import { map } from 'lit/directives/map.js'; +import { prefersReducedMotion } from '../../internal/animate'; +import { range } from 'lit/directives/range.js'; +import { ScrollController } from './scroll-controller'; +import { watch } from '../../internal/watch'; +import { when } from 'lit/directives/when.js'; +import ShoelaceElement from '../../internal/shoelace-element'; +import SlCarouselItem from '../carousel-item/carousel-item'; +import styles from './carousel.styles'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis. + * + * @since 2.0 + * @status experimental + * + * @dependency sl-icon + * + * @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes. + * + * @slot - The carousel's main content, one or more `` 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-roledescription', 'carousel'); + + const intersectionObserver = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => { + entries.forEach(entry => { + // Store all the entries in a map to be processed when scrolling ends + this.intersectionObserverEntries.set(entry.target, entry); + + const slide = entry.target; + slide.toggleAttribute('inert', !entry.isIntersecting); + slide.classList.toggle('--in-view', entry.isIntersecting); + slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true'); + }); + }, + { + root: this, + threshold: 0.6 + } + ); + this.intersectionObserver = intersectionObserver; + + // Store the initial state of each slide + intersectionObserver.takeRecords().forEach(entry => { + this.intersectionObserverEntries.set(entry.target, entry); + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.intersectionObserver.disconnect(); + this.mutationObserver.disconnect(); + } + + protected firstUpdated(): void { + this.initializeSlides(); + this.mutationObserver = new MutationObserver(this.handleSlotChange.bind(this)); + this.mutationObserver.observe(this, { childList: true, subtree: false }); + } + + private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) { + return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone')); + } + + /** + * Move the carousel backward by `slides-per-move` slides. + * + * @param behavior - The behavior used for scrolling. + */ + previous(behavior: ScrollBehavior = 'smooth') { + this.goToSlide(this.activeSlide - this.slidesPerMove, behavior); + } + + /** + * Move the carousel forward by `slides-per-move` slides. + * + * @param behavior - The behavior used for scrolling. + */ + next(behavior: ScrollBehavior = 'smooth') { + this.goToSlide(this.activeSlide + this.slidesPerMove, behavior); + } + + /** + * Scrolls the carousel to the slide specified by `index`. + * + * @param index - The slide index. + * @param behavior - The behavior used for scrolling. + */ + goToSlide(index: number, behavior: ScrollBehavior = 'smooth') { + const { slidesPerPage, loop } = this; + + const slidesWithClones = this.getSlides({ excludeClones: false }); + const normalizedIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1); + const slide = slidesWithClones[normalizedIndex]; + + this.scrollContainer.scrollTo({ + left: slide.offsetLeft, + top: slide.offsetTop, + behavior: prefersReducedMotion() ? 'auto' : behavior + }); + } + + handleSlotChange(mutations: MutationRecord[]) { + const needsInitialization = mutations.some(mutation => + [...mutation.addedNodes, ...mutation.removedNodes].some( + node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone') + ) + ); + + // Reinitialize the carousel if a carousel item has been added or removed + if (needsInitialization) { + this.initializeSlides(); + this.requestUpdate(); + } + } + + handleScrollEnd() { + const slides = this.getSlides(); + const entries = [...this.intersectionObserverEntries.values()]; + + const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting); + + if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) { + const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone')); + + // Scrolls to the original slide without animating, so the user won't notice that the position has changed + this.goToSlide(clonePosition, 'auto'); + + return; + } + + // Activate the first intersecting slide + if (firstIntersecting) { + this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem); + } + } + + @watch('loop', { waitUntilFirstUpdate: true }) + @watch('slidesPerPage', { waitUntilFirstUpdate: true }) + initializeSlides() { + const slides = this.getSlides(); + const intersectionObserver = this.intersectionObserver; + + this.intersectionObserverEntries.clear(); + + // Removes all the cloned elements from the carousel + this.getSlides({ excludeClones: false }).forEach(slide => { + intersectionObserver.unobserve(slide); + + slide.classList.remove('--in-view'); + slide.classList.remove('--is-active'); + + if (slide.hasAttribute('data-clone')) { + slide.remove(); + } + }); + + if (this.loop) { + // Creates clones to be placed before and after the original elements to simulate infinite scrolling + const slidesPerPage = this.slidesPerPage; + const lastSlides = slides.slice(-slidesPerPage); + const firstSlides = slides.slice(0, slidesPerPage); + + lastSlides.reverse().forEach((slide, i) => { + const clone = slide.cloneNode(true) as HTMLElement; + clone.setAttribute('data-clone', String(slides.length - i - 1)); + this.prepend(clone); + }); + + firstSlides.forEach((slide, i) => { + const clone = slide.cloneNode(true) as HTMLElement; + clone.setAttribute('data-clone', String(i)); + this.append(clone); + }); + } + + this.getSlides({ excludeClones: false }).forEach(slide => { + intersectionObserver.observe(slide); + }); + + // Because the DOM may be changed, restore the scroll position to the active slide + this.goToSlide(this.activeSlide, 'auto'); + } + + @watch('activeSlide') + handelSlideChange() { + const slides = this.getSlides(); + slides.forEach((slide, i) => { + slide.classList.toggle('--is-active', i === this.activeSlide); + }); + + // Do not emit an event on first render + if (this.hasUpdated) { + this.emit('sl-slide-change', { + detail: { + index: this.activeSlide, + slide: slides[this.activeSlide] + } + }); + } + } + + @watch('slidesPerMove') + handleSlidesPerMoveChange() { + const slides = this.getSlides({ excludeClones: false }); + + const slidesPerMove = this.slidesPerMove; + slides.forEach((slide, i) => { + const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0; + if (shouldSnap) { + slide.style.removeProperty('scroll-snap-align'); + } else { + slide.style.setProperty('scroll-snap-align', 'none'); + } + }); + } + + @watch('autoplay') + handleAutoplayChange() { + this.autoplayController.stop(); + if (this.autoplay) { + this.autoplayController.start(this.autoplayInterval); + } + } + + @watch('mouseDragging') + handleMouseDraggingChange() { + this.scrollController.mouseDragging = this.mouseDragging; + } + + private renderPagination = () => { + const slides = this.getSlides(); + const slidesCount = slides.length; + + const { activeSlide, slidesPerPage } = this; + const pagesCount = Math.ceil(slidesCount / slidesPerPage); + const currentPage = Math.floor(activeSlide / slidesPerPage); + + return html` + + `; + }; + + private renderNavigation = () => { + const { loop, activeSlide } = this; + const slides = this.getSlides(); + const slidesCount = slides.length; + const prevEnabled = loop || activeSlide > 0; + const nextEnabled = loop || activeSlide < slidesCount - 1; + const isLtr = this.localize.dir() === 'ltr'; + + return html` + + `; + }; + + render() { + const { autoplayController, scrollController } = this; + + 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 00000000..b0dc0a8a --- /dev/null +++ b/src/components/carousel/scroll-controller.ts @@ -0,0 +1,176 @@ +import { debounce } from 'src/internal/debounce'; +import { prefersReducedMotion } from 'src/internal/animate'; +import { waitForEvent } from 'src/internal/event'; +import type { ReactiveController, ReactiveElement } from 'lit'; + +interface ScrollHost extends ReactiveElement { + scrollContainer: HTMLElement; +} + +/** + * A controller for handling scrolling and mouse dragging. + */ +export class ScrollController implements ReactiveController { + private host: T; + private pointers = new Set(); + + dragging = false; + scrolling = false; + mouseDragging = false; + + constructor(host: T) { + this.host = host; + + host.addController(this); + + this.handleScroll = this.handleScroll.bind(this); + this.handlePointerDown = this.handlePointerDown.bind(this); + this.handlePointerMove = this.handlePointerMove.bind(this); + this.handlePointerUp = this.handlePointerUp.bind(this); + this.handlePointerUp = this.handlePointerUp.bind(this); + this.handleTouchStart = this.handleTouchStart.bind(this); + this.handleTouchEnd = this.handleTouchEnd.bind(this); + } + + async hostConnected() { + const host = this.host; + await host.updateComplete; + + const scrollContainer = host.scrollContainer; + + scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true }); + scrollContainer.addEventListener('pointerdown', this.handlePointerDown); + scrollContainer.addEventListener('pointerup', this.handlePointerUp); + scrollContainer.addEventListener('pointercancel', this.handlePointerUp); + scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true }); + scrollContainer.addEventListener('touchend', this.handleTouchEnd); + } + + hostDisconnected(): void { + const host = this.host; + const scrollContainer = host.scrollContainer; + + scrollContainer.removeEventListener('scroll', this.handleScroll); + scrollContainer.removeEventListener('pointerdown', this.handlePointerDown); + scrollContainer.removeEventListener('pointerup', this.handlePointerUp); + scrollContainer.removeEventListener('pointercancel', this.handlePointerUp); + scrollContainer.removeEventListener('touchstart', this.handleTouchStart); + scrollContainer.removeEventListener('touchend', this.handleTouchEnd); + } + + handleScroll() { + if (!this.scrolling) { + this.scrolling = true; + this.host.requestUpdate(); + } + this.handleScrollEnd(); + } + + @debounce(100) + handleScrollEnd() { + if (!this.pointers.size) { + this.scrolling = false; + this.host.scrollContainer.dispatchEvent( + new CustomEvent('scrollend', { + bubbles: false, + cancelable: false + }) + ); + this.host.requestUpdate(); + } + } + + handlePointerDown(event: PointerEvent) { + if (event.pointerType === 'touch') { + return; + } + + const scrollContainer = this.host.scrollContainer; + this.pointers.add(event.pointerId); + scrollContainer.setPointerCapture(event.pointerId); + + if (this.mouseDragging && this.pointers.size === 1) { + event.preventDefault(); + scrollContainer.addEventListener('pointermove', this.handlePointerMove); + } + } + + handlePointerMove(event: PointerEvent) { + const host = this.host; + const scrollContainer = host.scrollContainer; + + if (scrollContainer.hasPointerCapture(event.pointerId)) { + if (!this.dragging) { + this.handleDragStart(); + } + + this.handleDrag(event); + } + } + + handlePointerUp(event: PointerEvent) { + const host = this.host; + const scrollContainer = host.scrollContainer; + + this.pointers.delete(event.pointerId); + scrollContainer.releasePointerCapture(event.pointerId); + + if (this.pointers.size === 0) { + this.handleDragEnd(); + } + } + + handleTouchEnd(event: TouchEvent) { + for (const touch of event.changedTouches) { + this.pointers.delete(touch.identifier); + } + } + + handleTouchStart(event: TouchEvent) { + for (const touch of event.touches) { + this.pointers.add(touch.identifier); + } + } + + handleDragStart() { + const host = this.host; + + this.dragging = true; + host.scrollContainer.style.setProperty('scroll-snap-type', 'unset'); + host.requestUpdate(); + } + + handleDrag(event: PointerEvent) { + this.host.scrollContainer.scrollBy({ + left: -event.movementX, + top: -event.movementY + }); + } + + async handleDragEnd() { + const host = this.host; + const scrollContainer = host.scrollContainer; + + scrollContainer.removeEventListener('pointermove', this.handlePointerMove); + this.dragging = false; + + const startLeft = scrollContainer.scrollLeft; + const startTop = scrollContainer.scrollTop; + + scrollContainer.style.removeProperty('scroll-snap-type'); + const finalLeft = scrollContainer.scrollLeft; + const finalTop = scrollContainer.scrollTop; + + scrollContainer.style.setProperty('scroll-snap-type', 'unset'); + scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' }); + scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); + + if (this.scrolling) { + await waitForEvent(scrollContainer, 'scrollend'); + } + + scrollContainer.style.removeProperty('scroll-snap-type'); + + host.requestUpdate(); + } +} diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index ebf44753..7a223d39 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -158,6 +158,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC return this.input.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.input.reportValidity(); diff --git a/src/components/color-picker/color-picker.test.ts b/src/components/color-picker/color-picker.test.ts index 2c646c16..844b1a06 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 afa08548..aba32a2b 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 4d1f9d29..240712ea 100644 --- a/src/components/details/details.test.ts +++ b/src/components/details/details.test.ts @@ -2,6 +2,8 @@ import { expect, fixture, html, waitUntil } from '@open-wc/testing'; import sinon from 'sinon'; import type SlDetails from './details'; +import type SlHideEvent from '../../events/sl-hide'; +import type SlShowEvent from '../../events/sl-show'; describe('', () => { it('should be visible with the open attribute', async () => { @@ -134,7 +136,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 +155,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/dropdown/dropdown.ts b/src/components/dropdown/dropdown.ts index 4c98af7d..48b2e21d 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 8d97c001..d366d841 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 9547aefe..59b7e76f 100644 --- a/src/components/icon/icon.ts +++ b/src/components/icon/icon.ts @@ -18,6 +18,8 @@ let parser: DOMParser; * * @event sl-load - Emitted when the icon has loaded. * @event sl-error - Emitted when the icon fails to load due to an error. + * + * @csspart svg - The internal SVG element. */ @customElement('sl-icon') export default class SlIcon extends ShoelaceElement { @@ -102,6 +104,7 @@ export default class SlIcon extends ShoelaceElement { const svgEl = doc.body.querySelector('svg'); if (svgEl !== null) { + svgEl.part.add('svg'); library?.mutator?.(svgEl); this.svg = svgEl.outerHTML; this.emit('sl-load'); diff --git a/src/components/input/input.styles.ts b/src/components/input/input.styles.ts index 79870d09..8a02483e 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 c72840aa..879b0089 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 5ee1ac9f..d56b0855 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 @@ -389,6 +377,11 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont return this.input.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.input.reportValidity(); @@ -447,8 +440,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont 'input--disabled': this.disabled, 'input--focused': this.hasFocus, 'input--empty': !this.value, - 'input--no-spin-buttons': this.noSpinButtons, - 'input--is-firefox': isFirefox + 'input--no-spin-buttons': this.noSpinButtons })} > diff --git a/src/components/menu-item/menu-item.styles.ts b/src/components/menu-item/menu-item.styles.ts index 8ee1fef6..15ff27a4 100644 --- a/src/components/menu-item/menu-item.styles.ts +++ b/src/components/menu-item/menu-item.styles.ts @@ -60,7 +60,7 @@ export default css` margin-inline-start: var(--sl-spacing-x-small); } - :host(:focus) { + :host(:focus-visible) { outline: none; } @@ -69,7 +69,7 @@ export default css` color: var(--sl-color-neutral-1000); } - :host(:focus) .menu-item { + :host(:focus-visible) .menu-item { outline: none; background-color: var(--sl-color-primary-600); color: var(--sl-color-neutral-0); @@ -93,7 +93,7 @@ export default css` @media (forced-colors: active) { :host(:hover:not([aria-disabled='true'])) .menu-item, - :host(:focus) .menu-item { + :host(:focus-visible) .menu-item { outline: dashed 1px SelectedItem; outline-offset: -1px; } diff --git a/src/components/menu/menu.test.ts b/src/components/menu/menu.test.ts index 358bb128..ea916fd4 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/popup/popup.ts b/src/components/popup/popup.ts index c9e7f4f4..1527e7e4 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/radio-group/radio-group.test.ts b/src/components/radio-group/radio-group.test.ts index a0307b56..4591c860 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'; @@ -283,7 +284,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 +299,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 2a9a8643..37eb5085 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -257,12 +257,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor return true; } - /** Sets a custom validation message. Pass an empty string to restore validity. */ - setCustomValidity(message = '') { - this.customValidityMessage = message; - this.errorMessage = message; - this.validationInput.setCustomValidity(message); - this.formControlController.updateValidity(); + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); } /** Checks for validity and shows the browser's validation message if the control is invalid. */ @@ -284,6 +281,14 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor return isValid; } + /** Sets a custom validation message. Pass an empty string to restore validity. */ + setCustomValidity(message = '') { + this.customValidityMessage = message; + this.errorMessage = message; + this.validationInput.setCustomValidity(message); + this.formControlController.updateValidity(); + } + render() { const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); diff --git a/src/components/range/range.ts b/src/components/range/range.ts index 70bf9948..bb125f1e 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -255,6 +255,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont return this.input.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.input.reportValidity(); diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index 099ded92..6976fc86 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -162,6 +162,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 aa1e2b72..d8a03f9a 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. @@ -252,8 +253,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon this.setSelectedOptions(this.currentOption); } - this.emit('sl-input'); - this.emit('sl-change'); + // Emit after updating + this.updateComplete.then(() => { + this.emit('sl-input'); + this.emit('sl-change'); + }); if (!this.multiple) { this.hide(); @@ -377,9 +381,13 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon if (this.value !== '') { this.setSelectedOptions([]); this.displayInput.focus({ preventScroll: true }); - this.emit('sl-clear'); - this.emit('sl-input'); - this.emit('sl-change'); + + // Emit after update + this.updateComplete.then(() => { + this.emit('sl-clear'); + this.emit('sl-input'); + this.emit('sl-change'); + }); } } @@ -405,8 +413,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true })); if (this.value !== oldValue) { - this.emit('sl-input'); - this.emit('sl-change'); + // Emit after updating + this.updateComplete.then(() => { + this.emit('sl-input'); + this.emit('sl-change'); + }); } if (!this.multiple) { @@ -441,13 +452,17 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon } } - private handleTagRemove(event: CustomEvent, option: SlOption) { + private handleTagRemove(event: SlRemoveEvent, option: SlOption) { event.stopPropagation(); if (!this.disabled) { this.toggleOptionSelection(option, false); - this.emit('sl-input'); - this.emit('sl-change'); + + // Emit after updating + this.updateComplete.then(() => { + this.emit('sl-input'); + this.emit('sl-change'); + }); } } @@ -629,6 +644,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon return this.valueInput.checkValidity(); } + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.valueInput.reportValidity(); @@ -749,7 +769,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon ?pill=${this.pill} size=${this.size} removable - @sl-remove=${(event: CustomEvent) => this.handleTagRemove(event, option)} + @sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)} > ${option.getTextLabel()} diff --git a/src/components/spinner/spinner.styles.ts b/src/components/spinner/spinner.styles.ts index 21e474af..22932b26 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/switch/switch.ts b/src/components/switch/switch.ts index 0a3dbaf9..06ddfafe 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 aef6b151..215928e8 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 85152cd9..0ec05357 100644 --- a/src/components/tab-group/tab-group.ts +++ b/src/components/tab-group/tab-group.ts @@ -316,6 +316,9 @@ export default class SlTabGroup extends ShoelaceElement { this.tabs = this.getAllTabs({ includeDisabled: false }); this.panels = this.getAllPanels(); this.syncIndicator(); + + // After updating, show or hide scroll controls as needed + this.updateComplete.then(() => this.updateScrollControls()); } @watch('noScrollControls', { waitUntilFirstUpdate: true }) diff --git a/src/components/tab/tab.test.ts b/src/components/tab/tab.test.ts index 479de2df..fa277344 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 `); + 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 closeButton = el.shadowRoot!.querySelector('[part~="close-button"]')!; - const spy = sinon.spy(); + const handleClose = sinon.spy(); + const handleTabShow = sinon.spy(); - el.addEventListener('sl-close', spy, { once: true }); + tabGroup.addEventListener('sl-close', handleClose, { once: true }); + // The sl-tab-show event shouldn't be emitted when clicking the close button + tabGroup.addEventListener('sl-tab-show', handleTabShow); closeButton.click(); + await closeButton?.updateComplete; - expect(spy.called).to.equal(true); + expect(handleClose.called).to.equal(true); + expect(handleTabShow.called).to.equal(false); }); }); }); diff --git a/src/components/tab/tab.ts b/src/components/tab/tab.ts index 961d0c73..9db04c54 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 d0fb1b3f..20ed4e8a 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/tree.ts b/src/components/tree/tree.ts index 8fac04a7..60878355 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 ``. diff --git a/src/events/events.ts b/src/events/events.ts new file mode 100644 index 00000000..03dc0d86 --- /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 00000000..c3ddc7d1 --- /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 00000000..91be5690 --- /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 00000000..e507a3b9 --- /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 00000000..9bc1813d --- /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 00000000..e042cad9 --- /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 00000000..8df4ef3d --- /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 00000000..b3352c56 --- /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 00000000..17a0d6dc --- /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 00000000..5f72ea43 --- /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 00000000..194ec3b2 --- /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 00000000..c227504e --- /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 00000000..57b823a9 --- /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 00000000..91c5788c --- /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 00000000..438be58e --- /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 00000000..36b2f271 --- /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 00000000..289f77b6 --- /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 00000000..587e74ce --- /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 00000000..98f4bed5 --- /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 00000000..33ccbbb2 --- /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 00000000..5cf21580 --- /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 00000000..2acfe341 --- /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 00000000..4d5f76ee --- /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 00000000..f9b80d0a --- /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 00000000..d135fd23 --- /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 00000000..25d60c54 --- /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 00000000..c52c391a --- /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 00000000..a4fc9be3 --- /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 00000000..cfed91c8 --- /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 00000000..07920c5e --- /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 00000000..641fa061 --- /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 00000000..cbbc2902 --- /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 00000000..6f6f81de --- /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 00000000..16c6a0b5 --- /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 00000000..59920eaa --- /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 00000000..4646b3d4 --- /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 b5892c80..25d6c8e0 100644 --- a/src/internal/form.ts +++ b/src/internal/form.ts @@ -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/shoelace-element.ts b/src/internal/shoelace-element.ts index b4cac07e..87aae8be 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,7 +103,7 @@ export interface ShoelaceFormControl extends ShoelaceElement { defaultChecked?: boolean; form?: string; - // Standard validation attributes + // Constraint validation attributes pattern?: string; min?: number | Date; max?: number | Date; @@ -40,13 +112,13 @@ export interface ShoelaceFormControl extends ShoelaceElement { minlength?: number; maxlength?: number; - // Validation properties + // Form validation properties readonly validity: ValidityState; readonly validationMessage: string; - // Validation methods + // Form validation methods checkValidity: () => boolean; - /** Checks for validity and shows the browser's validation message if the control is invalid. */ + getForm: () => HTMLFormElement | null; reportValidity: () => boolean; setCustomValidity: (message: string) => void; } diff --git a/src/shoelace.ts b/src/shoelace.ts index a325a42c..ed52dba8 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 48f26a21..2fdf1700 100644 --- a/src/translations/da.ts +++ b/src/translations/da.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'Ryd indtastning', close: 'Luk', copy: 'Kopier', + currentValue: 'Nuværende værdi', + goToSlide: (slide, count) => `Gå til dias ${slide} af ${count}`, + hidePassword: 'Skjul adgangskode', + loading: 'Indlæser', + nextSlide: 'Næste slide', numOptionsSelected: (num: number) => { if (num === 0) return 'Ingen valgt'; if (num === 1) return '1 valgt'; return `${num} valgt`; }, - currentValue: 'Nuværende værdi', - hidePassword: 'Skjul adgangskode', - loading: 'Indlæser', + previousSlide: 'Forrige dias', progress: 'Status', remove: 'Fjern', resize: 'Tilpas størrelse', diff --git a/src/translations/de.ts b/src/translations/de.ts index c65b9224..fd8d98ee 100644 --- a/src/translations/de.ts +++ b/src/translations/de.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'Eingabe löschen', close: 'Schließen', copy: 'Kopieren', + currentValue: 'Aktueller Wert', + goToSlide: (slide, count) => `Gehen Sie zu Folie ${slide} von ${count}`, + hidePassword: 'Passwort verbergen', + loading: 'Wird geladen', + nextSlide: 'Nächste Folie', numOptionsSelected: num => { if (num === 0) return 'Keine Optionen ausgewählt'; if (num === 1) return '1 Option ausgewählt'; return `${num} Optionen ausgewählt`; }, - currentValue: 'Aktueller Wert', - hidePassword: 'Passwort verbergen', - loading: 'Wird geladen', + previousSlide: 'Vorherige Folie', progress: 'Fortschritt', remove: 'Entfernen', resize: 'Größe ändern', diff --git a/src/translations/en.ts b/src/translations/en.ts index 9dd8511f..8999eb22 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'Clear entry', close: 'Close', copy: 'Copy', + currentValue: 'Current value', + goToSlide: (slide, count) => `Go to slide ${slide} of ${count}`, + hidePassword: 'Hide password', + loading: 'Loading', + nextSlide: 'Next slide', numOptionsSelected: num => { if (num === 0) return 'No options selected'; if (num === 1) return '1 option selected'; return `${num} options selected`; }, - currentValue: 'Current value', - hidePassword: 'Hide password', - loading: 'Loading', + previousSlide: 'Previous slide', progress: 'Progress', remove: 'Remove', resize: 'Resize', diff --git a/src/translations/es.ts b/src/translations/es.ts index c1469238..2c8ebb78 100644 --- a/src/translations/es.ts +++ b/src/translations/es.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'Borrar entrada', close: 'Cerrar', copy: 'Copiar', + currentValue: 'Valor actual', + goToSlide: (slide, count) => `Ir a la diapositiva ${slide} de ${count}`, + hidePassword: 'Ocultar contraseña', + loading: 'Cargando', + nextSlide: 'Siguiente diapositiva', numOptionsSelected: num => { if (num === 0) return 'No hay opciones seleccionadas'; if (num === 1) return '1 opción seleccionada'; return `${num} opción seleccionada`; }, - currentValue: 'Valor actual', - hidePassword: 'Ocultar contraseña', - loading: 'Cargando', + previousSlide: 'Diapositiva anterior', progress: 'Progreso', remove: 'Eliminar', resize: 'Cambiar el tamaño', diff --git a/src/translations/fa.ts b/src/translations/fa.ts index f4e7a773..9f80d118 100644 --- a/src/translations/fa.ts +++ b/src/translations/fa.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'پاک کردن ورودی', close: 'بستن', copy: 'رونوشت', + currentValue: 'مقدار فعلی', + goToSlide: (slide, count) => `رفتن به اسلاید ${slide} از ${count}`, + hidePassword: 'پنهان کردن رمز', + loading: 'بارگذاری', + nextSlide: 'اسلاید بعدی', numOptionsSelected: num => { if (num === 0) return 'هیچ گزینه ای انتخاب نشده است'; if (num === 1) return '1 گزینه انتخاب شده است'; return `${num} گزینه انتخاب شده است`; }, - currentValue: 'مقدار فعلی', - hidePassword: 'پنهان کردن رمز', - loading: 'بارگذاری', + previousSlide: 'اسلاید قبلی', progress: 'پیشرفت', remove: 'حذف', resize: 'تغییر اندازه', diff --git a/src/translations/fr.ts b/src/translations/fr.ts index 93bd5e97..c70a04d5 100644 --- a/src/translations/fr.ts +++ b/src/translations/fr.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: `Effacer l'entrée`, close: 'Fermer', copy: 'Copier', + currentValue: 'Valeur actuelle', + goToSlide: (slide, count) => `Aller à la diapositive ${slide} de ${count}`, + hidePassword: 'Masquer le mot de passe', + loading: 'Chargement', + nextSlide: 'Diapositive suivante', numOptionsSelected: num => { if (num === 0) return 'Aucune option sélectionnée'; if (num === 1) return '1 option sélectionnée'; return `${num} options sélectionnées`; }, - currentValue: 'Valeur actuelle', - hidePassword: 'Masquer le mot de passe', - loading: 'Chargement', + previousSlide: 'Diapositive précédente', progress: 'Progrès', remove: 'Retirer', resize: 'Redimensionner', diff --git a/src/translations/he.ts b/src/translations/he.ts index bc50a2a8..3bac5351 100644 --- a/src/translations/he.ts +++ b/src/translations/he.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'נקה קלט', close: 'סגור', copy: 'העתק', + currentValue: 'ערך נוכחי', + goToSlide: (slide, count) => `עבור לשקופית ${slide} של ${count}`, + hidePassword: 'הסתר סיסמא', + loading: 'טוען', + nextSlide: 'Next slide', numOptionsSelected: num => { if (num === 0) return 'לא נבחרו אפשרויות'; if (num === 1) return 'נבחרה אפשרות אחת'; return `נבחרו ${num} אפשרויות`; }, - currentValue: 'ערך נוכחי', - hidePassword: 'הסתר סיסמא', - loading: 'טוען', + previousSlide: 'Previous slide', progress: 'התקדמות', remove: 'לְהַסִיר', resize: 'שנה גודל', diff --git a/src/translations/hu.ts b/src/translations/hu.ts index 8eec2fc4..76546c10 100644 --- a/src/translations/hu.ts +++ b/src/translations/hu.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'Bejegyzés törlése', close: 'Bezárás', copy: 'Másolás', + currentValue: 'Aktuális érték', + goToSlide: (slide, count) => `Ugrás a ${count}/${slide}. diára`, + hidePassword: 'Jelszó elrejtése', + loading: 'Betöltés', + nextSlide: 'Következő dia', numOptionsSelected: num => { if (num === 0) return 'Nincsenek kiválasztva opciók'; if (num === 1) return '1 lehetőség kiválasztva'; return `${num} lehetőség kiválasztva`; }, - currentValue: 'Aktuális érték', - hidePassword: 'Jelszó elrejtése', - loading: 'Betöltés', + previousSlide: 'Előző dia', progress: 'Folyamat', remove: 'Eltávolítás', resize: 'Átméretezés', diff --git a/src/translations/ja.ts b/src/translations/ja.ts index f1763547..b1996154 100644 --- a/src/translations/ja.ts +++ b/src/translations/ja.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'クリアエントリ', close: '閉じる', copy: 'コピー', + currentValue: '現在の価値', + goToSlide: (slide, count) => `${count} 枚中 ${slide} 枚のスライドに移動`, + hidePassword: 'パスワードを隠す', + loading: '読み込み中', + nextSlide: '次のスライド', numOptionsSelected: num => { if (num === 0) return 'オプションが選択されていません'; if (num === 1) return '1 つのオプションが選択されました'; return `${num} つのオプションが選択されました`; }, - currentValue: '現在の価値', - hidePassword: 'パスワードを隠す', - loading: '読み込み中', + previousSlide: '前のスライド', progress: '進行', remove: '削除', resize: 'サイズ変更', diff --git a/src/translations/nl.ts b/src/translations/nl.ts index 6a7fe354..30798b07 100644 --- a/src/translations/nl.ts +++ b/src/translations/nl.ts @@ -9,14 +9,17 @@ const translation: Translation = { 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', diff --git a/src/translations/pl.ts b/src/translations/pl.ts index b34f8995..79d1938d 100644 --- a/src/translations/pl.ts +++ b/src/translations/pl.ts @@ -9,14 +9,17 @@ const translation: Translation = { 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', diff --git a/src/translations/pt.ts b/src/translations/pt.ts index 3f62aa45..09595942 100644 --- a/src/translations/pt.ts +++ b/src/translations/pt.ts @@ -9,14 +9,17 @@ const translation: Translation = { 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', diff --git a/src/translations/ru.ts b/src/translations/ru.ts index 2d03690c..39af667d 100644 --- a/src/translations/ru.ts +++ b/src/translations/ru.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: 'Очистить запись', close: 'Закрыть', copy: 'Скопировать', + currentValue: 'Текущее значение', + goToSlide: (slide, count) => `Перейти к слайду ${slide} из ${count}`, + hidePassword: 'Скрыть пароль', + loading: 'Загрузка', + nextSlide: 'Следующий слайд', numOptionsSelected: num => { if (num === 0) return 'выбрано 0 вариантов'; if (num === 1) return 'Выбран 1 вариант'; return `выбрано ${num} варианта`; }, - currentValue: 'Текущее значение', - hidePassword: 'Скрыть пароль', - loading: 'Загрузка', + previousSlide: 'Предыдущий слайд', progress: 'Прогресс', remove: 'Удалить', resize: 'Изменить размер', diff --git a/src/translations/sv.ts b/src/translations/sv.ts index 204d9574..343b5b86 100644 --- a/src/translations/sv.ts +++ b/src/translations/sv.ts @@ -9,14 +9,17 @@ const translation: Translation = { 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', diff --git a/src/translations/tr.ts b/src/translations/tr.ts index e0da1add..77299473 100644 --- a/src/translations/tr.ts +++ b/src/translations/tr.ts @@ -9,14 +9,17 @@ const translation: Translation = { 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', diff --git a/src/translations/zh-tw.ts b/src/translations/zh-tw.ts index 35dcaa43..c579f78c 100644 --- a/src/translations/zh-tw.ts +++ b/src/translations/zh-tw.ts @@ -9,14 +9,17 @@ const translation: Translation = { clearEntry: '清空', close: '關閉', copy: '複製', + currentValue: '當前值', + goToSlide: (slide, count) => `轉到第 ${slide} 張幻燈片,共 ${count} 張`, + hidePassword: '隱藏密碼', + loading: '載入中', + nextSlide: '下一張幻燈片', numOptionsSelected: num => { if (num === 0) return '未選擇任何項目'; if (num === 1) return '已選擇 1 個項目'; return `${num} 選擇項目`; }, - currentValue: '當前值', - hidePassword: '隱藏密碼', - loading: '載入中', + previousSlide: '上一張幻燈片', progress: '進度', remove: '移除', resize: '調整大小', diff --git a/src/utilities/localize.ts b/src/utilities/localize.ts index edc153a2..e6ef29a0 100644 --- a/src/utilities/localize.ts +++ b/src/utilities/localize.ts @@ -16,10 +16,13 @@ export interface Translation extends DefaultTranslation { 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;