Compare commits

...

154 Commits

Author SHA1 Message Date
Cory LaViska
df25f8617b 2.3.0 2023-03-09 16:19:23 -05:00
Cory LaViska
ad2099a27f update version 2023-03-09 16:19:13 -05:00
Martin Alix
708127f96d Update French for Slide # (#1231) 2023-03-09 16:10:06 -05:00
Cory LaViska
9deb51e95a update docs 2023-03-09 16:09:33 -05:00
Cory LaViska
67852ea657 update installation docs 2023-03-07 16:52:02 -05:00
Cory LaViska
7240f4f8f4 Merge branch 'next' into autoload 2023-03-07 14:05:26 -05:00
Cory LaViska
17ee89a5e8 rename variable for clarity 2023-03-07 13:23:02 -05:00
Cory LaViska
f2177dccaf closes #1226 2023-03-07 11:03:03 -05:00
Cory LaViska
6aaf17b81a fixes #1224 2023-03-06 17:11:39 -05:00
dhellgartner
d113d13792 Fixed the avatar tests to produce less logs (#1222)
The reason for the problems is that the error event does
not escape from the shadow dom.
Thus it cannot be awaited for in the test

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-03-06 08:30:01 -05:00
Cory LaViska
ab9cb5f185 update changelog 2023-03-03 10:56:55 -05:00
Cory LaViska
76fd7aa28d trigger update immediately 2023-03-03 10:55:53 -05:00
Cory LaViska
8f17bf4e9d Improve Carousel Accessibility (#1218)
* fix demo

* improve accessibility, reorg, and polish up

* add support for up/down

* fix docs

* update docs
2023-03-03 10:53:17 -05:00
Cory LaViska
0f0f71af9b add custom-elements.json to exports 2023-03-03 10:36:30 -05:00
Cory LaViska
e624701022 fixes #1220 2023-03-03 10:16:15 -05:00
Cory LaViska
4cedfc3201 fix check 2023-03-02 11:43:09 -05:00
Cory LaViska
d88d9fc81a update example 2023-03-02 11:43:00 -05:00
Cory LaViska
051baa4ff5 remove warning 2023-03-02 11:27:47 -05:00
Cory LaViska
57c3d7009b fix example 2023-03-02 11:27:42 -05:00
Cory LaViska
a27fd4d2e9 Merge branch 'next' into autoload 2023-03-02 10:49:30 -05:00
Cory LaViska
79ac425e2b fix demo 2023-03-01 12:59:14 -05:00
Cory LaViska
857f318f9c fix overscroll (#1217) 2023-03-01 11:48:16 -05:00
Cory LaViska
c0966bf767 remove unused property 2023-03-01 11:35:25 -05:00
Cory LaViska
86cecc9e30 fix carousel pagination in iOS 2023-03-01 11:34:42 -05:00
Alessandro
ec036d8e61 fix(carousel): various fixes and improvements (#1216)
* fix(carousel): don't resume autoplay if interacting

* fix(carousel): wrap pagination items

* chore(carousel): add unit tests

* feat(carousel): more reactive pagination dots

* fix(carousel): trigger scrollend when user scroll exactly over a snap point
2023-03-01 11:05:29 -05:00
Cory LaViska
77b25f4581 add tag parts to <sl-select> 2023-03-01 10:58:24 -05:00
Cory LaViska
a8d59b3329 update changelog 2023-02-28 17:11:21 -05:00
Cory LaViska
5990fbd000 2.2.0 2023-02-28 17:05:11 -05:00
Cory LaViska
954d78dcd1 update version 2023-02-28 17:04:59 -05:00
Cory LaViska
3ea31389dd fixes #1082 2023-02-28 13:33:34 -05:00
Cory LaViska
d79799043a remove unused import 2023-02-28 13:07:18 -05:00
Cory LaViska
9f8ce58288 use clickOnElement 2023-02-28 13:07:07 -05:00
Cory LaViska
2371c5490f Merge branch 'next' into autoload 2023-02-28 12:45:23 -05:00
Cory LaViska
77abd42d66 fix popup positioning edge case; closes #1135 2023-02-28 12:30:05 -05:00
Cory LaViska
218e78e947 add getForm() method; closes #1180 2023-02-28 12:10:14 -05:00
Cory LaViska
8a1efac9b8 fixes #1201 2023-02-28 12:03:20 -05:00
Cory LaViska
f9ae8327f6 fix menu focus color 2023-02-28 09:46:49 -05:00
Cory LaViska
7f3076d195 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-02-27 16:40:55 -05:00
Cory LaViska
1fa79e64ae fix track color in dark mode 2023-02-27 16:40:53 -05:00
Bünyamin Eskiocak
dde1010465 Fixed clipped calendar toggle in Firefox (#1213)
* fixed clipped calendar toggle in firefox

* changelog

---------

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
2023-02-27 12:04:32 -05:00
Cory LaViska
3a3a7347bc link to event 2023-02-27 11:15:57 -05:00
Cory LaViska
77c9750206 fix sl-tab-show event when closing 2023-02-27 11:13:59 -05:00
Cory LaViska
3d2e618be8 fix term 2023-02-24 16:08:06 -05:00
Cory LaViska
79feaae7fc adjust attribution 2023-02-24 15:56:31 -05:00
Cory LaViska
b0f7dfb86b update comment 2023-02-24 15:55:02 -05:00
Cory LaViska
e1979b8f38 reorder custom properties 2023-02-24 15:54:57 -05:00
Cory LaViska
7e9ae32b9b add carousel terms 2023-02-24 15:48:39 -05:00
Cory LaViska
480a1df246 reorder 2023-02-24 15:24:39 -05:00
Cory LaViska
ff798adb49 update examples 2023-02-24 15:10:54 -05:00
Cory LaViska
70a64262e9 add svg part 2023-02-24 12:51:46 -05:00
Cory LaViska
5f65896150 update localize 2023-02-24 12:35:25 -05:00
Cory LaViska
c69db4919b wip; improve carousel docs 2023-02-23 16:50:18 -05:00
Cory LaViska
a526e8a956 cleanup 2023-02-23 15:00:27 -05:00
Cory LaViska
4970ba065e set a default aspect ratio 2023-02-23 15:00:02 -05:00
Cory LaViska
0292ed30c5 update example 2023-02-23 14:59:48 -05:00
Cory LaViska
b64b1c2536 fix test 2023-02-23 14:49:49 -05:00
Cory LaViska
8f9eb012ba sort imports 2023-02-23 14:42:58 -05:00
Cory LaViska
c8fd9f19d2 ignore lorem ipsum 2023-02-23 14:40:05 -05:00
Cory LaViska
603aa93322 update and fix typos 2023-02-23 14:39:05 -05:00
Cory LaViska
74203de094 sort imports 2023-02-23 14:23:38 -05:00
Cory LaViska
4fa4682a45 Merge branch 'alenaksu-feat/carousel' into next 2023-02-23 14:20:01 -05:00
Cory LaViska
34e0fb2fc1 Merge branch 'feat/carousel' of github.com:alenaksu/shoelace into alenaksu-feat/carousel 2023-02-23 14:13:19 -05:00
Cory LaViska
50972f2b38 update changelog and comment 2023-02-23 11:33:31 -05:00
Cory LaViska
652ce6c9f1 Merge branch 'mpharoah-mpharoah/typescript-events' into next 2023-02-23 11:26:47 -05:00
Cory LaViska
8412b150b2 Merge branch 'mpharoah/typescript-events' of github.com:mpharoah/shoelace into mpharoah-mpharoah/typescript-events 2023-02-23 11:24:19 -05:00
Justin Fagnani
22b8ef4edf Fix a few spelling issues (#1192) 2023-02-23 11:15:20 -05:00
Cory LaViska
0865dede6f fix heading 2023-02-23 11:11:19 -05:00
Cory LaViska
d638d811ad remove unused type 2023-02-23 11:04:51 -05:00
Cory LaViska
bc58472b7b fix skipped tests 2023-02-23 11:02:26 -05:00
Cory LaViska
226c856b1e update scroll controls when adding tabs; fixes #1208 2023-02-23 10:12:36 -05:00
Cory LaViska
a127b8722e fix autoload timing issues 2023-02-22 14:18:43 -05:00
Cory LaViska
9c573fb454 add autoloader to docs 2023-02-22 14:18:36 -05:00
Cory LaViska
a346d18930 add autoloader docs 2023-02-22 14:18:19 -05:00
Cory LaViska
a32488baeb add autoloader prototype 2023-02-22 14:18:04 -05:00
Cory LaViska
a4131caeda add subpath 2023-02-22 14:16:11 -05:00
Cory LaViska
6c62a4f4c0 use passive listeners 2023-02-22 12:54:33 -05:00
Cory LaViska
5b12de1edf fixes #1205 2023-02-21 11:54:39 -05:00
Cory LaViska
f79a670ca3 fix padding in Chrome; closes #1197 2023-02-17 09:56:52 -05:00
Cory LaViska
e1ec60af62 2.1.0 2023-02-16 16:40:34 -05:00
Cory LaViska
dcbcc4c050 bump version 2023-02-16 16:39:11 -05:00
Cory LaViska
0eb3375bb9 welcome back, null 2023-02-16 16:30:13 -05:00
Cory LaViska
c26a8810c8 add crude inline error example; closes #1191 2023-02-16 16:22:46 -05:00
Cory LaViska
872227e345 reformat and add comment 2023-02-16 15:19:24 -05:00
dhellgartner
f22c529eab Avoid null logs on resize observer errors (#1196)
* Resize observer sometimes throws errors which
are nothing to worry about, see also the corresponding
comment on tab-group.test.ts
* Unfortunately, the web testing library installs an
error event handler which takes precedence before the
event handlers installed in the tests
(see node_modules/@web/browser-logs/dist/logUncaughtErrors.js)
* the only possibility to avoid these null logs is to install
an error event handler at an even earlier place

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-02-16 15:13:57 -05:00
dhellgartner
3430b33c3e Alert test (#1189)
* Improved tests for SlAlert

* added more test for coverage
* Grouped tests in multiple subgroups

* remove executing only one tests

* Fix the now executing tests

---------

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-02-16 15:04:11 -05:00
Cory LaViska
1bc2a6ef76 expose rel; fixes #1200 2023-02-16 12:15:59 -05:00
Cory LaViska
5a94f5bf5b update changelog 2023-02-16 10:43:28 -05:00
Thomas Blum
4277377189 Underscore was missing (#1195) 2023-02-16 10:39:01 -05:00
Matt Pharoah
d818980dea Fixed copy-paste type in comment 2023-02-15 09:41:05 -05:00
Matt Pharoah
636f61006f Adjusted comment to be more clear 2023-02-15 09:39:43 -05:00
Matt Pharoah
d93e698baf Added comments to explain the Typescript metaprogramming 2023-02-15 09:37:48 -05:00
Matt Pharoah
f8d8291caa Added sl-invalid event type 2023-02-14 23:06:47 -05:00
Matt Pharoah
21bef1c2ea Merge branch 'next' into mpharoah/typescript-events 2023-02-14 22:59:20 -05:00
Cory LaViska
f0efb9253c remove inline validation examples 2023-02-14 16:38:10 -05:00
Cory LaViska
cfd28f2608 update changelog 2023-02-14 15:24:19 -05:00
Cory LaViska
a3844fe074 comments 2023-02-14 15:21:52 -05:00
Cory LaViska
65e90f12f4 rename event 2023-02-14 15:12:21 -05:00
Cory LaViska
4335289d6a make doc comments consistent for check/report validity 2023-02-14 15:02:52 -05:00
Cory LaViska
86cc721e03 update description and document sl-invalid event 2023-02-14 14:59:34 -05:00
xdev1
4a28825ea7 Added some missing form validation standard features (implemented for #1181) (#1167)
* #1163 - added read-only properties 'validity' and 'validationMessage' to all nine form controls

* #1163 - added base support for showing form validation messages below the form controls

* #1163 - animated validation errors in demo

* #1181 - Removed all previous changes that have been validation error specific

* Started with 'Inline validation' demo / fixed merge issues / etc.

* #1181 - continued work on missing form validation features

* #1181 - enhanced validation support for SlColorPicker / some cleanup

* #1181 - fixed CSS issues

* #1181 - fixed again CSS issues

* '1181 - added form validation features finally working

* #1181 - bug fixes

* #1181 - fixed open issues / added API doc comments

* #1181 - updated inline validation demos / removed some legacy code

* #1181 - finished invalid form validation example

* #1181 - added tests / several bugfixes

* #1181 - fixed typos etc.

* #1181 - tests

* #1181 - tests

* #1181 - tests
2023-02-14 14:50:06 -05:00
Cory LaViska
19cf823da5 Merge branch 'ceymard-popup-element' into next 2023-02-14 09:30:47 -05:00
Christophe Eymard
737b55d78d allow Element as the anchor - now with correct typings 2023-02-10 21:56:57 +01:00
Cory LaViska
8493131db5 Revert "let popup be anchored to Element and not HTMLElement (#1186)"
This reverts commit 0d86c2af37.
2023-02-10 12:42:19 -05:00
Christophe Eymard
0d86c2af37 let popup be anchored to Element and not HTMLElement (#1186)
It works with SVG as well, is there a need to be restrictive here ?
2023-02-10 12:38:47 -05:00
Matt Pharoah
d6a7820a52 Make emit return the actual event type instead of CustomEvent<any> 2023-02-09 15:56:54 -05:00
Matt Pharoah
39ca1208f5 Removed unused event handler from sl-dropdown 2023-02-09 13:09:31 -05:00
Matt Pharoah
610a06bcb9 Use PropertyKey instead of string where appropriate 2023-02-09 13:06:17 -05:00
Matt Pharoah
b8584c0581 Require detail to always be provided when calling emit with an event that requires it 2023-02-09 13:00:04 -05:00
Matt Pharoah
ab19afeb66 Resolved merge conflict 2023-02-08 18:24:17 -05:00
Matt Pharoah
41b5cb367f Use typed events in components and tests 2023-02-08 18:19:27 -05:00
Matt Pharoah
e65b09fdec Fixed Typescript error when CustomEvents with non-object details are registered 2023-02-08 17:55:28 -05:00
Matt Pharoah
15a4049a01 Require the options parameter to have a detail property for events with details 2023-02-08 17:42:14 -05:00
Matt Pharoah
ce708fbba8 Perform type checking of the event detail in the emit function if the event type matches a Shoelace event 2023-02-08 17:01:00 -05:00
Matt Pharoah
75bd7784fb Basic events have an empty details object, not a null 2023-02-08 14:12:15 -05:00
Matt Pharoah
6e092ccf7a Added event types for all events 2023-02-08 13:55:19 -05:00
Matt Pharoah
b7b73ea3a9 Added sl-request-close event 2023-02-08 13:30:56 -05:00
Matt Pharoah
9dab91e0d1 Added event for sl-error 2023-02-08 13:17:12 -05:00
Matt Pharoah
a3a802a37b Register events with global event map 2023-02-08 13:14:43 -05:00
Matt Pharoah
358ad7bb30 Fixed type in documentation (TreeItem[] -> SlTreeItem[]) 2023-02-07 17:44:56 -05:00
Matt Pharoah
0a555c53c7 Export typescript types for events with details 2023-02-07 17:20:01 -05:00
Cory LaViska
b260a4dc29 add focus/blur to color picker 2023-02-07 17:18:03 -05:00
Cory LaViska
1f1024f4ca change default; #1175 2023-02-07 15:57:50 -05:00
Cory LaViska
9e92d92684 whitespace 2023-02-07 15:56:43 -05:00
Cory LaViska
527bf79973 improve user interaction heuristics; closes #1175 2023-02-07 15:29:26 -05:00
Cory LaViska
b281c5bbc1 use Set instead of WeakMap 2023-02-07 13:56:02 -05:00
Cory LaViska
f03de8925b fix checkbox required label in Chrome
See https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
2023-02-07 10:52:54 -05:00
Cory LaViska
776ab2c715 add getForm() method 2023-02-07 09:01:32 -05:00
Cory LaViska
df967b7e84 improve checked state in forced-colors mode; fixes #1114 2023-02-06 18:00:39 -05:00
Cory LaViska
a539058253 validate even with novalidate; fixes #1164 2023-02-06 17:18:01 -05:00
Cory LaViska
af70d88153 improve icon page; fixes #1122 2023-02-06 15:19:08 -05:00
Cory LaViska
8dcffe270f remove 16.x from actions 2023-02-06 12:30:22 -05:00
Cory LaViska
c958f2e50a move focus logic; #1177 2023-02-06 12:20:32 -05:00
Cory LaViska
cedcd65c72 fix dropdown keyboard controls; closes #1177 2023-02-06 12:18:33 -05:00
Cory LaViska
12f62075ad move escape close logic to document listener; #1177 2023-02-06 11:32:06 -05:00
Cory LaViska
b8695b70a9 don't emit click when disabled; fixes #1113 2023-02-06 10:48:38 -05:00
Cory LaViska
a4e371618a fix comment 2023-02-06 10:47:08 -05:00
Cory LaViska
039ab175c3 add comment 2023-02-06 10:46:37 -05:00
Cory LaViska
7549e50fe4 fix positioning of native inputs; closes #1169 2023-02-05 12:06:06 -05:00
Cory LaViska
3c2cda699e fixes #1179 2023-02-05 11:08:57 -05:00
Cory LaViska
8685ddd049 fix select template; fixes #1178 2023-02-05 10:53:51 -05:00
Cory LaViska
c47ad40802 fixes #1172 2023-02-05 10:49:10 -05:00
Cory LaViska
ef1f129b22 fix test 2023-02-03 14:44:56 -05:00
Cory LaViska
6bb508ef14 revert; #1166 2023-02-03 14:36:17 -05:00
Cory LaViska
3596c8144d add dist to custom-elements.json; fixes #1166 2023-02-03 14:27:17 -05:00
Cory LaViska
20903bb638 update test 2023-02-02 12:23:32 -05:00
Alan Chambers
f45fb6848f Adjust sl-dropdown up/down keypress menuItems array to match menu (#1170)
* dropdown filtered menu items

* updated filter, added test

- updated filter to match menu getAllItems private method
- added test

---------

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
2023-02-02 11:53:38 -05:00
Cory LaViska
400f9b76d5 ignore non-menu items; fixes #1165 2023-02-02 11:47:00 -05:00
dhellgartner
38a9e98d9b Tab group tests (#1128)
* remove duplicate test

* Add tests for sl-tab-group -- initial round of tests

* use individual fixtures for each test

* extract mocks + utility functions in external files

* remove unnecessary internals of intersection observer from the mock

* added first test on scroll buttons

* add scrolling tests

* remove resize observer mock

Resize observer is triggered but waiting for element
to be updated is not enough. You need to free the main thread
with the test for some time

* Also removed intersection observer mock

By waiting long enough for the things to happen automatically

* Fix problems with resize observer

These problems appeared after npm ci but (according
to the sources linked in the comments) unproblematic

* Handle merge request comments

* replace custom wait function with corresponding function
 from openwc/testing
* Extracted waitForScrollingToEnd and isElementVisibleFromScrolling into
dedicated files to be reused
* Improve queryByTestId --> make it usable for more complex values
* Add js docs

* run lint fix

* Added tests for selecting a tab by click

* added further tests for tab group selection

* use Promise<void> instead of Promise<any>

to avoid eslint errors

---------

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-01-27 09:55:28 -05:00
Cory LaViska
e8fe783fb4 fixes #1157 2023-01-26 14:22:04 -05:00
Cory LaViska
223ef32b70 fix web-types.json 2023-01-26 09:47:33 -05:00
Cory LaViska
0793a219a2 fix play/pause buttons; fixes #1147 2023-01-25 16:52:30 -05:00
Cory LaViska
3bb92c095f add state 2023-01-24 11:46:37 -05:00
alenaksu
48ccc95dd9 chore: add react examples 2023-01-08 13:36:24 +01:00
Alessandro
c6a6a77bbd feat: add carousel component
feat: add nav indicators

wip

wip

wip

fix: minor fixes

fix: minor fixes

fix: some refactor

chore: update docs

chore: update docs

fix: remove slide component

feat: create sl-carousel-item

feat: code refactoring and improvements

chore: update docs with more examples

chore: fix docs

feat: add autoplay

feat: implement accessibility

fix: change icons for rtl

chore: minor change

feat: improve accessibility

fix: minor regression

fix: minor regression

chore: fix docs

fix: improve accessibility and minor fixes

fix: remove heading and refactor component

chore: add custom style exmaple

fix: address review commnets

* Removed header from carousel
* Added `ArrowUp` and `ArrowDown` in keyboard navigation
* Added `--scroll-hint-margin` css property
* Added an example with customized carousel layout
* Fixed thumbnails navigation in demo
* Renamed show-controls to show-arrows and updated the corresponding parts/css accordingly
* Changed `activeSlideElement` getter to a private method
* Changed pagination colors
* Added `--slide-width` and `--slide-height` css properties

chore: update docs

fix: integrate latest repo changes

fix: add aspect ratio and rebase

chore: remove ignore path

feat: multiple slides per page

feat: multiple slide per page

fix: various improvements

chore: minor changes

chore: minor changes

chore: add bit of documentation

chore: improve documentation

fix: add unit tests and fix minor issues

chore: update documentation and unit tests

chore: update tests
2023-01-08 12:24:24 +01:00
156 changed files with 6284 additions and 513 deletions

View File

@@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
node-version: [16.x, 18.x]
node-version: [18.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:

1
.gitignore vendored
View File

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

View File

@@ -9,6 +9,9 @@
"atrule",
"autocorrect",
"autofix",
"autoload",
"autoloader",
"autoloading",
"autoplay",
"bezier",
"boxicons",
@@ -27,6 +30,7 @@
"Consolas",
"contenteditable",
"copydir",
"Cotte",
"coverpage",
"crossorigin",
"crutchcorn",
@@ -35,6 +39,7 @@
"datetime",
"describedby",
"Docsify",
"dogfood",
"dropdowns",
"easings",
"enterkeyhint",
@@ -53,6 +58,7 @@
"FOUC",
"FOUCE",
"fullscreen",
"gestern",
"giga",
"globby",
"Grayscale",
@@ -70,10 +76,12 @@
"jsonata",
"keydown",
"keyframes",
"Kool",
"labelledby",
"Laravel",
"LaViska",
"listbox",
"listitem",
"litelement",
"lowercasing",
"Lucide",
@@ -106,9 +114,13 @@
"rgba",
"roadmap",
"Roboto",
"roledescription",
"Sapan",
"saturationl",
"Schilp",
"scrollbars",
"scrollend",
"scroller",
"Segoe",
"semibold",
"slotchange",
@@ -121,6 +133,7 @@
"tabpanel",
"templating",
"tera",
"testid",
"textareas",
"textfield",
"tinycolor",
@@ -143,6 +156,7 @@
"ignorePaths": [
"package.json",
"package-lock.json",
"docs/assets/examples/include.html",
".vscode/**",
"src/translations/!(en).ts",
"**/*.min.js"

View File

@@ -1,8 +1,8 @@
import fs from 'fs';
import { generateCustomData } from 'cem-plugin-vs-code-custom-data-generator';
import commandLineArgs from 'command-line-args';
import { parse } from 'comment-parser';
import { pascalCase } from 'pascal-case';
import commandLineArgs from 'command-line-args';
import fs from 'fs';
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const { name, description, version, author, homepage, license } = packageData;

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -436,6 +436,9 @@
result += `
## Importing
If you're using the autoloader or the traditional loader, you can ignore this section. Otherwise, feel free to
use any of the following snippets to [cherry pick](getting-started/installation#cherry-picking) this component.
<sl-tab-group>
<sl-tab slot="nav" panel="script">Script</sl-tab>
<sl-tab slot="nav" panel="import">Import</sl-tab>

View File

@@ -8,6 +8,17 @@ html {
box-sizing: inherit;
}
/* Show custom elements only after they're registered */
:not(:defined),
:not(:defined) * {
opacity: 0;
}
:defined {
opacity: 1;
transition: 0.1s opacity;
}
body {
font-family: var(--sl-font-sans);
font-size: var(--sl-font-size-medium);

View File

@@ -0,0 +1,81 @@
# Carousel Item
[component-header:sl-carousel-item]
```html preview
<sl-carousel pagination>
<sl-carousel-item>
<img
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
src="/assets/examples/carousel/mountains.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
src="/assets/examples/carousel/waterfall.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
src="/assets/examples/carousel/sunset.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
src="/assets/examples/carousel/field.jpg"
/>
</sl-carousel-item>
<sl-carousel-item>
<img
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
src="/assets/examples/carousel/valley.jpg"
/>
</sl-carousel-item>
</sl-carousel>
```
```jsx react
import { SlCarousel, SlCarouselItem } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlCarousel pagination>
<SlCarouselItem>
<img
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
src="/assets/examples/carousel/mountains.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
src="/assets/examples/carousel/waterfall.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
src="/assets/examples/carousel/sunset.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
src="/assets/examples/carousel/field.jpg"
/>
</SlCarouselItem>
<SlCarouselItem>
<img
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
src="/assets/examples/carousel/valley.jpg"
/>
</SlCarouselItem>
</SlCarousel>
);
```
?> Additional demonstrations can be found in the [carousel examples](/components/carousel).
[component-metadata:sl-carousel-item]

1221
docs/components/carousel.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -100,7 +100,8 @@ Use the `setCustomValidity()` method to set a custom validation message. This wi
const errorMessage = `Don't forget to check me!`;
// Set initial validity as soon as the element is defined
customElements.whenDefined('sl-checkbox').then(() => {
customElements.whenDefined('sl-checkbox').then(async () => {
await checkbox.updateComplete;
checkbox.setCustomValidity(errorMessage);
});

View File

@@ -64,7 +64,7 @@ const App = () => (
### Getting the Selected Item
When dropdowns are used with [menus](/components/menu), you can listen for the `sl-select` event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
When dropdowns are used with [menus](/components/menu), you can listen for the [`sl-select`](/components/menu#events) event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
```html preview
<div class="dropdown-selection">

View File

@@ -531,7 +531,7 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
</div>
```
## Tabler Icons
### Tabler Icons
This will register the [Tabler Icons](https://tabler-icons.io/) library using the jsDelivr CDN. This library features over 1,950 open source icons.
@@ -635,6 +635,19 @@ If you want to change the icons Shoelace uses internally, you can register an ic
<!-- Supporting scripts and styles for the search utility -->
<script>
function wrapWithTooltip(item) {
const tooltip = document.createElement('sl-tooltip');
tooltip.content = item.getAttribute('data-name');
// Close open tooltips
document.querySelectorAll('.icon-list sl-tooltip[open]').forEach(tooltip => tooltip.hide());
// Wrap it with a tooltip and trick it into showing up
item.parentNode.insertBefore(tooltip, item);
tooltip.appendChild(item);
requestAnimationFrame(() => tooltip.dispatchEvent(new MouseEvent('mouseover')));
}
fetch('/dist/assets/icons/icons.json')
.then(res => res.json())
.then(icons => {
@@ -658,19 +671,23 @@ If you want to change the icons Shoelace uses internally, you can register an ic
<use xlink:href="/assets/icons/sprite.svg#${i.name}"></use>
</svg>
`;
list.appendChild(item);
const tooltip = document.createElement('sl-tooltip');
tooltip.content = i.name;
tooltip.appendChild(item);
list.appendChild(tooltip);
// Wrap it with a tooltip the first time the mouse lands on it. We do this instead of baking them into the DOM
// to improve this page's performance. See: https://github.com/shoelace-style/shoelace/issues/1122
item.addEventListener('mouseover', () => wrapWithTooltip(item), { once: true });
// Copy on click
item.addEventListener('click', () => {
const tooltip = item.closest('sl-tooltip');
copyInput.value = i.name;
copyInput.select();
document.execCommand('copy');
tooltip.content = 'Copied!';
setTimeout(() => tooltip.content = i.name, 1000);
if (tooltip) {
tooltip.content = 'Copied!';
setTimeout(() => tooltip.content = i.name, 1000);
}
});
});

View File

@@ -247,7 +247,7 @@ Use [CSS parts](#css-parts) to customize the way form controls are drawn. This e
<style>
.label-on-left {
--label-width: 60px;
--label-width: 3.75rem;
--gap-width: 1rem;
}
@@ -267,8 +267,7 @@ Use [CSS parts](#css-parts) to customize the way form controls are drawn. This e
}
.label-on-left::part(form-control-help-text) {
grid-column: span 2;
padding-left: calc(var(--label-width) + var(--gap-width));
grid-column-start: 2;
}
</style>
```

View File

@@ -17,8 +17,10 @@ QR codes are useful for providing small pieces of information to users who can q
const qrCode = container.querySelector('sl-qr-code');
const input = container.querySelector('sl-input');
input.value = qrCode.value;
input.addEventListener('sl-input', () => (qrCode.value = input.value));
customElements.whenDefined('sl-qr-code').then(() => {
input.value = qrCode.value;
input.addEventListener('sl-input', () => (qrCode.value = input.value));
});
</script>
<style>

View File

@@ -175,7 +175,7 @@ Use the `setCustomValidity()` method to set a custom validation message. This wi
const errorMessage = 'You must choose the last option';
// Set initial validity as soon as the element is defined
customElements.whenDefined('sl-radio-group').then(() => {
customElements.whenDefined('sl-radio').then(() => {
radioGroup.setCustomValidity(errorMessage);
});

View File

@@ -295,13 +295,15 @@ This example demonstrates custom validation styles using `data-user-invalid` and
required
></sl-input>
<sl-select label="Favorite Animal" help-text="Select the best option." clearable required>
<sl-select name="animal" label="Favorite Animal" help-text="Select the best option." clearable required>
<sl-option value="birds">Birds</sl-option>
<sl-option value="cats">Cats</sl-option>
<sl-option value="dogs">Dogs</sl-option>
<sl-option value="other">Other</sl-option>
</sl-select>
<sl-checkbox value="accept" required>Accept terms and conditions</sl-checkbox>
<sl-button type="submit" variant="primary">Submit</sl-button>
<sl-button type="reset" variant="default">Reset</sl-button>
</form>
@@ -316,46 +318,172 @@ This example demonstrates custom validation styles using `data-user-invalid` and
<style>
.validity-styles sl-input,
.validity-styles sl-select {
.validity-styles sl-select,
.validity-styles sl-checkbox {
display: block;
margin-bottom: var(--sl-spacing-medium);
}
/* user invalid styles */
.validity-styles sl-input[data-user-invalid]::part(base),
.validity-styles sl-select[data-user-invalid]::part(combobox) {
.validity-styles sl-select[data-user-invalid]::part(combobox),
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
}
.validity-styles [data-user-invalid]::part(form-control-label),
.validity-styles [data-user-invalid]::part(form-control-help-text) {
.validity-styles [data-user-invalid]::part(form-control-help-text),
.validity-styles sl-checkbox[data-user-invalid]::part(label) {
color: var(--sl-color-danger-700);
}
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
outline: none;
}
.validity-styles sl-input:focus-within[data-user-invalid]::part(base),
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox) {
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox),
.validity-styles sl-checkbox:focus-within[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
}
/* User valid styles */
.validity-styles sl-input[data-user-valid]::part(base),
.validity-styles sl-select[data-user-valid]::part(combobox) {
.validity-styles sl-select[data-user-valid]::part(combobox),
.validity-styles sl-checkbox[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
}
.validity-styles [data-user-valid]::part(form-control-label),
.validity-styles [data-user-valid]::part(form-control-help-text) {
.validity-styles [data-user-valid]::part(form-control-help-text),
.validity-styles sl-checkbox[data-user-valid]::part(label) {
color: var(--sl-color-success-700);
}
.validity-styles sl-checkbox[data-user-valid]::part(control) {
background-color: var(--sl-color-success-600);
outline: none;
}
.validity-styles sl-input:focus-within[data-user-valid]::part(base),
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox) {
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox),
.validity-styles sl-checkbox:focus-within[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
}
</style>
```
## Inline Form Validation
By default, Shoelace form controls use the browser's tooltip-style error messages. No mechanism is provided to show errors inline, as there are too many opinions on how that would work when combined with native form controls and other custom elements. You can, however, implement your own solution using the following technique.
To disable the browser's error messages, you need to cancel the `sl-invalid` event. Then you can apply your own inline validation errors. This example demonstrates a primitive way to do this.
```html preview
<form class="inline-validation">
<sl-input
name="name"
label="Name"
help-text="What would you like people to call you?"
autocomplete="off"
required
></sl-input>
<div id="name-error" aria-live="polite" hidden></div>
<sl-button type="submit" variant="primary">Submit</sl-button>
<sl-button type="reset" variant="default">Reset</sl-button>
</form>
<script>
const form = document.querySelector('.inline-validation');
const nameError = document.querySelector('#name-error');
// A form control is invalid
form.addEventListener(
'sl-invalid',
event => {
// Suppress the browser's constraint validation message
event.preventDefault();
nameError.textContent = `Error: ${event.target.validationMessage}`;
nameError.hidden = false;
event.target.focus();
},
{ capture: true } // you must use capture since sl-invalid doesn't bubble!
);
// Handle form submit
form.addEventListener('submit', event => {
event.preventDefault();
nameError.hidden = true;
nameError.textContent = '';
setTimeout(() => alert('All fields are valid'), 50);
});
// Handle form reset
form.addEventListener('reset', event => {
nameError.hidden = true;
nameError.textContent = '';
});
</script>
<style>
#name-error {
font-size: var(--sl-input-help-text-font-size-medium);
color: var(--sl-color-danger-700);
}
#name-error ~ sl-button {
margin-top: var(--sl-spacing-medium);
}
.inline-validation sl-input {
display: block;
}
/* user invalid styles */
.inline-validation sl-input[data-user-invalid]::part(base) {
border-color: var(--sl-color-danger-600);
}
.inline-validation [data-user-invalid]::part(form-control-label),
.inline-validation [data-user-invalid]::part(form-control-help-text) {
color: var(--sl-color-danger-700);
}
.inline-validation sl-input:focus-within[data-user-invalid]::part(base) {
border-color: var(--sl-color-danger-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
}
/* User valid styles */
.inline-validation sl-input[data-user-valid]::part(base) {
border-color: var(--sl-color-success-600);
}
.inline-validation [data-user-valid]::part(form-control-label),
.inline-validation [data-user-valid]::part(form-control-help-text) {
color: var(--sl-color-success-700);
}
.inline-validation sl-checkbox[data-user-valid]::part(control) {
background-color: var(--sl-color-success-600);
outline: none;
}
.inline-validation sl-input:focus-within[data-user-valid]::part(base) {
border-color: var(--sl-color-success-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
}
</style>
```
!> This example is meant to demonstrate the concept of providing your own error messages inline. It is not intended to scale to more complex forms. Users who want this functionality are encouraged to build a more appropriate validation solution using the techniques shown below. Depending on how you implement this feature, custom error messages may affect the accessibility of your form controls.
## Getting Associated Form Controls
At this time, using [`HTMLFormElement.elements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) will not return Shoelace form controls because the browser is unaware of their status as custom element form controls. Fortunately, Shoelace provides an `elements()` function that does something very similar. However, instead of returning an [`HTMLFormControlsCollection`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection), it returns an array of HTML and Shoelace form controls in the order they appear in the DOM.

View File

@@ -1,32 +1,50 @@
# Installation
You can use Shoelace via CDN or by installing it locally. You can also [cherry pick](#cherry-picking) individual components for faster load times.
If you're using a framework, make sure to check out the pages for [React](/frameworks/react), [Vue](/frameworks/vue), and [Angular](/frameworks/angular).
You can load Shoelace via CDN or by installing it locally. If you're using a framework, make sure to check out the pages for [React](/frameworks/react), [Vue](/frameworks/vue), and [Angular](/frameworks/angular) for additional information.
## CDN Installation (Easiest)
The easiest way to install Shoelace is with the CDN. Just add the following tags to your page to get all components and the default light theme.
<sl-tab-group>
<sl-tab slot="nav" panel="autoloader" active>Autoloader</sl-tab>
<sl-tab slot="nav" panel="traditional">Traditional Loader</sl-tab>
<sl-tab-panel name="autoloader">
The experimental autoloader is the easiest and most efficient way to use Shoelace. A lightweight script watches the DOM for unregistered Shoelace elements and lazy loads them for you — even if they're added dynamically.
While convenient, autoloading may lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/). The linked article describes some ways to alleviate it.
<!-- prettier-ignore -->
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace-autoloader.js"></script>
```
</sl-tab-panel>
<sl-tab-panel name="traditional">
The traditional CDN loader registers all Shoelace elements up front. Note that, if you're only using a handful of components, it will be much more efficient to stick with the autoloader. However, you can also [cherry pick](#cherry-picking) components if you want to load specific ones up front.
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```
?> If you're only using a handful of components, it will be more efficient to [cherry pick](#cherry-picking) the ones you need.
</sl-tab-panel>
</sl-tab-group>
### Dark Theme
If you prefer to use the [dark theme](/getting-started/themes#dark-theme) instead, use this code and add `<html class="sl-theme-dark">` to the page.
The code above will load the light theme. If you want to use the [dark theme](/getting-started/themes#dark-theme) instead, update the stylesheet as shown below and add `<html class="sl-theme-dark">` to your page.
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/dark.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```
### Light & Dark Theme
If you want to load the light or dark theme based on the user's `prefers-color-scheme` setting, use this. The `media` attributes ensure that only the user's preferred theme stylesheet loads and the `onload` attribute sets the appropriate [theme class](/getting-started/themes) on the `<html>` element.
If you want to load the light or dark theme based on the user's `prefers-color-scheme` setting, use the stylesheets below. The `media` attributes ensure that only the user's preferred theme stylesheet loads and the `onload` attribute sets the appropriate [theme class](/getting-started/themes) on the `<html>` element.
```html
<link
@@ -40,7 +58,6 @@ If you want to load the light or dark theme based on the user's `prefers-color-s
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/dark.css"
onload="document.documentElement.classList.add('sl-theme-dark');"
/>
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```
Now you can [start using Shoelace!](/getting-started/usage)
@@ -68,7 +85,7 @@ Alternatively, [you can use a bundler](#bundling).
## Setting the Base Path
Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` and will "just work" for most users.
Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` or `shoelace-autoloader.js` and will "just work" for most users.
However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Shoelace, you'll need to set the base path. You can do this one of two ways.
@@ -88,9 +105,7 @@ However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Sh
## Cherry Picking
The previous approach is the _easiest_ way to load Shoelace, but easy isn't always efficient. You'll incur the full size of the library even if you only use a handful of components. This is convenient for prototyping or if you're using most of the components, but it may result in longer load times in production. To improve this, you can cherry pick the components you need.
Cherry picking can be done from your local install or [directly from the CDN](https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/). This will limit the number of files the browser has to download and reduce the amount of bytes being transferred. The disadvantage is that you need to load components manually.
Cherry picking can be done from [the CDN](#cdn-installation-easiest) or your [local installation](#local-installation). This approach will load only the components you need up front, while limiting the number of files the browser has to download. The disadvantage is that you need to import each individual component.
Here's an example that loads only the button component. Again, if you're not using a module resolver, you'll need to adjust the path to point to the folder Shoelace is in.

View File

@@ -32,9 +32,10 @@ Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska).
Add the following code to your page.
<!-- prettier-ignore -->
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace-autoloader.js"></script>
```
Now you have access to all of Shoelace's components! Try adding a button:
@@ -43,7 +44,7 @@ Now you have access to all of Shoelace's components! Try adding a button:
<sl-button>Click me</sl-button>
```
?> This will load all of Shoelace's components, but you should probably only load the ones you're actually using. To learn how, or for other ways to install Shoelace, refer to the [installation instructions](getting-started/installation).
?> This will activate Shoelace's experimental autoloader, which registers components on the fly as you use them. To learn more about it, or for other ways to install Shoelace, refer to the [installation instructions](getting-started/installation).
## New to Web Components?

View File

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

View File

@@ -39,9 +39,9 @@
<!-- Import Shoelace -->
<link rel="stylesheet" href="/dist/themes/light.css" />
<link rel="stylesheet" href="/dist/themes/dark.css" />
<script type="module" src="/dist/shoelace.js"></script>
<script type="module" src="/dist/shoelace-autoloader.js"></script>
</head>
<body data-shoelace="/dist">
<body>
<div id="app"></div>
<script>
// Set the initial theme to prevent flashing

View File

@@ -6,12 +6,68 @@ Components with the <sl-badge variant="warning" pill>Experimental</sl-badge> bad
New versions of Shoelace are released as-needed and generally occur when a critical mass of changes have accumulated. At any time, you can see what's coming in the next release by visiting [next.shoelace.style](https://next.shoelace.style).
## 2.3.0
- Added an experimental autoloader
- Added the `subpath` argument to `getBasePath()` to make it easier to generate full paths to any file
- Added `custom-elements.json` to package exports
- Added `tag__base`, `tag__content`, `tag__remove-button`, `tag__remove-button__base` parts to `<sl-select>`
- Fixed a bug in `<sl-rating>` that allowed the `sl-change` event to be emitted when disabled [#1220](https://github.com/shoelace-style/shoelace/issues/1220)
- Fixed a regression in `<sl-input>` that caused `min` and `max` to stop working when `type="date"` [#1224](https://github.com/shoelace-style/shoelace/issues/1224)
- Improved accessibility of `<sl-carousel>` [#1218](https://github.com/shoelace-style/shoelace/pull/1218)
- Improved `<sl-option>` so it converts non-string values to strings for convenience [#1226](https://github.com/shoelace-style/shoelace/issues/1226)
- Updated the docs to dogfood the autoloader
## 2.2.0
- Added TypeScript types to all custom events [#1183](https://github.com/shoelace-style/shoelace/pull/1183)
- Added the `svg` part to `<sl-icon>`
- Added the `getForm()` method to all form controls [#1180](https://github.com/shoelace-style/shoelace/issues/1180)
- Added the experimental carousel component [#851](https://github.com/shoelace-style/shoelace/pull/851)
- Fixed a bug in `<sl-select>` that caused the display label to render incorrectly in Chrome after form validation [#1197](https://github.com/shoelace-style/shoelace/discussions/1197)
- Fixed a bug in `<sl-input>` that prevented users from applying their own value for `autocapitalize`, `autocomplete`, and `autocorrect` when using `type="password` [#1205](https://github.com/shoelace-style/shoelace/issues/1205)
- Fixed a bug in `<sl-tab-group>` that prevented scroll controls from showing when dynamically adding tabs [#1208](https://github.com/shoelace-style/shoelace/issues/1208)
- Fixed a big in `<sl-input>` that caused the calendar icon to be clipped in Firefox [#1213](https://github.com/shoelace-style/shoelace/pull/1213)
- Fixed a bug in `<sl-tab>` that caused `sl-tab-show` to be emitted when activating the close button
- Fixed a bug in `<sl-spinner>` that caused `--track-color` to be invisible with certain colors
- Fixed a bug in `<sl-menu-item>` that caused the focus color to show when selecting menu items with a mouse or touch device
- Fixed a bug in `<sl-select>` that caused `sl-change` and `sl-input` to be emitted too early [#1201](https://github.com/shoelace-style/shoelace/issues/1201)
- Fixed a positioning edge case that caused `<sl-popup>` to positioned nested popups incorrectly [#1135](https://github.com/shoelace-style/shoelace/issues/1135)
- Fixed a bug in `<sl-tree>` that caused the tree item to collapse when clicking a child item, dragging the mouse, and releasing it on the parent node [#1082](https://github.com/shoelace-style/shoelace/issues/1082)
- Updated `@shoelace-style/localize` to 3.1.0
- Updated `@floating-ui/dom` to 1.2.1
When using `<input type="password">` the default value for `autocapitalize`, `autocomplete`, and `autocorrect` may be affected due to the bug fixed in [#1205](https://github.com/shoelace-style/shoelace/issues/1205). For any affected users, setting these attributes to `off` will restore the previous behavior.
## 2.1.0
- Added the `sl-focus` and `sl-blur` events to `<sl-color-picker>`
- Added the `focus()` and `blur()` methods to `<sl-color-picker>`
- Added the `sl-invalid` event to all form controls to enable custom validation logic [#1167](https://github.com/shoelace-style/shoelace/pull/1167)
- Added `validity` and `validationMessage` properties to all form controls [#1167](https://github.com/shoelace-style/shoelace/pull/1167)
- Added the `rel` attribute to `<sl-button>` to allow users to create button links that point to specific targets [#1200](https://github.com/shoelace-style/shoelace/issues/1200)
- Fixed a bug in `<sl-animated-image>` where the play and pause buttons were transposed [#1147](https://github.com/shoelace-style/shoelace/issues/1147)
- Fixed a bug that prevented `web-types.json` from being generated [#1154](https://github.com/shoelace-style/shoelace/discussions/1154)
- Fixed a bug in `<sl-color-picker>` that prevented `sl-change` and `sl-input` from emitting when using the eye dropper [#1157](https://github.com/shoelace-style/shoelace/issues/1157)
- Fixed a bug in `<sl-dropdown>` that prevented keyboard users from selecting menu items when using the keyboard [#1165](https://github.com/shoelace-style/shoelace/issues/1165)
- Fixed a bug in the template for `<sl-select>` that caused the `form-control-help-text` part to not be in the same location as other form controls [#1178](https://github.com/shoelace-style/shoelace/issues/1178)
- Fixed a bug in `<sl-checkbox>` and `<sl-switch>` that caused the browser to scroll incorrectly when focusing on a control in a container with overflow [#1169](https://github.com/shoelace-style/shoelace/issues/1169)
- Fixed a bug in `<sl-menu-item>` that caused the `click` event to be emitted when the item was disabled [#1113](https://github.com/shoelace-style/shoelace/issues/1113)
- Fixed a bug in form controls that erroneously prevented validation states from being set when `novalidate` was used on the containing form [#1164](https://github.com/shoelace-style/shoelace/issues/1164)
- Fixed a bug in `<sl-checkbox>` that caused the required asterisk to appear before the label in Chrome
- Fixed a bug that prevented large form control labels from having the correct font size [#1195](https://github.com/shoelace-style/shoelace/pull/1195)
- Improved the behavior of `<sl-dropdown>` in Safari so keyboard interaction works the same as in other browsers [#1177](https://github.com/shoelace-style/shoelace/issues/1177)
- Improved the [icons](/components/icon) page so it's not as sluggish in Safari [#1122](https://github.com/shoelace-style/shoelace/issues/1122)
- Improved the accessibility of `<sl-switch>` when used in forced-colors / Windows High Contrast mode [#1114](https://github.com/shoelace-style/shoelace/issues/1114)
- Improved user interaction heuristics for all form controls [#1175](https://github.com/shoelace-style/shoelace/issues/1175)
## 2.0.0
This is the first stable release of Shoelace 2, meaning breaking changes to the API will no longer be accepted for this version. Development of Shoelace 2.0 started in January 2020. The first beta was released on [July 15, 2020](https://github.com/shoelace-style/shoelace/releases/tag/v2.0.0-beta.1). Since then, Shoelace has grown quite a bit! Here are some stats from the project as of January 24, 2023:
- 55 components have been built
- [Over 2,500 commits](https://github.com/shoelace-style/shoelace/commits/next) have been made to the project
- [88 beta versions](https://github.com/shoelace-style/shoelace/tags) have been released
- [85 people](https://github.com/shoelace-style/shoelace/graphs/contributors) have contributed to the project
- [669 issues](https://github.com/shoelace-style/shoelace/issues?q=is%3Aissue+is%3Aclosed) have been filed on GitHub
- [274 pull requests](https://github.com/shoelace-style/shoelace/pulls) have been opened
@@ -250,8 +306,7 @@ This release removes the `<sl-responsive-media>` component. When this component
- Fixed a bug in `<sl-tree>` that prevented the keyboard from working when the component was nested in a shadow root [#871](https://github.com/shoelace-style/shoelace/issues/871)
- Fixed a bug in `<sl-tab-group>` that prevented the keyboard from working when the component was nested in a shadow root [#872](https://github.com/shoelace-style/shoelace/issues/872)
- Fixed a bug in `<sl-tab>` that allowed disabled tabs to erroneously receive focus
- Improved single selection in `<sl-tree>` so nodes expand and collapse and rece
ive selection when clicking on the label
- Improved single selection in `<sl-tree>` so nodes expand and collapse and receive selection when clicking on the label
- Renamed `expanded-icon` and `collapsed-icon` slots to `expand-icon` and `collapse-icon` in the experimental `<sl-tree>` and `<sl-tree-item>` components
- Improved RTL support for `<sl-image-comparer>`
- Refactored components to extend from `ShoelaceElement` to make `dir` and `lang` reactive properties in all components

73
package-lock.json generated
View File

@@ -1,19 +1,20 @@
{
"name": "@shoelace-style/shoelace",
"version": "2.0.0",
"version": "2.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@shoelace-style/shoelace",
"version": "2.0.0",
"version": "2.3.0",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
"@floating-ui/dom": "^1.1.0",
"@floating-ui/dom": "^1.2.1",
"@lit-labs/react": "^1.1.1",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.0.4",
"@shoelace-style/localize": "^3.1.0",
"composed-offset-position": "^0.0.4",
"lit": "^2.6.1",
"qr-creator": "^1.0.0"
},
@@ -30,7 +31,7 @@
"@web/test-runner-playwright": "^0.9.0",
"bootstrap-icons": "^1.10.3",
"browser-sync": "^2.27.11",
"cem-plugin-vs-code-custom-data-generator": "^1.3.2",
"cem-plugin-vs-code-custom-data-generator": "^1.4.1",
"chalk": "^5.2.0",
"command-line-args": "^5.2.1",
"comment-parser": "^1.3.1",
@@ -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",
@@ -4005,9 +4006,9 @@
}
},
"node_modules/cem-plugin-vs-code-custom-data-generator": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.3.2.tgz",
"integrity": "sha512-1ytpSc3KhS/c1IZ5G03FXJlaCuJ+WZ653w9SXqhABhbpoiQmzioa1Ds0UGHC1vIGT43yoyLMriX+YTg2ZXiuwg==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.4.1.tgz",
"integrity": "sha512-mulzg6I2wJVNKCM9ml4ttxTnGK25kHHdkhX979vbrKwSIIplFnPOgGa0Sj14pQWnfDwbGr6pSbLgBmi4nVHFxA==",
"dev": true,
"dependencies": {
"prettier": "^2.7.1"
@@ -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",
@@ -18545,9 +18551,9 @@
}
},
"cem-plugin-vs-code-custom-data-generator": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.3.2.tgz",
"integrity": "sha512-1ytpSc3KhS/c1IZ5G03FXJlaCuJ+WZ653w9SXqhABhbpoiQmzioa1Ds0UGHC1vIGT43yoyLMriX+YTg2ZXiuwg==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.4.1.tgz",
"integrity": "sha512-mulzg6I2wJVNKCM9ml4ttxTnGK25kHHdkhX979vbrKwSIIplFnPOgGa0Sj14pQWnfDwbGr6pSbLgBmi4nVHFxA==",
"dev": true,
"requires": {
"prettier": "^2.7.1"
@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.0.0",
"version": "2.3.0",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",
@@ -14,6 +14,7 @@
"types": "./dist/shoelace.d.ts",
"import": "./dist/shoelace.js"
},
"./dist/custom-elements.json": "./dist/custom-elements.json",
"./dist/themes/*": "./dist/themes/*",
"./dist/components/*": "./dist/components/*",
"./dist/utilities/*": "./dist/utilities/*",
@@ -63,10 +64,11 @@
},
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
"@floating-ui/dom": "^1.1.0",
"@floating-ui/dom": "^1.2.1",
"@lit-labs/react": "^1.1.1",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.0.4",
"@shoelace-style/localize": "^3.1.0",
"composed-offset-position": "^0.0.4",
"lit": "^2.6.1",
"qr-creator": "^1.0.0"
},
@@ -83,7 +85,7 @@
"@web/test-runner-playwright": "^0.9.0",
"bootstrap-icons": "^1.10.3",
"browser-sync": "^2.27.11",
"cem-plugin-vs-code-custom-data-generator": "^1.3.2",
"cem-plugin-vs-code-custom-data-generator": "^1.4.1",
"chalk": "^5.2.0",
"command-line-args": "^5.2.1",
"comment-parser": "^1.3.1",

View File

@@ -51,6 +51,8 @@ fs.mkdirSync(outdir, { recursive: true });
//
// The whole shebang
'./src/shoelace.ts',
// The auto-loader
'./src/shoelace-autoloader.ts',
// Components
...(await globby('./src/components/**/!(*.(style|test)).ts')),
// Translations
@@ -120,6 +122,22 @@ fs.mkdirSync(outdir, { recursive: true });
routes: {
'/dist': './dist'
}
},
//
// Suppress Chrome's document.write() warning
//
// More info: https://github.com/BrowserSync/browser-sync/issues/1600)
//
snippetOptions: {
rule: {
match: /<\/head>/u,
fn: (snippet, match) => {
const {
groups: { src }
} = /src='(?<src>[^']+)'/u.exec(snippet);
return `<script src="${src}" async></script>${match}`;
}
}
}
};

View File

@@ -64,6 +64,7 @@ const jsonataExprString = `{
// Run the conversion
const expression = jsonata(jsonataExprString);
const result = await expression.evaluate(metadata);
console.log('Generating web types');
fs.writeFileSync(path.join(outdir, 'web-types.json'), JSON.stringify(expression.evaluate(metadata), null, 2), 'utf8');
fs.writeFileSync(path.join(outdir, 'web-types.json'), JSON.stringify(result, null, 2), 'utf8');

View File

@@ -1,91 +1,305 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { clickOnElement, moveMouseOnElement } from '../../internal/test';
import { queryByTestId } from '../../internal/test/data-testid-helpers';
import { resetMouse } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlAlert from './alert';
import type SlIconButton from '../icon-button/icon-button';
const getAlertContainer = (alert: SlAlert): HTMLElement => {
return alert.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
};
const expectAlertToBeVisible = (alert: SlAlert): void => {
const alertContainer = getAlertContainer(alert);
const style = window.getComputedStyle(alertContainer);
expect(style.display).not.to.equal('none');
expect(style.visibility).not.to.equal('hidden');
expect(style.visibility).not.to.equal('collapse');
};
const expectAlertToBeInvisible = (alert: SlAlert): void => {
const alertContainer = getAlertContainer(alert);
const style = window.getComputedStyle(alertContainer);
expect(style.display, 'alert should be invisible').to.equal('none');
};
const expectHideAndAfterHideToBeEmittedInCorrectOrder = async (alert: SlAlert, action: () => void | Promise<void>) => {
const hidePromise = oneEvent(alert, 'sl-hide');
const afterHidePromise = oneEvent(alert, 'sl-after-hide');
let afterHideHappened = false;
oneEvent(alert, 'sl-after-hide').then(() => (afterHideHappened = true));
action();
await hidePromise;
expect(afterHideHappened).to.be.false;
await afterHidePromise;
expectAlertToBeInvisible(alert);
};
const expectShowAndAfterShowToBeEmittedInCorrectOrder = async (alert: SlAlert, action: () => void | Promise<void>) => {
const showPromise = oneEvent(alert, 'sl-show');
const afterShowPromise = oneEvent(alert, 'sl-after-show');
let afterShowHappened = false;
oneEvent(alert, 'sl-after-show').then(() => (afterShowHappened = true));
action();
await showPromise;
expect(afterShowHappened).to.be.false;
await afterShowPromise;
expectAlertToBeVisible(alert);
};
const getCloseButton = (alert: SlAlert): SlIconButton | null | undefined =>
alert.shadowRoot?.querySelector<SlIconButton>('[part="close-button"]');
describe('<sl-alert>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
let clock: sinon.SinonFakeTimers | null = null;
expect(base.hidden).to.be.false;
afterEach(async () => {
clock?.restore();
await resetMouse();
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
it('renders', async () => {
const alert = await fixture<SlAlert>(html`<sl-alert open>I am an alert</sl-alert>`);
expect(base.hidden).to.be.true;
expectAlertToBeVisible(alert);
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
it('is accessible', async () => {
const alert = await fixture<SlAlert>(html`<sl-alert open>I am an alert</sl-alert>`);
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
await expect(alert).to.be.accessible();
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
describe('alert visibility', () => {
it('should be visible with the open attribute', async () => {
const alert = await fixture<SlAlert>(html`<sl-alert open>I am an alert</sl-alert>`);
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
expectAlertToBeVisible(alert);
});
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
it('should not be visible without the open attribute', async () => {
const alert = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert>`);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
expectAlertToBeInvisible(alert);
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const alert = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert>`);
expectAlertToBeInvisible(alert);
await expectShowAndAfterShowToBeEmittedInCorrectOrder(alert, () => alert.show());
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const alert = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert>`);
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => alert.hide());
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const alert = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
await expectShowAndAfterShowToBeEmittedInCorrectOrder(alert, () => {
alert.open = true;
});
});
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const alert = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
alert.open = false;
});
});
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
describe('close button', () => {
it('shows a close button if the alert has the closable attribute', () => async () => {
const alert = await fixture<SlAlert>(html` <sl-alert open closable>I am an alert</sl-alert> `);
const closeButton = getCloseButton(alert);
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.open = true;
expect(closeButton).to.be.visible;
});
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
it('clicking the close button closes the alert', () => async () => {
const alert = await fixture<SlAlert>(html` <sl-alert open closable>I am an alert</sl-alert> `);
const closeButton = getCloseButton(alert);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
clickOnElement(closeButton!);
});
});
});
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
describe('toast', () => {
const getToastStack = (): HTMLDivElement | null => document.querySelector<HTMLDivElement>('.sl-toast-stack');
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.open = false;
const closeRemainingAlerts = async (): Promise<void> => {
const toastStack = getToastStack();
if (toastStack?.children) {
for (const element of toastStack.children) {
await (element as SlAlert).hide();
}
}
};
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
beforeEach(async () => {
await closeRemainingAlerts();
});
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
it('can be rendered as a toast', async () => {
const alert = await fixture<SlAlert>(html`<sl-alert>I am an alert</sl-alert>`);
expectShowAndAfterShowToBeEmittedInCorrectOrder(alert, () => alert.toast());
const toastStack = getToastStack();
expect(toastStack).to.be.visible;
expect(toastStack?.firstChild).to.be.equal(alert);
});
it('resolves only after being closed', async () => {
const alert = await fixture<SlAlert>(html`<sl-alert closable>I am an alert</sl-alert>`);
const afterShowEvent = oneEvent(alert, 'sl-after-show');
let toastPromiseResolved = false;
alert.toast().then(() => (toastPromiseResolved = true));
await afterShowEvent;
expect(toastPromiseResolved).to.be.false;
const closePromise = oneEvent(alert, 'sl-after-hide');
const closeButton = getCloseButton(alert);
clickOnElement(closeButton!);
await closePromise;
await aTimeout(0);
expect(toastPromiseResolved).to.be.true;
});
const expectToastStack = () => {
const toastStack = getToastStack();
expect(toastStack).not.to.be.null;
};
const expectNoToastStack = () => {
const toastStack = getToastStack();
expect(toastStack).to.be.null;
};
const openToast = async (alert: SlAlert): Promise<void> => {
const openPromise = oneEvent(alert, 'sl-after-show');
alert.toast();
await openPromise;
};
const closeToast = async (alert: SlAlert): Promise<void> => {
const closePromise = oneEvent(alert, 'sl-after-hide');
const closeButton = getCloseButton(alert);
await clickOnElement(closeButton!);
await closePromise;
await aTimeout(0);
};
it('deletes the toast stack after the last alert is done', async () => {
const container = await fixture<HTMLElement>(html`<div>
<sl-alert data-testid="alert1" closable>alert 1</sl-alert>
<sl-alert data-testid="alert2" closable>alert 2</sl-alert>
</div>`);
const alert1 = queryByTestId<SlAlert>(container, 'alert1');
const alert2 = queryByTestId<SlAlert>(container, 'alert2');
await openToast(alert1!);
expectToastStack();
await openToast(alert2!);
expectToastStack();
await closeToast(alert1!);
expectToastStack();
await closeToast(alert2!);
expectNoToastStack();
});
});
describe('timer controlled closing', () => {
it('closes after a predefined amount of time', async () => {
clock = sinon.useFakeTimers();
const alert = await fixture<SlAlert>(html` <sl-alert open duration="3000">I am an alert</sl-alert>`);
expectAlertToBeVisible(alert);
clock.tick(2999);
expectAlertToBeVisible(alert);
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
clock?.tick(1);
});
});
it('resets the closing timer after mouse-over', async () => {
clock = sinon.useFakeTimers();
const alert = await fixture<SlAlert>(html` <sl-alert open duration="3000">I am an alert</sl-alert>`);
expectAlertToBeVisible(alert);
clock.tick(1000);
await moveMouseOnElement(alert);
clock.tick(2999);
expectAlertToBeVisible(alert);
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
clock?.tick(1);
});
});
it('resets the closing timer after opening', async () => {
clock = sinon.useFakeTimers();
const alert = await fixture<SlAlert>(html` <sl-alert duration="3000">I am an alert</sl-alert>`);
expectAlertToBeInvisible(alert);
clock.tick(1000);
const afterShowPromise = oneEvent(alert, 'sl-after-show');
alert.show();
await afterShowPromise;
clock.tick(2999);
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
clock?.tick(1);
});
});
});
describe('alert variants', () => {
const variants = ['primary', 'success', 'neutral', 'warning', 'danger'];
variants.forEach(variant => {
it(`adapts to the variant: ${variant}`, async () => {
const alert = await fixture<SlAlert>(html`<sl-alert variant="${variant}" open>I am an alert</sl-alert>`);
const alertContainer = getAlertContainer(alert);
expect(alertContainer).to.have.class(`alert--${variant}`);
});
});
});
});

View File

@@ -50,8 +50,8 @@ export default css`
opacity: 0;
}
:host([play]) slot[name='pause-icon'],
:host(:not([play])) slot[name='play-icon'] {
:host([play]) slot[name='play-icon'],
:host(:not([play])) slot[name='pause-icon'] {
display: none;
}
`;

View File

@@ -1,5 +1,4 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import type SlAvatar from './avatar';
// The default avatar background just misses AA contrast, but the next step up is way too dark. Since avatars aren't
@@ -113,23 +112,20 @@ describe('<sl-avatar>', () => {
});
it('should not render the image when the image fails to load', async () => {
const errorHandler = sinon.spy();
el = await fixture<SlAvatar>(html`<sl-avatar></sl-avatar>`);
el.addEventListener('error', errorHandler);
el.image = 'bad_image';
waitUntil(() => errorHandler.calledOnce);
await aTimeout(0);
await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
expect(el.shadowRoot!.querySelector('img')).to.be.null;
});
it('should show a valid image after being passed an invalid image initially', async () => {
const errorHandler = sinon.spy();
el = await fixture<SlAvatar>(html`<sl-avatar></sl-avatar>`);
el.addEventListener('error', errorHandler);
el.image = 'bad_image';
waitUntil(() => errorHandler.calledOnce);
await aTimeout(0);
await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
el.image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
await el.updateComplete;

View File

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

View File

@@ -48,7 +48,7 @@ export default css`
cursor: not-allowed;
}
/* When disabled, prevent mouse events from bubbling up */
/* When disabled, prevent mouse events from bubbling up from children */
.button--disabled * {
pointer-events: none;
}

View File

@@ -1,4 +1,5 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import sinon from 'sinon';
import type SlButton from './button';
@@ -116,6 +117,30 @@ describe('<sl-button>', () => {
expect(el.shadowRoot!.querySelector('a')).to.exist;
expect(el.shadowRoot!.querySelector('button')).not.to.exist;
});
it('should render a link with rel="noreferrer noopener" when target is set and rel is not', async () => {
const el = await fixture<SlButton>(
html` <sl-button href="https://example.com/" target="_blank">Link</sl-button> `
);
const link = el.shadowRoot!.querySelector('a')!;
expect(link?.getAttribute('rel')).to.equal('noreferrer noopener');
});
it('should render a link with rel="" when a target is provided and rel is empty', async () => {
const el = await fixture<SlButton>(
html` <sl-button href="https://example.com/" target="_blank" rel="">Link</sl-button> `
);
const link = el.shadowRoot!.querySelector('a')!;
expect(link?.getAttribute('rel')).to.equal('');
});
it(`should render a link with a custom rel when a custom rel is provided`, async () => {
const el = await fixture<SlButton>(
html` <sl-button href="https://example.com/" target="_blank" rel="1">Link</sl-button> `
);
const link = el.shadowRoot!.querySelector('a')!;
expect(link?.getAttribute('rel')).to.equal('1');
});
});
describe('when submitting a form', () => {
@@ -234,4 +259,31 @@ describe('<sl-button>', () => {
expect(clickHandler).to.have.been.calledOnce;
});
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'type="button"',
init: (control: SlButton) => {
control.type = 'button';
}
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'type="submit"',
init: (control: SlButton) => {
control.type = 'submit';
}
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'href="xyz"',
init: (control: SlButton) => {
control.href = 'some-url';
}
});
});

View File

@@ -2,7 +2,7 @@ import '../icon/icon';
import '../spinner/spinner';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { FormControlController } from '../../internal/form';
import { FormControlController, validValidityState } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { html, literal } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
@@ -24,6 +24,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
*
* @event sl-blur - Emitted when the button loses focus.
* @event sl-focus - Emitted when the button gains focus.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @slot - The button's label.
* @slot prefix - A presentational prefix icon or similar element.
@@ -41,7 +42,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
private readonly formControlController = new FormControlController(this, {
form: input => {
// Buttons support a form attribute that points to an arbitrary form, so if this attribute it set we need to query
// Buttons support a form attribute that points to an arbitrary form, so if this attribute is set we need to query
// the form from the same root using its id
if (input.hasAttribute('form')) {
const doc = input.getRootNode() as Document | ShadowRoot;
@@ -51,7 +52,8 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
// Fall back to the closest containing form
return input.closest('form');
}
},
assumeInteractionOn: ['click']
});
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
private readonly localize = new LocalizeController(this);
@@ -114,6 +116,14 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
/** Tells the browser where to open the link. Only used when `href` is present. */
@property() target: '_blank' | '_parent' | '_self' | '_top';
/**
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
* setting the attribute to an empty string or a value of your choice, respectively.
*/
@property() rel = 'noreferrer noopener';
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
@property() download?: string;
@@ -139,6 +149,35 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
/** Used to override the form owner's `target` attribute. */
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
/** Gets the validity state object */
get validity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validity;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.handleHostClick = this.handleHostClick.bind(this);
this.addEventListener('click', this.handleHostClick);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this.handleHostClick);
}
firstUpdated() {
if (this.isButton()) {
this.formControlController.updateValidity();
@@ -155,13 +194,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
this.emit('sl-focus');
}
private handleClick(event: MouseEvent) {
if (this.disabled || this.loading) {
event.preventDefault();
event.stopPropagation();
return;
}
private handleClick() {
if (this.type === 'submit') {
this.formControlController.submit(this);
}
@@ -171,6 +204,19 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
}
}
private handleHostClick(event: MouseEvent) {
// Prevent the click event from being emitted when the button is disabled or loading
if (this.disabled || this.loading) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private isButton() {
return this.href ? false : true;
}
@@ -202,7 +248,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
this.button.blur();
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).checkValidity();
@@ -211,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()) {
@@ -270,12 +321,13 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
href=${ifDefined(isLink ? this.href : undefined)}
target=${ifDefined(isLink ? this.target : undefined)}
download=${ifDefined(isLink ? this.download : undefined)}
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
rel=${ifDefined(isLink ? this.rel : undefined)}
role=${ifDefined(isLink ? undefined : 'button')}
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@invalid=${this.isButton() ? this.handleInvalid : null}
@click=${this.handleClick}
>
<slot name="prefix" part="prefix" class="button__prefix"></slot>

View File

@@ -0,0 +1,26 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
export default css`
${componentStyles}
:host {
--aspect-ratio: inherit;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
width: 100%;
max-height: 100%;
aspect-ratio: var(--aspect-ratio);
scroll-snap-align: start;
scroll-snap-stop: always;
}
::slotted(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
`;

View File

@@ -0,0 +1,17 @@
import { expect, fixture, html } from '@open-wc/testing';
describe('<sl-carousel-item>', () => {
it('should render a component', async () => {
const el = await fixture(html` <sl-carousel-item></sl-carousel-item> `);
expect(el).to.exist;
});
it('should pass accessibility tests', async () => {
// Arrange
const el = await fixture(html` <div role="list"><sl-carousel-item></sl-carousel-item></div> `);
// Assert
await expect(el).to.be.accessible();
});
});

View File

@@ -0,0 +1,40 @@
import { customElement } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './carousel-item.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary A carousel item represent a slide within a [carousel](/components/carousel).
*
* @since 2.0
* @status experimental
*
* @slot - The carousel item's content..
*
* @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default.
*
*/
@customElement('sl-carousel-item')
export default class SlCarouselItem extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static isCarouselItem(node: Node) {
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
}
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'group');
}
render() {
return html` <slot></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-carousel-item': SlCarouselItem;
}
}

View File

@@ -0,0 +1,73 @@
import type { ReactiveController, ReactiveElement } from 'lit';
/**
* A controller that repeatedly calls the specified callback with the provided interval time.
* The timer is automatically paused while the user is interacting with the component.
*/
export class AutoplayController implements ReactiveController {
private host: ReactiveElement;
private timerId = 0;
private tickCallback: () => void;
private activeInteractions = 0;
paused = false;
stopped = true;
constructor(host: ReactiveElement, tickCallback: () => void) {
host.addController(this);
this.host = host;
this.tickCallback = tickCallback;
}
hostConnected(): void {
this.host.addEventListener('mouseenter', this.pause);
this.host.addEventListener('mouseleave', this.resume);
this.host.addEventListener('focusin', this.pause);
this.host.addEventListener('focusout', this.resume);
this.host.addEventListener('touchstart', this.pause, { passive: true });
this.host.addEventListener('touchend', this.resume);
}
hostDisconnected(): void {
this.stop();
this.host.removeEventListener('mouseenter', this.pause);
this.host.removeEventListener('mouseleave', this.resume);
this.host.removeEventListener('focusin', this.pause);
this.host.removeEventListener('focusout', this.resume);
this.host.removeEventListener('touchstart', this.pause);
this.host.removeEventListener('touchend', this.resume);
}
start(interval: number) {
this.stop();
this.stopped = false;
this.timerId = window.setInterval(() => {
if (!this.paused) {
this.tickCallback();
}
}, interval);
}
stop() {
clearInterval(this.timerId);
this.stopped = true;
this.host.requestUpdate();
}
pause = () => {
if (!this.activeInteractions++) {
this.paused = true;
this.host.requestUpdate();
}
};
resume = () => {
if (!--this.activeInteractions) {
this.paused = false;
this.host.requestUpdate();
}
};
}

View File

@@ -0,0 +1,160 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
export default css`
${componentStyles}
:host {
--slide-gap: var(--sl-spacing-medium, 1rem);
--aspect-ratio: 16 / 9;
--scroll-hint: 0px;
display: flex;
}
.carousel {
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-template-rows: 1fr min-content;
grid-template-areas:
'. slides .'
'. pagination .';
gap: var(--sl-spacing-medium);
align-items: center;
min-height: 100%;
min-width: 100%;
position: relative;
}
.carousel__pagination {
grid-area: pagination;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--sl-spacing-small);
}
.carousel__slides {
grid-area: slides;
display: grid;
height: 100%;
width: 100%;
align-items: center;
justify-items: center;
overflow: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
aspect-ratio: calc(var(--aspect-ratio) * var(--slides-per-page));
border-radius: var(--sl-border-radius-small);
--slide-size: calc((100% - (var(--slides-per-page) - 1) * var(--slide-gap)) / var(--slides-per-page));
}
@media (prefers-reduced-motion) {
:where(.carousel__slides) {
scroll-behavior: auto;
}
}
.carousel__slides--horizontal {
grid-auto-flow: column;
grid-auto-columns: var(--slide-size);
grid-auto-rows: 100%;
column-gap: var(--slide-gap);
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--scroll-hint);
padding-inline: var(--scroll-hint);
overflow-y: hidden;
}
.carousel__slides--vertical {
grid-auto-flow: row;
grid-auto-columns: 100%;
grid-auto-rows: var(--slide-size);
row-gap: var(--slide-gap);
scroll-snap-type: y mandatory;
scroll-padding-block: var(--scroll-hint);
padding-block: var(--scroll-hint);
overflow-x: hidden;
}
.carousel__slides--dragging,
.carousel__slides--dropping {
scroll-snap-type: unset;
}
:host([vertical]) ::slotted(sl-carousel-item) {
height: 100%;
}
.carousel__slides::-webkit-scrollbar {
display: none;
}
.carousel__navigation {
grid-area: navigation;
display: contents;
font-size: var(--sl-font-size-x-large);
}
.carousel__navigation-button {
flex: 0 0 auto;
display: flex;
align-items: center;
background: none;
border: none;
border-radius: var(--sl-border-radius-small);
font-size: inherit;
color: var(--sl-color-neutral-600);
padding: var(--sl-spacing-x-small);
cursor: pointer;
transition: var(--sl-transition-medium) color;
appearance: none;
}
.carousel__navigation-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.carousel__navigation-button--disabled::part(base) {
pointer-events: none;
}
.carousel__navigation-button--previous {
grid-column: 1;
grid-row: 1;
}
.carousel__navigation-button--next {
grid-column: 3;
grid-row: 1;
}
.carousel__pagination-item {
display: block;
cursor: pointer;
background: none;
border: 0;
border-radius: var(--sl-border-radius-circle);
width: var(--sl-spacing-small);
height: var(--sl-spacing-small);
background-color: var(--sl-color-neutral-300);
padding: 0;
margin: 0;
}
.carousel__pagination-item--active {
background-color: var(--sl-color-neutral-700);
transform: scale(1.2);
}
/* Focus styles */
.carousel__slides:focus-visible,
.carousel__navigation-button:focus-visible,
.carousel__pagination-item:focus-visible {
outline: var(--sl-focus-ring);
outline-offset: var(--sl-focus-ring-offset);
}
`;

View File

@@ -0,0 +1,588 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import sinon from 'sinon';
import type SlCarousel from './carousel';
describe('<sl-carousel>', () => {
it('should render a carousel with default configuration', async () => {
// Arrange
const el = await fixture(html`
<sl-carousel>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Assert
expect(el).to.exist;
expect(el).to.have.attribute('role', 'region');
expect(el).to.have.attribute('aria-label', 'Carousel');
expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist;
expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist;
});
describe('when `autoplay` attribute is provided', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers({
now: new Date()
});
});
afterEach(() => {
clock.restore();
});
it('should scroll forwards every `autoplay-interval` milliseconds', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel autoplay autoplay-interval="10">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
sinon.stub(el, 'next');
await el.updateComplete;
// Act
clock.next();
clock.next();
// Assert
expect(el.next).to.have.been.calledTwice;
});
it('should pause the autoplay while the user is interacting', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel autoplay autoplay-interval="10">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
sinon.stub(el, 'next');
await el.updateComplete;
// Act
el.dispatchEvent(new Event('mouseenter'));
await el.updateComplete;
clock.next();
clock.next();
// Assert
expect(el.next).not.to.have.been.called;
});
it('should not resume if the user is still interacting', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel autoplay autoplay-interval="10">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
sinon.stub(el, 'next');
await el.updateComplete;
// Act
el.dispatchEvent(new Event('mouseenter'));
el.dispatchEvent(new Event('focusin'));
await el.updateComplete;
el.dispatchEvent(new Event('mouseleave'));
await el.updateComplete;
clock.next();
clock.next();
// Assert
expect(el.next).not.to.have.been.called;
});
});
describe('when `loop` attribute is provided', () => {
it('should create clones of the first and last slides', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel loop>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
expect(el.firstElementChild).to.have.attribute('data-clone', '2');
expect(el.lastElementChild).to.have.attribute('data-clone', '0');
});
describe('and `slides-per-page` is provided', () => {
it('should create multiple clones', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel loop slides-per-page="2">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
const clones = [...el.children].filter(child => child.hasAttribute('data-clone'));
// Assert
expect(clones).to.have.lengthOf(4);
});
});
});
describe('when `pagination` attribute is provided', () => {
it('should render pagination controls', async () => {
// Arrange
const el = await fixture(html`
<sl-carousel pagination>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Assert
expect(el).to.exist;
expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist;
expect(el.shadowRoot!.querySelector('.carousel__pagination')).to.exist;
});
describe('and user clicks on a pagination button', () => {
it('should scroll the carousel to the nth slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel pagination>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
sinon.stub(el, 'goToSlide');
await el.updateComplete;
// Act
const paginationItem = el.shadowRoot!.querySelectorAll('.carousel__pagination-item')[2] as HTMLElement;
await clickOnElement(paginationItem);
expect(el.goToSlide).to.have.been.calledWith(2);
});
});
});
describe('when `navigation` attribute is provided', () => {
it('should render navigation controls', async () => {
// Arrange
const el = await fixture(html`
<sl-carousel navigation>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Assert
expect(el).to.exist;
expect(el.shadowRoot!.querySelector('.carousel__navigation')).to.exist;
expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist;
});
});
describe('when `slides-per-page` attribute is provided', () => {
it('should show multiple slides at a given time', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-page="2">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
expect(el.scrollContainer.style.getPropertyValue('--slides-per-page').trim()).to.be.equal('2');
});
});
describe('when `slides-per-move` attribute is provided', () => {
it('should set the granularity of snapping', async () => {
// Arrange
const expectedSnapGranularity = 2;
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-move="${expectedSnapGranularity}">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
<sl-carousel-item>Node 4</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
for (let i = 0; i < el.children.length; i++) {
const child = el.children[i] as HTMLElement;
if (i % expectedSnapGranularity === 0) {
expect(child.style.getPropertyValue('scroll-snap-align')).to.be.equal('');
} else {
expect(child.style.getPropertyValue('scroll-snap-align')).to.be.equal('none');
}
}
});
});
describe('when `orientation` attribute is provided', () => {
describe('and value is `vertical`', () => {
it('should make the scrollable along the y-axis', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel orientation="vertical" style="height: 100px">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
expect(el.scrollContainer.scrollWidth).to.be.equal(el.scrollContainer.clientWidth);
expect(el.scrollContainer.scrollHeight).to.be.greaterThan(el.scrollContainer.clientHeight);
});
});
describe('and value is `horizontal`', () => {
it('should make the scrollable along the x-axis', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel orientation="horizontal" style="height: 100px">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
</sl-carousel>
`);
// Act
await el.updateComplete;
// Assert
expect(el.scrollContainer.scrollWidth).to.be.greaterThan(el.scrollContainer.clientWidth);
expect(el.scrollContainer.scrollHeight).to.be.equal(el.scrollContainer.clientHeight);
});
});
});
describe('Navigation controls', () => {
describe('when the user clicks the next button', () => {
it('should scroll to the next slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
sinon.stub(el, 'next');
await el.updateComplete;
// Act
await clickOnElement(nextButton);
await el.updateComplete;
// Assert
expect(el.next).to.have.been.calledOnce;
});
describe('and carousel is positioned on the last slide', () => {
it('should not scroll', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
sinon.stub(el, 'next');
el.goToSlide(2, 'auto');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
// Act
await clickOnElement(nextButton);
await el.updateComplete;
// Assert
expect(nextButton).to.have.attribute('aria-disabled', 'true');
expect(el.next).not.to.have.been.called;
});
describe('and `loop` attribute is provided', () => {
it('should scroll to the first slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation loop>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
el.goToSlide(2, 'auto');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
// Act
await clickOnElement(nextButton);
// wait first scroll to clone
await oneEvent(el.scrollContainer, 'scrollend');
// wait scroll to actual item
await oneEvent(el.scrollContainer, 'scrollend');
// Assert
expect(nextButton).to.have.attribute('aria-disabled', 'false');
expect(el.activeSlide).to.be.equal(0);
});
});
});
});
describe('and clicks the previous button', () => {
it('should scroll to the previous slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Go to the second slide so that the previous button will be enabled
el.goToSlide(1, 'auto');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
sinon.stub(el, 'previous');
await el.updateComplete;
// Act
await clickOnElement(previousButton);
await el.updateComplete;
// Assert
expect(el.previous).to.have.been.calledOnce;
});
describe('and carousel is positioned on the first slide', () => {
it('should not scroll', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
sinon.stub(el, 'previous');
await el.updateComplete;
// Act
await clickOnElement(previousButton);
await el.updateComplete;
// Assert
expect(previousButton).to.have.attribute('aria-disabled', 'true');
expect(el.previous).not.to.have.been.called;
});
describe('and `loop` attribute is provided', () => {
it('should scroll to the last slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation loop>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
await el.updateComplete;
// Act
await clickOnElement(previousButton);
// wait first scroll to clone
await oneEvent(el.scrollContainer, 'scrollend');
// wait scroll to actual item
await oneEvent(el.scrollContainer, 'scrollend');
// Assert
expect(previousButton).to.have.attribute('aria-disabled', 'false');
expect(el.activeSlide).to.be.equal(2);
});
});
});
});
});
describe('API', () => {
describe('#next', () => {
it('should scroll the carousel to the next slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-move="2">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
sinon.stub(el, 'goToSlide');
await el.updateComplete;
// Act
el.next();
expect(el.goToSlide).to.have.been.calledWith(2);
});
});
describe('#previous', () => {
it('should scroll the carousel to the previous slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel slides-per-move="2">
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
sinon.stub(el, 'goToSlide');
await el.updateComplete;
// Act
el.previous();
expect(el.goToSlide).to.have.been.calledWith(-2);
});
});
describe('#goToSlide', () => {
it('should scroll the carousel to the nth slide', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
await el.updateComplete;
// Act
el.goToSlide(2);
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
// Assert
expect(el.activeSlide).to.be.equal(2);
});
});
});
describe('Accessibility', () => {
it('should pass accessibility tests', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel navigation pagination>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
const pagination = el.shadowRoot!.querySelector('.carousel__pagination')!;
const navigation = el.shadowRoot!.querySelector('.carousel__navigation')!;
await el.updateComplete;
// Assert
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
expect(el.scrollContainer).to.have.attribute('aria-atomic', 'true');
expect(pagination).to.have.attribute('role', 'tablist');
expect(pagination).to.have.attribute('aria-controls', el.scrollContainer.id);
for (const paginationItem of pagination.querySelectorAll('.carousel__pagination-item')) {
expect(paginationItem).to.have.attribute('role', 'tab');
expect(paginationItem).to.have.attribute('aria-selected');
expect(paginationItem).to.have.attribute('aria-label');
}
for (const navigationItem of navigation.querySelectorAll('.carousel__navigation-item')) {
expect(navigationItem).to.have.attribute('aria-controls', el.scrollContainer.id);
expect(navigationItem).to.have.attribute('aria-disabled');
expect(navigationItem).to.have.attribute('aria-label');
}
await expect(el).to.be.accessible();
});
describe('when scrolling', () => {
it('should update aria-busy attribute', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel autoplay>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
await el.updateComplete;
// Act
el.goToSlide(2, 'smooth');
await oneEvent(el.scrollContainer, 'scroll');
await el.updateComplete;
// Assert
expect(el.scrollContainer).to.have.attribute('aria-busy', 'true');
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
});
});
});
});

View File

@@ -0,0 +1,468 @@
import '../icon/icon';
import { AutoplayController } from './autoplay-controller';
import { clamp } from 'src/internal/math';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '@shoelace-style/localize';
import { map } from 'lit/directives/map.js';
import { prefersReducedMotion } from '../../internal/animate';
import { range } from 'lit/directives/range.js';
import { ScrollController } from './scroll-controller';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import SlCarouselItem from '../carousel-item/carousel-item';
import styles from './carousel.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
*
* @since 2.0
* @status experimental
*
* @dependency sl-icon
*
* @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes.
*
* @slot - The carousel's main content, one or more `<sl-carousel-item>` elements.
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<sl-icon>`.
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<sl-icon>`.
*
* @csspart base - The carousel's internal wrapper.
* @csspart scroll-container - The scroll container that wraps the slides.
* @csspart pagination - The pagination indicators wrapper.
* @csspart pagination-item - The pagination indicator.
* @csspart pagination-item--active - Applied when the item is active.
* @csspart navigation - The navigation wrapper.
* @csspart navigation-button - The navigation button.
* @csspart navigation-button--previous - Applied to the previous button.
* @csspart navigation-button--next - Applied to the next button.
*
* @cssproperty --slide-gap - The space between each slide.
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
* partially visible as a scroll hint.
*/
@customElement('sl-carousel')
export default class SlCarousel extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
@property({ type: Boolean, reflect: true }) loop = false;
/** When set, show the carousel's navigation. */
@property({ type: Boolean, reflect: true }) navigation = false;
/** When set, show the carousel's pagination indicators. */
@property({ type: Boolean, reflect: true }) pagination = false;
/** When set, the slides will scroll automatically when the user is not interacting with them. */
@property({ type: Boolean, reflect: true }) autoplay = false;
/** Specifies the amount of time, in milliseconds, between each automatic scroll. */
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
/** Specifies how many slides should be shown at a given time. */
@property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1;
/**
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
* greater than one.
*/
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
/** Specifies the orientation in which the carousel will lay out. */
@property() orientation: 'horizontal' | 'vertical' = 'horizontal';
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.carousel__slides') scrollContainer: HTMLElement;
@query('.carousel__pagination') paginationContainer: HTMLElement;
// The index of the active slide
@state() activeSlide = 0;
private autoplayController = new AutoplayController(this, () => this.next());
private scrollController = new ScrollController(this);
private readonly slides = this.getElementsByTagName('sl-carousel-item');
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
// A map containing the state of all the slides
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
private readonly localize = new LocalizeController(this);
private mutationObserver: MutationObserver;
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'region');
this.setAttribute('aria-label', this.localize.term('carousel'));
const intersectionObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
// Store all the entries in a map to be processed when scrolling ends
this.intersectionObserverEntries.set(entry.target, entry);
const slide = entry.target;
slide.toggleAttribute('inert', !entry.isIntersecting);
slide.classList.toggle('--in-view', entry.isIntersecting);
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
});
},
{
root: this,
threshold: 0.6
}
);
this.intersectionObserver = intersectionObserver;
// Store the initial state of each slide
intersectionObserver.takeRecords().forEach(entry => {
this.intersectionObserverEntries.set(entry.target, entry);
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.intersectionObserver.disconnect();
this.mutationObserver.disconnect();
}
protected firstUpdated(): void {
this.initializeSlides();
this.mutationObserver = new MutationObserver(this.handleSlotChange.bind(this));
this.mutationObserver.observe(this, { childList: true, subtree: false });
}
private getPageCount() {
return Math.ceil(this.getSlides().length / this.slidesPerPage);
}
private getCurrentPage() {
return Math.floor(this.activeSlide / this.slidesPerPage);
}
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
}
private handleKeyDown(event: KeyboardEvent) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const target = event.target as HTMLElement;
const isRtl = this.localize.dir() === 'rtl';
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
const isNext =
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
const isPrevious =
event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight');
event.preventDefault();
if (isPrevious) {
this.previous();
}
if (isNext) {
this.next();
}
if (event.key === 'Home') {
this.goToSlide(0);
}
if (event.key === 'End') {
this.goToSlide(this.getSlides().length - 1);
}
if (isFocusInPagination) {
this.updateComplete.then(() => {
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
'[part~="pagination-item--active"]'
);
if (activePaginationItem) {
activePaginationItem.focus();
}
});
}
}
}
private handleScrollEnd() {
const slides = this.getSlides();
const entries = [...this.intersectionObserverEntries.values()];
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
this.goToSlide(clonePosition, 'auto');
return;
}
// Activate the first intersecting slide
if (firstIntersecting) {
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
}
}
private handleSlotChange(mutations: MutationRecord[]) {
const needsInitialization = mutations.some(mutation =>
[...mutation.addedNodes, ...mutation.removedNodes].some(
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
)
);
// Reinitialize the carousel if a carousel item has been added or removed
if (needsInitialization) {
this.initializeSlides();
}
this.requestUpdate();
}
@watch('loop', { waitUntilFirstUpdate: true })
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
initializeSlides() {
const slides = this.getSlides();
const intersectionObserver = this.intersectionObserver;
this.intersectionObserverEntries.clear();
// Removes all the cloned elements from the carousel
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
intersectionObserver.unobserve(slide);
slide.classList.remove('--in-view');
slide.classList.remove('--is-active');
slide.setAttribute('aria-label', this.localize.term('slide_num', index + 1));
if (slide.hasAttribute('data-clone')) {
slide.remove();
}
});
if (this.loop) {
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
const slidesPerPage = this.slidesPerPage;
const lastSlides = slides.slice(-slidesPerPage);
const firstSlides = slides.slice(0, slidesPerPage);
lastSlides.reverse().forEach((slide, i) => {
const clone = slide.cloneNode(true) as HTMLElement;
clone.setAttribute('data-clone', String(slides.length - i - 1));
this.prepend(clone);
});
firstSlides.forEach((slide, i) => {
const clone = slide.cloneNode(true) as HTMLElement;
clone.setAttribute('data-clone', String(i));
this.append(clone);
});
}
this.getSlides({ excludeClones: false }).forEach(slide => {
intersectionObserver.observe(slide);
});
// Because the DOM may be changed, restore the scroll position to the active slide
this.goToSlide(this.activeSlide, 'auto');
}
@watch('activeSlide')
handelSlideChange() {
const slides = this.getSlides();
slides.forEach((slide, i) => {
slide.classList.toggle('--is-active', i === this.activeSlide);
});
// Do not emit an event on first render
if (this.hasUpdated) {
this.emit('sl-slide-change', {
detail: {
index: this.activeSlide,
slide: slides[this.activeSlide]
}
});
}
}
@watch('slidesPerMove')
handleSlidesPerMoveChange() {
const slides = this.getSlides({ excludeClones: false });
const slidesPerMove = this.slidesPerMove;
slides.forEach((slide, i) => {
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
if (shouldSnap) {
slide.style.removeProperty('scroll-snap-align');
} else {
slide.style.setProperty('scroll-snap-align', 'none');
}
});
}
@watch('autoplay')
handleAutoplayChange() {
this.autoplayController.stop();
if (this.autoplay) {
this.autoplayController.start(this.autoplayInterval);
}
}
@watch('mouseDragging')
handleMouseDraggingChange() {
this.scrollController.mouseDragging = this.mouseDragging;
}
/**
* Move the carousel backward by `slides-per-move` slides.
*
* @param behavior - The behavior used for scrolling.
*/
previous(behavior: ScrollBehavior = 'smooth') {
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
}
/**
* Move the carousel forward by `slides-per-move` slides.
*
* @param behavior - The behavior used for scrolling.
*/
next(behavior: ScrollBehavior = 'smooth') {
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
}
/**
* Scrolls the carousel to the slide specified by `index`.
*
* @param index - The slide index.
* @param behavior - The behavior used for scrolling.
*/
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
const { slidesPerPage, loop } = this;
const slides = this.getSlides();
const slidesWithClones = this.getSlides({ excludeClones: false });
// Sets the next index without taking into account clones, if any.
const newActiveSlide = (index + slides.length) % slides.length;
this.activeSlide = newActiveSlide;
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
// to normalize the starting index in order to ignore the first nth clones.
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
const nextSlide = slidesWithClones[nextSlideIndex];
this.scrollContainer.scrollTo({
left: nextSlide.offsetLeft,
top: nextSlide.offsetTop,
behavior: prefersReducedMotion() ? 'auto' : behavior
});
}
render() {
const { scrollController, slidesPerPage } = this;
const pagesCount = this.getPageCount();
const currentPage = this.getCurrentPage();
const prevEnabled = this.loop || currentPage > 0;
const nextEnabled = this.loop || currentPage < pagesCount - 1;
const isLtr = this.localize.dir() === 'ltr';
return html`
<div part="base" class="carousel">
<div
id="scroll-container"
part="scroll-container"
class="${classMap({
carousel__slides: true,
'carousel__slides--horizontal': this.orientation === 'horizontal',
'carousel__slides--vertical': this.orientation === 'vertical'
})}"
style="--slides-per-page: ${this.slidesPerPage};"
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
aria-atomic="true"
tabindex="0"
@keydown=${this.handleKeyDown}
@scrollend=${this.handleScrollEnd}
>
<slot></slot>
</div>
${this.navigation
? html`
<div part="navigation" class="carousel__navigation">
<button
part="navigation-button navigation-button--previous"
class="${classMap({
'carousel__navigation-button': true,
'carousel__navigation-button--previous': true,
'carousel__navigation-button--disabled': !prevEnabled
})}"
aria-label="${this.localize.term('previousSlide')}"
aria-controls="scroll-container"
aria-disabled="${prevEnabled ? 'false' : 'true'}"
@click=${prevEnabled ? () => this.previous() : null}
>
<slot name="previous-icon">
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
</slot>
</button>
<button
part="navigation-button navigation-button--next"
class=${classMap({
'carousel__navigation-button': true,
'carousel__navigation-button--next': true,
'carousel__navigation-button--disabled': !nextEnabled
})}
aria-label="${this.localize.term('nextSlide')}"
aria-controls="scroll-container"
aria-disabled="${nextEnabled ? 'false' : 'true'}"
@click=${nextEnabled ? () => this.next() : null}
>
<slot name="next-icon">
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
</slot>
</button>
</div>
`
: ''}
${this.pagination
? html`
<div part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
${map(range(pagesCount), index => {
const isActive = index === currentPage;
return html`
<button
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
class="${classMap({
'carousel__pagination-item': true,
'carousel__pagination-item--active': isActive
})}"
role="tab"
aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
tabindex=${isActive ? '0' : '-1'}
@click=${() => this.goToSlide(index * slidesPerPage)}
@keydown=${this.handleKeyDown}
></button>
`;
})}
</div>
`
: ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-carousel': SlCarousel;
}
}

View File

@@ -0,0 +1,178 @@
import { debounce } from 'src/internal/debounce';
import { prefersReducedMotion } from 'src/internal/animate';
import { waitForEvent } from 'src/internal/event';
import type { ReactiveController, ReactiveElement } from 'lit';
interface ScrollHost extends ReactiveElement {
scrollContainer: HTMLElement;
}
/**
* A controller for handling scrolling and mouse dragging.
*/
export class ScrollController<T extends ScrollHost> implements ReactiveController {
private host: T;
private pointers = new Set();
dragging = false;
scrolling = false;
mouseDragging = false;
constructor(host: T) {
this.host = host;
host.addController(this);
this.handleScroll = this.handleScroll.bind(this);
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
}
async hostConnected() {
const host = this.host;
await host.updateComplete;
const scrollContainer = host.scrollContainer;
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true });
scrollContainer.addEventListener('touchend', this.handleTouchEnd);
}
hostDisconnected(): void {
const host = this.host;
const scrollContainer = host.scrollContainer;
scrollContainer.removeEventListener('scroll', this.handleScroll);
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
scrollContainer.removeEventListener('touchstart', this.handleTouchStart);
scrollContainer.removeEventListener('touchend', this.handleTouchEnd);
}
handleScroll() {
if (!this.scrolling) {
this.scrolling = true;
this.host.requestUpdate();
}
this.handleScrollEnd();
}
@debounce(100)
handleScrollEnd() {
if (!this.pointers.size) {
this.scrolling = false;
this.host.scrollContainer.dispatchEvent(
new CustomEvent('scrollend', {
bubbles: false,
cancelable: false
})
);
this.host.requestUpdate();
} else {
this.handleScrollEnd();
}
}
handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'touch') {
return;
}
const scrollContainer = this.host.scrollContainer;
this.pointers.add(event.pointerId);
scrollContainer.setPointerCapture(event.pointerId);
if (this.mouseDragging && this.pointers.size === 1) {
event.preventDefault();
scrollContainer.addEventListener('pointermove', this.handlePointerMove);
}
}
handlePointerMove(event: PointerEvent) {
const host = this.host;
const scrollContainer = host.scrollContainer;
if (scrollContainer.hasPointerCapture(event.pointerId)) {
if (!this.dragging) {
this.handleDragStart();
}
this.handleDrag(event);
}
}
handlePointerUp(event: PointerEvent) {
const host = this.host;
const scrollContainer = host.scrollContainer;
this.pointers.delete(event.pointerId);
scrollContainer.releasePointerCapture(event.pointerId);
if (this.pointers.size === 0) {
this.handleDragEnd();
}
}
handleTouchEnd(event: TouchEvent) {
for (const touch of event.changedTouches) {
this.pointers.delete(touch.identifier);
}
}
handleTouchStart(event: TouchEvent) {
for (const touch of event.touches) {
this.pointers.add(touch.identifier);
}
}
handleDragStart() {
const host = this.host;
this.dragging = true;
host.scrollContainer.style.setProperty('scroll-snap-type', 'unset');
host.requestUpdate();
}
handleDrag(event: PointerEvent) {
this.host.scrollContainer.scrollBy({
left: -event.movementX,
top: -event.movementY
});
}
async handleDragEnd() {
const host = this.host;
const scrollContainer = host.scrollContainer;
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
this.dragging = false;
const startLeft = scrollContainer.scrollLeft;
const startTop = scrollContainer.scrollTop;
scrollContainer.style.removeProperty('scroll-snap-type');
const finalLeft = scrollContainer.scrollLeft;
const finalTop = scrollContainer.scrollTop;
scrollContainer.style.setProperty('scroll-snap-type', 'unset');
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
if (this.scrolling) {
await waitForEvent(scrollContainer, 'scrollend');
}
scrollContainer.style.removeProperty('scroll-snap-type');
host.requestUpdate();
}
}

View File

@@ -9,6 +9,7 @@ export default css`
}
.checkbox {
position: relative;
display: inline-flex;
align-items: top;
font-family: var(--sl-input-font-family);

View File

@@ -1,5 +1,6 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlCheckbox from './checkbox';
@@ -94,6 +95,21 @@ describe('<sl-checkbox>', () => {
await el.updateComplete;
});
it('should hide the native input with the correct positioning to scroll correctly when contained in an overflow', async () => {
//
// See: https://github.com/shoelace-style/shoelace/issues/1169
//
const el = await fixture<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
const label = el.shadowRoot!.querySelector('.checkbox')!;
const input = el.shadowRoot!.querySelector('.checkbox__input')!;
const labelPosition = getComputedStyle(label).position;
const inputPosition = getComputedStyle(input).position;
expect(labelPosition).to.equal('relative');
expect(inputPosition).to.equal('absolute');
});
describe('when submitting a form', () => {
it('should submit the correct value when a value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
@@ -184,6 +200,18 @@ describe('<sl-checkbox>', () => {
expect(formData.get('a')).to.equal('1');
});
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-checkbox required></sl-checkbox></form> `);
const checkbox = el.querySelector<SlCheckbox>('sl-checkbox')!;
expect(checkbox.hasAttribute('data-required')).to.be.true;
expect(checkbox.hasAttribute('data-optional')).to.be.false;
expect(checkbox.hasAttribute('data-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-valid')).to.be.false;
expect(checkbox.hasAttribute('data-user-invalid')).to.be.false;
expect(checkbox.hasAttribute('data-user-valid')).to.be.false;
});
});
describe('when resetting a form', () => {
@@ -281,5 +309,7 @@ describe('<sl-checkbox>', () => {
expect(indeterminateIcon).to.be.null;
});
runFormControlBaseTests('sl-checkbox');
});
});

View File

@@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @event sl-change - Emitted when the checked state changes.
* @event sl-focus - Emitted when the checkbox gains focus.
* @event sl-input - Emitted when the checkbox receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart control - The square container that wraps the checkbox's checked state.
@@ -85,6 +86,16 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@@ -104,6 +115,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
@@ -137,12 +153,17 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.input.blur();
}
/** Checks for validity but does not show a validation message. Returns true when valid and false when invalid. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows a validation message if the control is invalid. */
/** 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();
}
@@ -157,6 +178,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
}
render() {
//
// NOTE: we use a <div> around the label slot because of this Chrome bug.
//
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
//
return html`
<label
part="base"
@@ -184,6 +210,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
aria-checked=${this.checked ? 'true' : 'false'}
@click=${this.handleClick}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
/>
@@ -209,7 +236,9 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
: ''}
</span>
<slot part="label" class="checkbox__label"></slot>
<div part="label" class="checkbox__label">
<slot></slot>
</div>
</label>
`;
}

View File

@@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -324,6 +325,101 @@ describe('<sl-color-picker>', () => {
expect(previewColor).to.equal('#ff000050');
});
it('should emit sl-focus when rendered as a dropdown and focused', async () => {
const el = await fixture<SlColorPicker>(html`
<div>
<sl-color-picker></sl-color-picker>
<button type="button">Click me</button>
</div>
`);
const colorPicker = el.querySelector('sl-color-picker')!;
const trigger = colorPicker.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const button = el.querySelector('button')!;
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
colorPicker.addEventListener('sl-focus', focusHandler);
colorPicker.addEventListener('sl-blur', blurHandler);
await clickOnElement(trigger);
await colorPicker.updateComplete;
expect(focusHandler).to.have.been.calledOnce;
await clickOnElement(button);
await colorPicker.updateComplete;
expect(blurHandler).to.have.been.calledOnce;
});
it('should emit sl-focus when rendered inline and focused', async () => {
const el = await fixture<SlColorPicker>(html`
<div>
<sl-color-picker inline></sl-color-picker>
<button type="button">Click me</button>
</div>
`);
const colorPicker = el.querySelector('sl-color-picker')!;
const button = el.querySelector('button')!;
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
colorPicker.addEventListener('sl-focus', focusHandler);
colorPicker.addEventListener('sl-blur', blurHandler);
await clickOnElement(colorPicker);
await colorPicker.updateComplete;
expect(focusHandler).to.have.been.calledOnce;
await clickOnElement(button);
await colorPicker.updateComplete;
expect(blurHandler).to.have.been.calledOnce;
});
it('should focus and blur when calling focus() and blur() and rendered as a dropdown', async () => {
const colorPicker = await fixture<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
colorPicker.addEventListener('sl-focus', focusHandler);
colorPicker.addEventListener('sl-blur', blurHandler);
// Focus
colorPicker.focus();
await colorPicker.updateComplete;
expect(document.activeElement).to.equal(colorPicker);
expect(focusHandler).to.have.been.calledOnce;
// Blur
colorPicker.blur();
await colorPicker.updateComplete;
expect(document.activeElement).to.equal(document.body);
expect(blurHandler).to.have.been.calledOnce;
});
it('should focus and blur when calling focus() and blur() and rendered inline', async () => {
const colorPicker = await fixture<SlColorPicker>(html` <sl-color-picker inline></sl-color-picker> `);
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
colorPicker.addEventListener('sl-focus', focusHandler);
colorPicker.addEventListener('sl-blur', blurHandler);
// Focus
colorPicker.focus();
await colorPicker.updateComplete;
expect(document.activeElement).to.equal(colorPicker);
expect(focusHandler).to.have.been.calledOnce;
// Blur
colorPicker.blur();
await colorPicker.updateComplete;
expect(document.activeElement).to.equal(document.body);
expect(blurHandler).to.have.been.calledOnce;
});
describe('when submitting a form', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html`
@@ -398,19 +494,21 @@ describe('<sl-color-picker>', () => {
});
it('should be invalid when required and empty', async () => {
const el = await fixture<SlColorPicker>(html` <sl-input required></sl-input> `);
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
expect(el.checkValidity()).to.be.false;
});
it('should be invalid when required and disabled is removed', async () => {
const el = await fixture<SlColorPicker>(html` <sl-input disabled required></sl-input> `);
const el = await fixture<SlColorPicker>(html` <sl-color-picker disabled required></sl-color-picker> `);
el.disabled = false;
await el.updateComplete;
expect(el.checkValidity()).to.be.false;
});
it('should receive the correct validation attributes ("states") when valid', async () => {
const el = await fixture<SlColorPicker>(html` <sl-input required value="a"></sl-input> `);
const el = await fixture<SlColorPicker>(html` <sl-color-picker required value="#fff"></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!;
const grid = el.shadowRoot!.querySelector('[part~="grid"]')!;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-required')).to.be.true;
@@ -420,8 +518,9 @@ describe('<sl-color-picker>', () => {
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.false;
el.focus();
await sendKeys({ press: 'b' });
await clickOnElement(trigger);
await aTimeout(500);
await clickOnElement(grid);
await el.updateComplete;
expect(el.checkValidity()).to.be.true;
@@ -430,7 +529,9 @@ describe('<sl-color-picker>', () => {
});
it('should receive the correct validation attributes ("states") when invalid', async () => {
const el = await fixture<SlColorPicker>(html` <sl-input required></sl-input> `);
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!;
const grid = el.shadowRoot!.querySelector('[part~="grid"]')!;
expect(el.hasAttribute('data-required')).to.be.true;
expect(el.hasAttribute('data-optional')).to.be.false;
@@ -439,13 +540,16 @@ describe('<sl-color-picker>', () => {
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.false;
el.focus();
await sendKeys({ press: 'a' });
await sendKeys({ press: 'Backspace' });
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;
});
});
runFormControlBaseTests('sl-color-picker');
});

View File

@@ -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;
@@ -49,8 +51,11 @@ declare const EyeDropper: EyeDropperConstructor;
*
* @slot label - The color picker's form label. Alternatively, you can use the `label` attribute.
*
* @event sl-change Emitted when the color picker's value changes.
* @event sl-input Emitted when the color picker receives input.
* @event sl-blur - Emitted when the color picker loses focus.
* @event sl-change - Emitted when the color picker's value changes.
* @event sl-focus - Emitted when the color picker receives focus.
* @event sl-input - Emitted when the color picker receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart trigger - The color picker's dropdown trigger.
@@ -94,10 +99,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
private isSafeValue = false;
private readonly localize = new LocalizeController(this);
@query('[part~="base"]') base: HTMLElement;
@query('[part~="input"]') input: SlInput;
@query('[part~="preview"]') previewButton: HTMLButtonElement;
@query('.color-dropdown') dropdown: SlDropdown;
@query('[part~="preview"]') previewButton: HTMLButtonElement;
@query('[part~="trigger"]') trigger: HTMLButtonElement;
@state() private hasFocus = false;
@state() private isDraggingGridHandle = false;
@state() private isEmpty = false;
@state() private inputValue = '';
@@ -169,6 +177,39 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
*/
@property({ reflect: true }) form = '';
/** Makes the color picker a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.handleFocusIn = this.handleFocusIn.bind(this);
this.handleFocusOut = this.handleFocusOut.bind(this);
this.addEventListener('focusin', this.handleFocusIn);
this.addEventListener('focusout', this.handleFocusOut);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('focusin', this.handleFocusIn);
this.removeEventListener('focusout', this.handleFocusOut);
}
firstUpdated() {
this.input.updateComplete.then(() => {
this.formControlController.updateValidity();
});
}
private handleCopy() {
this.input.select();
document.execCommand('copy');
@@ -181,6 +222,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
private handleFocusIn() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleFocusOut() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFormatToggle() {
const formats = ['hex', 'rgb', 'hsl', 'hsv'];
const nextIndex = (formats.indexOf(this.format) + 1) % formats.length;
@@ -368,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;
@@ -388,7 +439,9 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
private handleInputInput(event: CustomEvent) {
private handleInputInput(event: SlInputEvent) {
this.formControlController.updateValidity();
// Prevent the <sl-input>'s sl-input event from bubbling up
event.stopPropagation();
}
@@ -413,6 +466,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
private handleInputInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleTouchMove(event: TouchEvent) {
event.preventDefault();
}
@@ -563,7 +621,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
eyeDropper
.open()
.then(colorSelectionResult => this.setColor(colorSelectionResult.sRGBHex))
.then(colorSelectionResult => {
const oldValue = this.value;
this.setColor(colorSelectionResult.sRGBHex);
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
})
.catch(() => {
// The user canceled, do nothing
});
@@ -592,6 +659,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
return color.toHex8String();
}
// Prevents nested components from leaking events
private stopNestedEventPropagation(event: CustomEvent) {
event.stopImmediatePropagation();
}
@watch('format', { waitUntilFirstUpdate: true })
handleFormatChange() {
this.syncValues();
@@ -629,6 +701,32 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
/** Sets focus on the color picker. */
focus(options?: FocusOptions) {
if (this.inline) {
this.base.focus(options);
} else {
this.trigger.focus(options);
}
}
/** Removes focus from the color picker. */
blur() {
const elementToBlur = this.inline ? this.base : this.trigger;
if (this.hasFocus) {
// We don't know which element in the color picker has focus, so we'll move it to the trigger or base (inline) and
// blur that instead. This results in document.activeElement becoming the <body>. This doesn't cause another focus
// event because we're using focusin and something inside the color picker already has focus.
elementToBlur.focus({ preventScroll: true });
elementToBlur.blur();
}
if (this.dropdown?.open) {
this.dropdown.hide();
}
}
/** Returns the current value as a string in the specified format. */
getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva' = 'hex') {
const currentColor = this.parseColor(
@@ -661,18 +759,29 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
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.checkValidity()) {
if (!this.inline && !this.validity.valid) {
// If the input is inline and invalid, show the dropdown so the browser can focus on it
this.dropdown.show();
this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true });
return this.checkValidity();
if (!this.disabled) {
// By standards we have to emit a `sl-invalid` event here synchronously.
this.formControlController.emitInvalidEvent();
}
return false;
}
return this.input.reportValidity();
@@ -697,7 +806,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
class=${classMap({
'color-picker': true,
'color-picker--inline': this.inline,
'color-picker--disabled': this.disabled
'color-picker--disabled': this.disabled,
'color-picker--focused': this.hasFocus
})}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-labelledby="label"
@@ -821,11 +931,15 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
autocapitalize="off"
spellcheck="false"
value=${this.isEmpty ? '' : this.inputValue}
?required=${this.required}
?disabled=${this.disabled}
aria-label=${this.localize.term('currentValue')}
@keydown=${this.handleInputKeyDown}
@sl-change=${this.handleInputChange}
@sl-input=${this.handleInputInput}
@sl-invalid=${this.handleInputInvalid}
@sl-blur=${this.stopNestedEventPropagation}
@sl-focus=${this.stopNestedEventPropagation}
></sl-input>
<sl-button-group>
@@ -842,6 +956,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
caret:format-button__caret
"
@click=${this.handleFormatToggle}
@sl-blur=${this.stopNestedEventPropagation}
@sl-focus=${this.stopNestedEventPropagation}
>
${this.setLetterCase(this.format)}
</sl-button>
@@ -859,6 +975,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
caret:eye-dropper-button__caret
"
@click=${this.handleEyeDropper}
@sl-blur=${this.stopNestedEventPropagation}
@sl-focus=${this.stopNestedEventPropagation}
>
<sl-icon
library="system"
@@ -932,6 +1050,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
'color-dropdown__trigger--medium': this.size === 'medium',
'color-dropdown__trigger--large': this.size === 'large',
'color-dropdown__trigger--empty': this.isEmpty,
'color-dropdown__trigger--focused': this.hasFocus,
'color-picker__transparent-bg': true
})}
style=${styleMap({

View File

@@ -2,6 +2,8 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import type SlDetails from './details';
import type SlHideEvent from '../../events/sl-hide';
import type SlShowEvent from '../../events/sl-show';
describe('<sl-details>', () => {
it('should be visible with the open attribute', async () => {
@@ -134,7 +136,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const showHandler = sinon.spy((event: CustomEvent) => event.preventDefault());
const showHandler = sinon.spy((event: SlShowEvent) => event.preventDefault());
el.addEventListener('sl-show', showHandler);
el.open = true;
@@ -153,7 +155,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const hideHandler = sinon.spy((event: CustomEvent) => event.preventDefault());
const hideHandler = sinon.spy((event: SlHideEvent) => event.preventDefault());
el.addEventListener('sl-hide', hideHandler);
el.open = false;

View File

@@ -1,3 +1,4 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { sendKeys, sendMouse } from '@web/test-runner-commands';
import sinon from 'sinon';
@@ -179,6 +180,27 @@ describe('<sl-dropdown>', () => {
expect(el.open).to.be.true;
});
it('should navigate to first focusable item on arrow navigation', async () => {
const el = await fixture<SlDropdown>(html`
<sl-dropdown>
<sl-button slot="trigger" caret>Toggle</sl-button>
<sl-menu>
<sl-menu-label>Top Label</sl-menu-label>
<sl-menu-item>Item 1</sl-menu-item>
</sl-menu>
</sl-dropdown>
`);
const trigger = el.querySelector('sl-button')!;
const item = el.querySelector('sl-menu-item')!;
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(document.activeElement).to.equal(item);
});
it('should close on escape key', async () => {
const el = await fixture<SlDropdown>(html`
<sl-dropdown open>
@@ -233,6 +255,30 @@ describe('<sl-dropdown>', () => {
expect(el.open).to.be.true;
});
it('should focus on menu items when clicking the trigger and arrowing through options', async () => {
const el = await fixture<SlDropdown>(html`
<sl-dropdown>
<sl-button slot="trigger" caret>Toggle</sl-button>
<sl-menu>
<sl-menu-item>Item 1</sl-menu-item>
<sl-menu-item>Item 2</sl-menu-item>
<sl-menu-item>Item 3</sl-menu-item>
</sl-menu>
</sl-dropdown>
`);
const trigger = el.querySelector('sl-button')!;
const secondMenuItem = el.querySelectorAll('sl-menu-item')[1];
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should open on enter key when no menu exists', async () => {
const el = await fixture<SlDropdown>(html`
<sl-dropdown>

View File

@@ -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);
@@ -155,6 +153,14 @@ export default class SlDropdown extends ShoelaceElement {
}
handleDocumentKeyDown(event: KeyboardEvent) {
// Close when escape or tab is pressed
if (event.key === 'Escape' && this.open) {
event.stopPropagation();
this.focusOnTrigger();
this.hide();
return;
}
// Handle tabbing
if (event.key === 'Tab') {
// Tabbing within an open menu should close the dropdown and refocus the trigger
@@ -193,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
@@ -213,18 +214,11 @@ export default class SlDropdown extends ShoelaceElement {
this.hide();
} else {
this.show();
this.focusOnTrigger();
}
}
handleTriggerKeyDown(event: KeyboardEvent) {
// Close when escape or tab is pressed
if (event.key === 'Escape' && this.open) {
event.stopPropagation();
this.focusOnTrigger();
this.hide();
return;
}
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
// key again to hide the menu in case they don't want to make a selection.
if ([' ', 'Enter'].includes(event.key)) {
@@ -236,7 +230,7 @@ export default class SlDropdown extends ShoelaceElement {
const menu = this.getMenu();
if (menu) {
const menuItems = menu.defaultSlot.assignedElements({ flatten: true }) as SlMenuItem[];
const menuItems = menu.getAllItems();
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
@@ -253,7 +247,7 @@ export default class SlDropdown extends ShoelaceElement {
if (menuItems.length > 0) {
// Focus on the first/last menu item after showing
requestAnimationFrame(() => {
this.updateComplete.then(() => {
if (event.key === 'ArrowDown' || event.key === 'Home') {
menu.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
@@ -341,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);
@@ -350,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);
}

View File

@@ -1,6 +1,8 @@
import { elementUpdated, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { registerIconLibrary } from '../../../dist/shoelace.js';
import type SlErrorEvent from '../../events/sl-error';
import type SlIcon from './icon';
import type SlLoadEvent from '../../events/sl-load';
const testLibraryIcons = {
'test-icon1': `
@@ -46,7 +48,7 @@ describe('<sl-icon>', () => {
it('renders pre-loaded system icons and emits sl-load event', async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="system"></sl-icon> `);
const listener = oneEvent(el, 'sl-load') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-load') as Promise<SlLoadEvent>;
el.name = 'check';
const ev = await listener;
@@ -93,6 +95,7 @@ describe('<sl-icon>', () => {
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(el.shadowRoot?.querySelector('svg')?.part.contains('svg')).to.be.true;
expect(el.shadowRoot?.querySelector('svg')?.getAttribute('id')).to.equal(fakeId);
});
});
@@ -100,7 +103,7 @@ describe('<sl-icon>', () => {
describe('new library', () => {
it('renders icons from the new library and emits sl-load event', async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="test-library"></sl-icon> `);
const listener = oneEvent(el, 'sl-load') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-load') as Promise<SlLoadEvent>;
el.name = 'test-icon1';
const ev = await listener;
@@ -129,7 +132,7 @@ describe('<sl-icon>', () => {
it('emits sl-error when the file cant be retrieved', async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="test-library"></sl-icon> `);
const listener = oneEvent(el, 'sl-error') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-error') as Promise<SlErrorEvent>;
el.name = 'bad-request';
const ev = await listener;
@@ -141,7 +144,7 @@ describe('<sl-icon>', () => {
it("emits sl-error when there isn't an svg element in the registered icon", async () => {
const el = await fixture<SlIcon>(html` <sl-icon library="test-library"></sl-icon> `);
const listener = oneEvent(el, 'sl-error') as Promise<CustomEvent>;
const listener = oneEvent(el, 'sl-error') as Promise<SlErrorEvent>;
el.name = 'bad-icon';
const ev = await listener;

View File

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

View File

@@ -3,7 +3,7 @@ import type { IconLibrary } from './library';
const library: IconLibrary = {
name: 'default',
resolver: name => `${getBasePath()}/assets/icons/${name}.svg`
resolver: name => getBasePath(`assets/icons/${name}.svg`)
};
export default library;

View File

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

View File

@@ -1,8 +1,9 @@
// eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { getFormControls } from '../../../dist/utilities/form.js';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form'; // must come from the same module
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands'; // must come from the same module
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
import type SlInput from './input';
@@ -130,6 +131,8 @@ describe('<sl-input>', () => {
await el.updateComplete;
await sendKeys({ press: 'b' });
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
@@ -151,10 +154,24 @@ describe('<sl-input>', () => {
await sendKeys({ press: 'a' });
await sendKeys({ press: 'Backspace' });
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.hasAttribute('data-user-invalid')).to.be.true;
expect(el.hasAttribute('data-user-valid')).to.be.false;
});
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-input required></sl-input></form> `);
const input = el.querySelector<SlInput>('sl-input')!;
expect(input.hasAttribute('data-required')).to.be.true;
expect(input.hasAttribute('data-optional')).to.be.false;
expect(input.hasAttribute('data-invalid')).to.be.true;
expect(input.hasAttribute('data-valid')).to.be.false;
expect(input.hasAttribute('data-user-invalid')).to.be.false;
expect(input.hasAttribute('data-user-valid')).to.be.false;
});
});
describe('when submitting a form', () => {
@@ -218,6 +235,8 @@ describe('<sl-input>', () => {
input.focus();
await sendKeys({ type: 'test' });
await input.updateComplete;
input.blur();
await input.updateComplete;
expect(input.hasAttribute('data-user-invalid')).to.be.true;
expect(input.hasAttribute('data-user-valid')).to.be.false;
@@ -331,7 +350,7 @@ describe('<sl-input>', () => {
await el.updateComplete;
});
it('should not emit sl-change or sl-input when calling setinputText()', async () => {
it('should not emit sl-change or sl-input when calling setRangeText()', async () => {
const el = await fixture<SlInput>(html` <sl-input value="hi there"></sl-input> `);
el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted'));
@@ -478,4 +497,6 @@ describe('<sl-input>', () => {
expect(formControls.map((fc: HTMLInputElement) => fc.value).join('')).to.equal('12345678910'); // eslint-disable-line
});
});
runFormControlBaseTests('sl-input');
});

View File

@@ -14,18 +14,6 @@ import styles from './input.styles';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
//
// It's currently impossible to hide Firefox's built-in clear icon when using <input type="date|time">, so we need this
// check to apply a clip-path to hide it. I know, I know…user agent sniffing is nasty but, if it fails, we only see a
// redundant clear icon so nothing important is breaking. The benefits outweigh the costs for this one. See the
// discussion at: https://github.com/shoelace-style/shoelace/pull/794
//
// Also note that we do the Chromium check first to prevent Chrome from logging a console notice as described here:
// https://github.com/shoelace-style/shoelace/issues/855
//
const isChromium = navigator.userAgentData?.brands.some(b => b.brand.includes('Chromium'));
const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
/**
* @summary Inputs collect data from the user.
* @documentation https://shoelace.style/components/input
@@ -47,6 +35,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
* @event sl-clear - Emitted when the clear button is activated.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -63,7 +52,9 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this);
private readonly formControlController = new FormControlController(this, {
assumeInteractionOn: ['sl-blur', 'sl-input']
});
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
@@ -153,10 +144,10 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
@property({ type: Number }) maxlength: number;
/** The input's minimum value. Only applies to date and number input types. */
@property({ type: Number }) min: number;
@property() min: number | string;
/** The input's maximum value. Only applies to date and number input types. */
@property({ type: Number }) max: number;
@property() max: number | string;
/**
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
@@ -225,6 +216,16 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.value = input.value;
}
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@@ -260,8 +261,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.emit('sl-input');
}
private handleInvalid() {
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleKeyDown(event: KeyboardEvent) {
@@ -370,11 +372,16 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
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();
@@ -433,8 +440,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
'input--disabled': this.disabled,
'input--focused': this.hasFocus,
'input--empty': !this.value,
'input--no-spin-buttons': this.noSpinButtons,
'input--is-firefox': isFirefox
'input--no-spin-buttons': this.noSpinButtons
})}
>
<slot name="prefix" part="prefix" class="input__prefix"></slot>
@@ -455,9 +461,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
max=${ifDefined(this.max)}
step=${ifDefined(this.step as number)}
.value=${live(this.value)}
autocapitalize=${ifDefined(this.type === 'password' ? 'off' : this.autocapitalize)}
autocomplete=${ifDefined(this.type === 'password' ? 'off' : this.autocomplete)}
autocorrect=${ifDefined(this.type === 'password' ? 'off' : this.autocorrect)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${this.spellcheck}
pattern=${ifDefined(this.pattern)}

View File

@@ -60,7 +60,7 @@ export default css`
margin-inline-start: var(--sl-spacing-x-small);
}
:host(:focus) {
:host(:focus-visible) {
outline: none;
}

View File

@@ -1,4 +1,5 @@
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import type SlMenuItem from './menu-item';
@@ -26,13 +27,20 @@ describe('<sl-menu-item>', () => {
});
it('should render the correct aria attributes when disabled', async () => {
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
el.disabled = true;
await aTimeout(100);
const el = await fixture<SlMenuItem>(html` <sl-menu-item disabled>Test</sl-menu-item> `);
expect(el.getAttribute('aria-disabled')).to.equal('true');
});
it('should not emit the click event when disabled', async () => {
const el = await fixture<SlMenuItem>(html` <sl-menu-item disabled>Test</sl-menu-item> `);
const clickHandler = sinon.spy();
el.addEventListener('click', clickHandler);
await clickOnElement(el);
await el.updateComplete;
expect(clickHandler).to.not.have.been.called;
});
it('should return a text label when calling getTextLabel()', async () => {
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
expect(el.getTextLabel()).to.equal('Test');

View File

@@ -47,6 +47,17 @@ export default class SlMenuItem extends ShoelaceElement {
/** Draws the menu item in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
connectedCallback() {
super.connectedCallback();
this.handleHostClick = this.handleHostClick.bind(this);
this.addEventListener('click', this.handleHostClick);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this.handleHostClick);
}
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
@@ -63,6 +74,14 @@ export default class SlMenuItem extends ShoelaceElement {
}
}
private handleHostClick(event: MouseEvent) {
// Prevent the click event from being emitted when the button is disabled or loading
if (this.disabled) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
@watch('checked')
handleCheckedChange() {
// For proper accessibility, users have to use type="checkbox" to use the checked attribute

View File

@@ -4,7 +4,7 @@ import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlMenu from './menu';
import type SlMenuItem from '../menu-item/menu-item';
import type SlSelectEvent from '../../events/sl-select';
describe('<sl-menu>', () => {
it('emits sl-select with the correct event detail when clicking an item', async () => {
@@ -17,8 +17,8 @@ describe('<sl-menu>', () => {
</sl-menu>
`);
const item2 = menu.querySelectorAll('sl-menu-item')[1];
const selectHandler = sinon.spy((event: CustomEvent) => {
const item = event.detail.item as SlMenuItem; // eslint-disable-line
const selectHandler = sinon.spy((event: SlSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect event detail emitted with sl-select');
}
@@ -40,8 +40,8 @@ describe('<sl-menu>', () => {
</sl-menu>
`);
const [item1, item2] = menu.querySelectorAll('sl-menu-item');
const selectHandler = sinon.spy((event: CustomEvent) => {
const item = event.detail.item as SlMenuItem; // eslint-disable-line
const selectHandler = sinon.spy((event: SlSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect item selected');
}

View File

@@ -29,16 +29,6 @@ export default class SlMenu extends ShoelaceElement {
this.setAttribute('role', 'menu');
}
private getAllItems() {
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
if (el.inert || !this.isMenuItem(el)) {
return false;
}
return true;
}) as SlMenuItem[];
}
private handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const item = target.closest('sl-menu-item');
@@ -125,6 +115,16 @@ export default class SlMenu extends ShoelaceElement {
);
}
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
getAllItems() {
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
if (el.inert || !this.isMenuItem(el)) {
return false;
}
return true;
}) as SlMenuItem[];
}
/**
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.

View File

@@ -41,4 +41,14 @@ describe('<sl-option>', () => {
expect(slotChangeHandler).to.have.been.calledOnce;
});
it('should convert non-string values to string', async () => {
const el = await fixture<SlOption>(html` <sl-option>Text</sl-option> `);
// @ts-expect-error - intentional
el.value = 10;
await el.updateComplete;
expect(el.value).to.equal('10');
});
});

View File

@@ -92,6 +92,12 @@ export default class SlOption extends ShoelaceElement {
@watch('value')
handleValueChange() {
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
// instead of requiring them to cast the value to a string.
if (typeof this.value !== 'string') {
this.value = String(this.value);
}
if (this.value.includes(' ')) {
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
this.value = this.value.replace(/ /g, '_');

View File

@@ -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';
@@ -38,7 +39,7 @@ import type { CSSResultGroup } from 'lit';
export default class SlPopup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private anchorEl: HTMLElement | null;
private anchorEl: Element | null;
private cleanup: ReturnType<typeof autoUpdate> | undefined;
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@@ -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';
@@ -223,7 +224,7 @@ export default class SlPopup extends ShoelaceElement {
// Locate the anchor by id
const root = this.getRootNode() as Document | ShadowRoot;
this.anchorEl = root.getElementById(this.anchor);
} else if (this.anchor instanceof HTMLElement) {
} else if (this.anchor instanceof Element) {
// Use the anchor's reference
this.anchorEl = this.anchor;
} else {
@@ -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

View File

@@ -72,7 +72,7 @@ export default class SlQrCode extends ShoelaceElement {
part="base"
class="qr-code"
role="img"
aria-label=${this.label.length > 0 ? this.label : this.value}
aria-label=${this.label?.length > 0 ? this.label : this.value}
style=${styleMap({
width: `${this.size}px`,
height: `${this.size}px`

View File

@@ -1,7 +1,9 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
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';
@@ -130,6 +132,25 @@ describe('<sl-radio-group>', () => {
expect(radioGroup.hasAttribute('data-user-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-user-valid')).to.be.false;
});
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
const el = await fixture<HTMLFormElement>(html`
<form novalidate>
<sl-radio-group required>
<sl-radio value="1"></sl-radio>
<sl-radio value="2"></sl-radio>
</sl-radio-group>
</form>
`);
const radioGroup = el.querySelector<SlRadioGroup>('sl-radio-group')!;
expect(radioGroup.hasAttribute('data-required')).to.be.true;
expect(radioGroup.hasAttribute('data-optional')).to.be.false;
expect(radioGroup.hasAttribute('data-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-valid')).to.be.false;
expect(radioGroup.hasAttribute('data-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-user-valid')).to.be.false;
});
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
@@ -263,7 +284,7 @@ describe('when the value changes', () => {
`);
const radio = radioGroup.querySelector<SlRadio>('#radio-1')!;
setTimeout(() => radio.click());
const event = (await oneEvent(radioGroup, 'sl-change')) as CustomEvent;
const event = (await oneEvent(radioGroup, 'sl-change')) as SlChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
@@ -278,7 +299,7 @@ describe('when the value changes', () => {
const radio = radioGroup.querySelector<SlRadio>('#radio-1')!;
radio.focus();
setTimeout(() => sendKeys({ press: ' ' }));
const event = (await oneEvent(radioGroup, 'sl-change')) as CustomEvent;
const event = (await oneEvent(radioGroup, 'sl-change')) as SlChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
@@ -296,4 +317,6 @@ describe('when the value changes', () => {
radioGroup.value = '2';
await radioGroup.updateComplete;
});
runFormControlBaseTests('sl-radio-group');
});

View File

@@ -1,7 +1,12 @@
import '../button-group/button-group';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { FormControlController } from '../../internal/form';
import {
customErrorValidityState,
FormControlController,
validValidityState,
valueMissingValidityState
} from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { watch } from '../../internal/watch';
@@ -26,6 +31,7 @@ import type SlRadioButton from '../radio-button/radio-button';
*
* @event sl-change - Emitted when the radio group's selected value changes.
* @event sl-input - Emitted when the radio group receives user input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -75,6 +81,34 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return customErrorValidityState;
} else if (isRequiredAndEmpty) {
return valueMissingValidityState;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return this.customValidityMessage;
} else if (isRequiredAndEmpty) {
return this.validationInput.validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.defaultValue = this.value;
@@ -163,34 +197,47 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
private handleSlotChange() {
const radios = this.getAllRadios();
if (customElements.get('sl-radio') || customElements.get('sl-radio-button')) {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot?.querySelector('button');
if (buttonRadio) {
buttonRadio.tabIndex = 0;
}
} else {
radios[0].tabIndex = 0;
}
}
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot!.querySelector('button')!;
buttonRadio.tabIndex = 0;
} else {
radios[0].tabIndex = 0;
}
}
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (this.hasButtonGroup) {
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (buttonGroup) {
buttonGroup.disableRole = true;
if (buttonGroup) {
buttonGroup.disableRole = true;
}
}
} else {
// Rerun this handler when <sl-radio> or <sl-radio-button> is registered
customElements.whenDefined('sl-radio').then(() => this.handleSlotChange());
customElements.whenDefined('sl-radio-button').then(() => this.handleSlotChange());
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.formControlController.setValidity(this.checkValidity());
this.formControlController.setValidity(this.validity.valid);
}
@watch('value')
@@ -200,29 +247,27 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (isRequiredAndEmpty || hasCustomValidityMessage) {
this.formControlController.emitInvalidEvent();
return false;
}
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. */
reportValidity(): boolean {
const isValid = this.checkValidity();
const isValid = this.validity.valid;
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
this.formControlController.setValidity(isValid);
@@ -239,6 +284,14 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
return isValid;
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message = '') {
this.customValidityMessage = message;
this.errorMessage = message;
this.validationInput.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
@@ -289,6 +342,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
?required=${this.required}
tabindex="-1"
hidden
@invalid=${this.handleInvalid}
/>
</label>
</div>

View File

@@ -1,5 +1,6 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -164,11 +165,26 @@ describe('<sl-range>', () => {
await clickOnElement(range);
await range.updateComplete;
range.blur();
await range.updateComplete;
expect(range.hasAttribute('data-user-invalid')).to.be.true;
expect(range.hasAttribute('data-user-valid')).to.be.false;
});
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-range></sl-range></form> `);
const range = el.querySelector<SlRange>('sl-range')!;
range.setCustomValidity('Invalid value');
await range.updateComplete;
expect(range.hasAttribute('data-invalid')).to.be.true;
expect(range.hasAttribute('data-valid')).to.be.false;
expect(range.hasAttribute('data-user-invalid')).to.be.false;
expect(range.hasAttribute('data-user-valid')).to.be.false;
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
@@ -214,4 +230,6 @@ describe('<sl-range>', () => {
expect(input.value).to.equal(0);
});
});
runFormControlBaseTests('sl-range');
});

View File

@@ -1,5 +1,5 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { defaultValue } from '../../internal/default-value';
import { FormControlController } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
@@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -101,6 +102,16 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = 0;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());
@@ -145,6 +156,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
this.emit('sl-focus');
}
@eventOptions({ passive: true })
private handleThumbDragStart() {
this.hasTooltip = true;
}
@@ -207,6 +219,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
/** Sets focus on the range. */
focus(options?: FocusOptions) {
this.input.focus(options);
@@ -233,11 +250,16 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
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();
@@ -306,8 +328,9 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
.value=${live(this.value.toString())}
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@focus=${this.handleFocus}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
/>
${this.tooltip !== 'none' && !this.disabled

View File

@@ -79,6 +79,20 @@ describe('<sl-rating>', () => {
expect(el.value).to.equal(1);
});
it('should not emit sl-change when disabled', async () => {
const el = await fixture<SlRating>(html` <sl-rating value="5" disabled></sl-rating> `);
const lastSymbol = el.shadowRoot!.querySelector<HTMLSpanElement>('.rating__symbol:last-child')!;
const changeHandler = sinon.spy();
el.addEventListener('sl-change', changeHandler);
await clickOnElement(lastSymbol);
await el.updateComplete;
expect(changeHandler).to.not.have.been.called;
expect(el.value).to.equal(5);
});
it('should not emit sl-change when the value is changed programmatically', async () => {
const el = await fixture<SlRating>(html` <sl-rating label="Test" value="1"></sl-rating> `);
el.addEventListener('sl-change', () => expect.fail('sl-change incorrectly emitted'));

View File

@@ -1,7 +1,7 @@
import '../icon/icon';
import { clamp } from '../../internal/math';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { styleMap } from 'lit/directives/style-map.js';
@@ -89,6 +89,10 @@ export default class SlRating extends ShoelaceElement {
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
return;
}
this.setValue(this.getValueFromMousePosition(event));
this.emit('sl-change');
}
@@ -159,6 +163,7 @@ export default class SlRating extends ShoelaceElement {
event.preventDefault();
}
@eventOptions({ passive: true })
private handleTouchMove(event: TouchEvent) {
this.hoverValue = this.getValueFromTouchPosition(event);
}

View File

@@ -85,6 +85,8 @@ export default css`
.select__value-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0;

View File

@@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -161,6 +162,32 @@ describe('<sl-select>', () => {
await el.updateComplete;
});
it('should emit sl-change and sl-input with the correct validation message when the value changes', async () => {
const el = await fixture<SlSelect>(html`
<sl-select required>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
`);
const option2 = el.querySelectorAll('sl-option')[1];
const handler = sinon.spy((event: CustomEvent) => {
if (el.validationMessage) {
expect.fail(`Validation message should be empty when ${event.type} is emitted and a value is set`);
}
});
el.addEventListener('sl-change', handler);
el.addEventListener('sl-input', handler);
await clickOnElement(el);
await aTimeout(500);
await clickOnElement(option2);
await el.updateComplete;
expect(handler).to.be.calledTwice;
});
});
it('should open the listbox when any letter key is pressed with sl-select is on focus', async () => {
@@ -263,6 +290,8 @@ describe('<sl-select>', () => {
await el.show();
await clickOnElement(secondOption);
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
@@ -290,10 +319,32 @@ describe('<sl-select>', () => {
await clickOnElement(secondOption);
el.value = '';
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.hasAttribute('data-user-invalid')).to.be.true;
expect(el.hasAttribute('data-user-valid')).to.be.false;
});
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
const el = await fixture<HTMLFormElement>(html`
<form novalidate>
<sl-select required>
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
</form>
`);
const select = el.querySelector<SlSelect>('sl-select')!;
expect(select.hasAttribute('data-required')).to.be.true;
expect(select.hasAttribute('data-optional')).to.be.false;
expect(select.hasAttribute('data-invalid')).to.be.true;
expect(select.hasAttribute('data-valid')).to.be.false;
expect(select.hasAttribute('data-user-invalid')).to.be.false;
expect(select.hasAttribute('data-user-valid')).to.be.false;
});
});
describe('when submitting a form', () => {
@@ -524,4 +575,6 @@ describe('<sl-select>', () => {
expect(tag.hasAttribute('pill')).to.be.true;
});
runFormControlBaseTests('sl-select');
});

View File

@@ -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.
@@ -46,6 +47,7 @@ import type SlPopup from '../popup/popup';
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
* @event sl-hide - Emitted when the select's menu closes.
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -57,6 +59,10 @@ import type SlPopup from '../popup/popup';
* @csspart listbox - The listbox container where options are slotted.
* @csspart tags - The container that houses option tags when `multiselect` is used.
* @csspart tag - The individual tags that represent each multiselect option.
* @csspart tag__base - The tag's base part.
* @csspart tag__content - The tag's content part.
* @csspart tag__remove-button - The tag's remove button.
* @csspart tag__remove-button__base - The tag's remove button base part.
* @csspart clear-button - The clear button.
* @csspart expand-icon - The container that wraps the expand icon.
*/
@@ -64,7 +70,9 @@ import type SlPopup from '../popup/popup';
export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this);
private readonly formControlController = new FormControlController(this, {
assumeInteractionOn: ['sl-blur', 'sl-input']
});
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
private typeToSelectString = '';
@@ -160,6 +168,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.valueInput.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.valueInput.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this);
@@ -239,8 +257,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.setSelectedOptions(this.currentOption);
}
this.emit('sl-input');
this.emit('sl-change');
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
if (!this.multiple) {
this.hide();
@@ -364,9 +385,13 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
if (this.value !== '') {
this.setSelectedOptions([]);
this.displayInput.focus({ preventScroll: true });
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
// Emit after update
this.updateComplete.then(() => {
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
});
}
}
@@ -392,8 +417,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
if (this.value !== oldValue) {
this.emit('sl-input');
this.emit('sl-change');
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
if (!this.multiple) {
@@ -409,27 +437,28 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
const values: string[] = [];
// Check for duplicate values in menu items
allOptions.forEach(option => {
if (values.includes(option.value)) {
console.error(
`An option with a duplicate value of "${option.value}" has been found in <sl-select>. All options must have unique values.`,
option
);
}
values.push(option.value);
});
if (customElements.get('sl-option')) {
allOptions.forEach(option => values.push(option.value));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
} else {
// Rerun this handler when <sl-option> is registered
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
}
}
private handleTagRemove(event: CustomEvent, option: SlOption) {
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
event.stopPropagation();
if (!this.disabled) {
this.toggleOptionSelection(option, false);
this.emit('sl-input');
this.emit('sl-change');
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
}
@@ -518,6 +547,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
});
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Close the listbox when the control is disabled
@@ -601,11 +635,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
return waitForEvent(this, 'sl-after-hide');
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
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();
@@ -723,10 +762,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
return html`
<sl-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base
"
?pill=${this.pill}
size=${this.size}
removable
@sl-remove=${(event: CustomEvent) => this.handleTagRemove(event, option)}
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
>
${option.getTextLabel()}
</sl-tag>
@@ -750,6 +795,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
tabindex="-1"
aria-hidden="true"
@focus=${() => this.focus()}
@invalid=${this.handleInvalid}
/>
${hasClearIcon
@@ -788,17 +834,17 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
@slotchange=${this.handleDefaultSlotChange}
></slot>
</sl-popup>
<slot
name="help-text"
part="form-control-help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
${this.helpText}
</slot>
</div>
<slot
name="help-text"
part="form-control-help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
${this.helpText}
</slot>
</div>
`;
}

View File

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

View File

@@ -33,6 +33,7 @@ export default css`
}
.switch {
position: relative;
display: inline-flex;
align-items: center;
font-family: var(--sl-input-font-family);
@@ -153,4 +154,11 @@ export default css`
content: var(--sl-input-required-content);
margin-inline-start: var(--sl-input-required-content-offset);
}
@media (forced-colors: active) {
.switch.switch--checked:not(.switch--disabled) .switch__control:hover .switch__thumb,
.switch--checked .switch__control .switch__thumb {
background-color: ButtonText;
}
}
`;

View File

@@ -1,4 +1,5 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlSwitch from './switch';
@@ -113,6 +114,21 @@ describe('<sl-switch>', () => {
await el.updateComplete;
});
it('should hide the native input with the correct positioning to scroll correctly when contained in an overflow', async () => {
//
// See: https://github.com/shoelace-style/shoelace/issues/1169
//
const el = await fixture<SlSwitch>(html` <sl-switch></sl-switch> `);
const label = el.shadowRoot!.querySelector('.switch')!;
const input = el.shadowRoot!.querySelector('.switch__input')!;
const labelPosition = getComputedStyle(label).position;
const inputPosition = getComputedStyle(input).position;
expect(labelPosition).to.equal('relative');
expect(inputPosition).to.equal('absolute');
});
describe('when submitting a form', () => {
it('should submit the correct value when a value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
@@ -202,6 +218,18 @@ describe('<sl-switch>', () => {
expect(formData.get('a')).to.equal('1');
});
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-switch required></sl-switch></form> `);
const slSwitch = el.querySelector<SlSwitch>('sl-switch')!;
expect(slSwitch.hasAttribute('data-required')).to.be.true;
expect(slSwitch.hasAttribute('data-optional')).to.be.false;
expect(slSwitch.hasAttribute('data-invalid')).to.be.true;
expect(slSwitch.hasAttribute('data-valid')).to.be.false;
expect(slSwitch.hasAttribute('data-user-invalid')).to.be.false;
expect(slSwitch.hasAttribute('data-user-valid')).to.be.false;
});
});
describe('when resetting a form', () => {
@@ -233,4 +261,6 @@ describe('<sl-switch>', () => {
expect(switchEl.checked).to.false;
});
});
runFormControlBaseTests('sl-switch');
});

View File

@@ -23,6 +23,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @event sl-change - Emitted when the control's checked state changes.
* @event sl-input - Emitted when the control receives input.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart control - The control that houses the switch's thumb.
@@ -76,6 +77,16 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
/** Makes the switch a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@@ -89,6 +100,11 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleClick() {
this.checked = !this.checked;
this.emit('sl-change');
@@ -142,11 +158,16 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.input.blur();
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
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();
@@ -185,6 +206,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
aria-checked=${this.checked ? 'true' : 'false'}
@click=${this.handleClick}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@keydown=${this.handleKeyDown}

View File

@@ -0,0 +1,446 @@
import { aTimeout, elementUpdated, expect, fixture, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { html } from 'lit';
import { isElementVisibleFromOverflow } from '../../internal/test/element-visible-overflow';
import { queryByTestId } from '../../internal/test/data-testid-helpers';
import { sendKeys } from '@web/test-runner-commands';
import { waitForScrollingToEnd } from '../../internal/test/wait-for-scrolling';
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;
}
const waitForScrollButtonsToBeRendered = async (tabGroup: SlTabGroup): Promise<void> => {
await waitUntil(() => {
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
return scrollButtons?.length === 2;
});
};
const getClientRectangles = (tabGroup: SlTabGroup): ClientRectangles => {
const shadowRoot = tabGroup.shadowRoot;
if (shadowRoot) {
const nav = shadowRoot.querySelector<HTMLElement>('[part=nav]');
const body = shadowRoot.querySelector<HTMLElement>('[part=body]');
return {
body: body?.getBoundingClientRect(),
navigation: nav?.getBoundingClientRect()
};
}
return {};
};
const expectHeaderToBeVisible = (container: HTMLElement, dataTestId: string): void => {
const generalHeader = queryByTestId<SlTab>(container, dataTestId);
expect(generalHeader).not.to.be.null;
expect(generalHeader).to.be.visible;
};
const expectOnlyOneTabPanelToBeActive = async (container: HTMLElement, dataTestIdOfActiveTab: string) => {
await waitUntil(() => {
const tabPanels = Array.from(container.getElementsByTagName('sl-tab-panel'));
const activeTabPanels = tabPanels.filter((element: SlTabPanel) => element.hasAttribute('active'));
return activeTabPanels.length === 1;
});
const tabPanels = Array.from(container.getElementsByTagName('sl-tab-panel'));
const activeTabPanels = tabPanels.filter((element: SlTabPanel) => element.hasAttribute('active'));
expect(activeTabPanels).to.have.lengthOf(1);
expect(activeTabPanels[0]).to.have.attribute('data-testid', dataTestIdOfActiveTab);
};
const expectPromiseToHaveName = async (showEventPromise: Promise<SlTabShowEvent>, expectedName: string) => {
const showEvent = await showEventPromise;
expect(showEvent.detail.name).to.equal(expectedName);
};
const waitForHeaderToBeActive = async (container: HTMLElement, headerTestId: string): Promise<SlTab> => {
const generalHeader = queryByTestId<SlTab>(container, headerTestId);
await waitUntil(() => {
return generalHeader?.hasAttribute('active');
});
if (generalHeader) {
return generalHeader;
} else {
throw new Error(`did not find error with testid=${headerTestId}`);
}
};
describe('<sl-tab-group>', () => {
it('renders', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
expect(tabGroup).to.be.visible;
});
it('is accessible', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
await expect(tabGroup).to.be.accessible();
});
it('displays all tabs', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-tab-header">General</sl-tab>
<sl-tab slot="nav" panel="disabled" disabled data-testid="disabled-tab-header">Disabled</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="disabled">This is a disabled tab panel.</sl-tab-panel>
</sl-tab-group>
`);
expectHeaderToBeVisible(tabGroup, 'general-tab-header');
expectHeaderToBeVisible(tabGroup, 'disabled-tab-header');
});
it('shows the first tab to be active by default', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
await expectOnlyOneTabPanelToBeActive(tabGroup, 'general-tab-content');
});
describe('proper positioning', () => {
it('shows the header above the tabs by default', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.top).to.be.greaterThanOrEqual(clientRectangles.navigation?.bottom || -Infinity);
});
it('shows the header below the tabs by setting placement to bottom', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.placement = 'bottom';
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.bottom).to.be.lessThanOrEqual(clientRectangles.navigation?.top || +Infinity);
});
it('shows the header left of the tabs by setting placement to start', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.placement = 'start';
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.left).to.be.greaterThanOrEqual(clientRectangles.navigation?.right || -Infinity);
});
it('shows the header right of the tabs by setting placement to end', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.placement = 'end';
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.right).to.be.lessThanOrEqual(clientRectangles.navigation?.left || -Infinity);
});
});
describe('scrolling behavior', () => {
const generateTabs = (n: number): HTMLTemplateResult[] => {
const result: HTMLTemplateResult[] = [];
for (let i = 0; i < n; i++) {
result.push(html`<sl-tab slot="nav" panel="tab-${i}">Tab ${i}</sl-tab>
<sl-tab-panel name="tab-${i}">Content of tab ${i}0</sl-tab-panel> `);
}
return result;
};
before(() => {
// disabling failing on resize observer ... unfortunately on webkit this is not really specific
// https://github.com/WICG/resize-observer/issues/38#issuecomment-422126006
// https://stackoverflow.com/a/64197640
const errorHandler = window.onerror;
window.onerror = (
event: string | Event,
source?: string | undefined,
lineno?: number | undefined,
colno?: number | undefined,
error?: Error | undefined
) => {
if ((event as string).includes('ResizeObserver') || event === 'Script error.') {
return true;
} else if (errorHandler) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return errorHandler(event, source, lineno, colno, error);
} else {
return true;
}
};
});
it('shows scroll buttons on too many tabs', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(30)} </sl-tab-group>`);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons, 'Both scroll buttons should be shown').to.have.length(2);
tabGroup.disconnectedCallback();
});
it('does not show scroll buttons on too many tabs if deactivated', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(30)} </sl-tab-group>`);
tabGroup.noScrollControls = true;
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does not show scroll buttons if all tabs fit on the screen', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(2)} </sl-tab-group>`);
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does not show scroll buttons if placement is start', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(50)} </sl-tab-group>`);
tabGroup.placement = 'start';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does not show scroll buttons if placement is end', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(50)} </sl-tab-group>`);
tabGroup.placement = 'end';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does scroll on scroll button click', async () => {
const numberOfElements = 15;
const tabGroup = await fixture<SlTabGroup>(
html`<sl-tab-group> ${generateTabs(numberOfElements)} </sl-tab-group>`
);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(2);
const firstTab = tabGroup.querySelector('[panel="tab-0"]');
expect(firstTab).not.to.be.null;
const lastTab = tabGroup.querySelector(`[panel="tab-${numberOfElements - 1}"]`);
expect(lastTab).not.to.be.null;
expect(isElementVisibleFromOverflow(tabGroup, firstTab!)).to.be.true;
expect(isElementVisibleFromOverflow(tabGroup, lastTab!)).to.be.false;
const scrollToRightButton = tabGroup.shadowRoot?.querySelector('sl-icon-button[part*="scroll-button--end"]');
expect(scrollToRightButton).not.to.be.null;
await clickOnElement(scrollToRightButton!);
await elementUpdated(tabGroup);
await waitForScrollingToEnd(firstTab!);
await waitForScrollingToEnd(lastTab!);
expect(isElementVisibleFromOverflow(tabGroup, firstTab!)).to.be.false;
expect(isElementVisibleFromOverflow(tabGroup, lastTab!)).to.be.true;
});
});
describe('tab selection', () => {
const expectCustomTabToBeActiveAfter = async (tabGroup: SlTabGroup, action: () => Promise<void>): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<SlTabShowEvent>;
await action();
expect(customHeader).to.have.attribute('active');
await expectPromiseToHaveName(showEventPromise, 'custom');
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
};
const expectGeneralTabToBeStillActiveAfter = async (
tabGroup: SlTabGroup,
action: () => Promise<void>
): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
let showEventFired = false;
let hideEventFired = false;
oneEvent(tabGroup, 'sl-tab-show').then(() => (showEventFired = true));
oneEvent(tabGroup, 'sl-tab-hide').then(() => (hideEventFired = true));
await action();
expect(generalHeader).to.have.attribute('active');
expect(showEventFired).to.be.false;
expect(hideEventFired).to.be.false;
return expectOnlyOneTabPanelToBeActive(tabGroup, 'general-tab-content');
};
it('selects a tab by clicking on it', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
return expectCustomTabToBeActiveAfter(tabGroup, () => clickOnElement(customHeader!));
});
it('does not change if the active tab is reselected', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const generalHeader = queryByTestId(tabGroup, 'general-header');
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => clickOnElement(generalHeader!));
});
it('does not change if a disabled tab is clicked', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="disabled" data-testid="disabled-header" disabled>disabled</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="disabled">This is the disabled tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const disabledHeader = queryByTestId(tabGroup, 'disabled-header');
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => clickOnElement(disabledHeader!));
});
it('selects a tab by using the arrow keys', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
return expectCustomTabToBeActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
});
it('selects a tab by using the arrow keys and enter if activation is set to manual', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.activation = 'manual';
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<SlTabShowEvent>;
await sendKeys({ press: 'ArrowRight' });
await aTimeout(0);
expect(generalHeader).to.have.attribute('active');
await sendKeys({ press: 'Enter' });
expect(customHeader).to.have.attribute('active');
await expectPromiseToHaveName(showEventPromise, 'custom');
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
});
it('does not allow selection of disabled tabs with arrow keys', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="disabled" disabled>Disabled</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="disabled">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
});
it('selects a tab by using the show function', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
return expectCustomTabToBeActiveAfter(tabGroup, () => {
tabGroup.show('custom');
return aTimeout(0);
});
});
});
});

View File

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

View File

@@ -35,14 +35,6 @@ describe('<sl-tab-panel>', () => {
expect(el.getAttribute('aria-hidden')).to.equal('false');
});
it('changing active should always update aria-hidden role', async () => {
const el = await fixture<SlTabPanel>(html` <sl-tab-panel>Test</sl-tab-panel> `);
el.active = true;
await aTimeout(100);
expect(el.getAttribute('aria-hidden')).to.equal('false');
});
it('passed id should be used', async () => {
const el = await fixture<SlTabPanel>(html` <sl-tab-panel id="test-id">Test</sl-tab-panel> `);

View File

@@ -1,6 +1,8 @@
import { expect, fixture, html } from '@open-wc/testing';
import sinon from 'sinon';
import type SlIconButton from '../icon-button/icon-button';
import type SlTab from './tab';
import type SlTabGroup from '../tab-group/tab-group';
describe('<sl-tab>', () => {
it('passes accessibility test', async () => {
@@ -88,17 +90,31 @@ describe('<sl-tab>', () => {
});
describe('closable', () => {
it('should emit close event when close button clicked', async () => {
const el = await fixture<SlTab>(html` <sl-tab closable>Test</sl-tab> `);
it('should emit close event when the close button is clicked', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" closable>General</sl-tab>
<sl-tab slot="nav" panel="custom" closable>Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const closeButton = tabGroup
.querySelectorAll('sl-tab')[0]!
.shadowRoot!.querySelector<SlIconButton>('[part~="close-button"]')!;
const closeButton = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="close-button"]')!;
const spy = sinon.spy();
const handleClose = sinon.spy();
const handleTabShow = sinon.spy();
el.addEventListener('sl-close', spy, { once: true });
tabGroup.addEventListener('sl-close', handleClose, { once: true });
// The sl-tab-show event shouldn't be emitted when clicking the close button
tabGroup.addEventListener('sl-tab-show', handleTabShow);
closeButton.click();
await closeButton?.updateComplete;
expect(spy.called).to.equal(true);
expect(handleClose.called).to.equal(true);
expect(handleTabShow.called).to.equal(false);
});
});
});

View File

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

View File

@@ -1,4 +1,5 @@
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -147,6 +148,8 @@ describe('<sl-textarea>', () => {
el.focus();
await sendKeys({ press: 'b' });
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
@@ -167,10 +170,24 @@ describe('<sl-textarea>', () => {
await sendKeys({ press: 'a' });
await sendKeys({ press: 'Backspace' });
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.hasAttribute('data-user-invalid')).to.be.true;
expect(el.hasAttribute('data-user-valid')).to.be.false;
});
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-textarea required></sl-textarea></form> `);
const textarea = el.querySelector<SlTextarea>('sl-textarea')!;
expect(textarea.hasAttribute('data-required')).to.be.true;
expect(textarea.hasAttribute('data-optional')).to.be.false;
expect(textarea.hasAttribute('data-invalid')).to.be.true;
expect(textarea.hasAttribute('data-valid')).to.be.false;
expect(textarea.hasAttribute('data-user-invalid')).to.be.false;
expect(textarea.hasAttribute('data-user-valid')).to.be.false;
});
});
describe('when submitting a form', () => {
@@ -201,6 +218,8 @@ describe('<sl-textarea>', () => {
textarea.focus();
await sendKeys({ type: 'test' });
await textarea.updateComplete;
textarea.blur();
await textarea.updateComplete;
expect(textarea.hasAttribute('data-user-invalid')).to.be.true;
expect(textarea.hasAttribute('data-user-valid')).to.be.false;
@@ -274,4 +293,6 @@ describe('<sl-textarea>', () => {
expect(textarea.spellcheck).to.be.false;
});
});
runFormControlBaseTests('sl-textarea');
});

View File

@@ -25,6 +25,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -37,7 +38,9 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
export default class SlTextarea extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this);
private readonly formControlController = new FormControlController(this, {
assumeInteractionOn: ['sl-blur', 'sl-input']
});
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private resizeObserver: ResizeObserver;
@@ -133,6 +136,16 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight());
@@ -173,6 +186,11 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private setTextareaHeight() {
if (this.resize === 'auto') {
this.input.style.height = 'auto';
@@ -258,11 +276,16 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
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();
@@ -342,6 +365,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
></textarea>

View File

@@ -1,4 +1,5 @@
import { expect, fixture, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing';
import { aTimeout, expect, fixture, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlTree from './tree';
@@ -433,7 +434,7 @@ describe('<sl-tree>', () => {
const expandButton: HTMLElement = node.shadowRoot!.querySelector('.tree-item__expand-button')!;
// Act
expandButton.click();
await clickOnElement(expandButton);
await el.updateComplete;
// Assert
@@ -453,10 +454,10 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
node0.click();
await clickOnElement(node0);
await el.updateComplete;
node1.click();
await clickOnElement(node1);
await el.updateComplete;
// Assert
@@ -474,10 +475,10 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
node0.click();
await clickOnElement(node0);
await el.updateComplete;
node1.click();
await clickOnElement(node1);
await el.updateComplete;
// Assert
@@ -492,7 +493,7 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
parentNode.click();
await clickOnElement(parentNode);
await parentNode.updateComplete;
// Assert
@@ -511,10 +512,10 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Act
node0.click();
await clickOnElement(node0);
await el.updateComplete;
node1.click();
await clickOnElement(node1);
await el.updateComplete;
// Assert
@@ -529,7 +530,7 @@ describe('<sl-tree>', () => {
const parentNode = el.children[2] as SlTreeItem;
// Act
parentNode.click();
await clickOnElement(parentNode);
await el.updateComplete;
// Assert
@@ -549,7 +550,10 @@ describe('<sl-tree>', () => {
const childNode = parentNode.children[0] as SlTreeItem;
// Act
childNode.click();
parentNode.expanded = true;
await parentNode.updateComplete;
await aTimeout(300);
await clickOnElement(childNode);
await el.updateComplete;
// Assert
@@ -572,9 +576,9 @@ describe('<sl-tree>', () => {
const node = el.children[0] as SlTreeItem;
// Act
node.click();
await clickOnElement(node);
await el.updateComplete;
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert
@@ -598,9 +602,9 @@ describe('<sl-tree>', () => {
const node = el.children[0] as SlTreeItem;
// Act
node.click();
await clickOnElement(node);
await el.updateComplete;
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert
@@ -621,7 +625,7 @@ describe('<sl-tree>', () => {
const node = el.querySelector<SlTreeItem>('#expandable')!;
// Act
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert
@@ -643,9 +647,9 @@ describe('<sl-tree>', () => {
const node = el.children[0] as SlTreeItem;
// Act
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
node.click();
await clickOnElement(node);
await Promise.all([node.updateComplete, el.updateComplete]);
// Assert

View File

@@ -54,7 +54,7 @@ function syncCheckboxes(changedTreeItem: SlTreeItem, initialSync = false) {
* @status stable
* @since 2.0
*
* @event {{ selection: TreeItem[] }} sl-selection-change - Emitted when a tree item is selected or deselected.
* @event {{ selection: SlTreeItem[] }} sl-selection-change - Emitted when a tree item is selected or deselected.
*
* @slot - The default slot.
* @slot expand-icon - The icon to show when the tree item is expanded. Works best with `<sl-icon>`.
@@ -90,6 +90,7 @@ export default class SlTree extends ShoelaceElement {
private lastFocusedItem: SlTreeItem;
private readonly localize = new LocalizeController(this);
private mutationObserver: MutationObserver;
private clickTarget: SlTreeItem | null = null;
async connectedCallback() {
super.connectedCallback();
@@ -292,13 +293,20 @@ export default class SlTree extends ShoelaceElement {
}
private handleClick(event: Event) {
const target = event.target as HTMLElement;
const target = event.target as SlTreeItem;
const treeItem = target.closest('sl-tree-item')!;
const isExpandButton = event
.composedPath()
.some((el: HTMLElement) => el?.classList?.contains('tree-item__expand-button'));
if (!treeItem || treeItem.disabled) {
//
// Don't Do anything if there's no tree item, if it's disabled, or if the click doesn't match the initial target
// from mousedown. The latter case prevents the user from starting a click on one item and ending it on another,
// causing the parent node to collapse.
//
// See https://github.com/shoelace-style/shoelace/issues/1082
//
if (!treeItem || treeItem.disabled || target !== this.clickTarget) {
return;
}
@@ -309,6 +317,11 @@ export default class SlTree extends ShoelaceElement {
}
}
handleMouseDown(event: MouseEvent) {
// Record the click target so we know which item the click initially targeted
this.clickTarget = event.target as SlTreeItem;
}
private handleFocusOut(event: FocusEvent) {
const relatedTarget = event.relatedTarget as HTMLElement;
@@ -392,7 +405,13 @@ export default class SlTree extends ShoelaceElement {
render() {
return html`
<div part="base" class="tree" @click=${this.handleClick} @keydown=${this.handleKeyDown}>
<div
part="base"
class="tree"
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mousedown=${this.handleMouseDown}
>
<slot @slotchange=${this.handleSlotChange}></slot>
<slot name="expand-icon" hidden aria-hidden="true"> </slot>
<slot name="collapse-icon" hidden aria-hidden="true"> </slot>

34
src/events/events.ts Normal file
View File

@@ -0,0 +1,34 @@
export { default as SlAfterCollapseEvent } from './sl-after-collapse';
export { default as SlAfterExpandEvent } from './sl-after-expand';
export { default as SlAfterHideEvent } from './sl-after-hide';
export { default as SlAfterShowEvent } from './sl-after-show';
export { default as SlBlurEvent } from './sl-blur';
export { default as SlCancelEvent } from './sl-cancel';
export { default as SlChangeEvent } from './sl-change';
export { default as SlClearEvent } from './sl-clear';
export { default as SlCloseEvent } from './sl-close';
export { default as SlCollapseEvent } from './sl-collapse';
export { default as SlErrorEvent } from './sl-error';
export { default as SlExpandEvent } from './sl-expand';
export { default as SlFinishEvent } from './sl-finish';
export { default as SlFocusEvent } from './sl-focus';
export { default as SlHideEvent } from './sl-hide';
export { default as SlHoverEvent } from './sl-hover';
export { default as SlInitialFocusEvent } from './sl-initial-focus';
export { default as SlInputEvent } from './sl-input';
export { default as SlInvalidEvent } from './sl-invalid';
export { default as SlLazyChangeEvent } from './sl-lazy-change';
export { default as SlLazyLoadEvent } from './sl-lazy-load';
export { default as SlLoadEvent } from './sl-load';
export { default as SlMutationEvent } from './sl-mutation';
export { default as SlRemoveEvent } from './sl-remove';
export { default as SlRepositionEvent } from './sl-reposition';
export { default as SlRequestCloseEvent } from './sl-request-close';
export { default as SlResizeEvent } from './sl-resize';
export { default as SlSelectEvent } from './sl-select';
export { default as SlSelectionChangeEvent } from './sl-selection-change';
export { default as SlShowEvent } from './sl-show';
export { default as SlSlideChange } from './sl-slide-change';
export { default as SlStartEvent } from './sl-start';
export { default as SlTabHideEvent } from './sl-tab-hide';
export { default as SlTabShowEvent } from './sl-tab-show';

View File

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

View File

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

View File

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

View File

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

9
src/events/sl-blur.ts Normal file
View File

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

9
src/events/sl-cancel.ts Normal file
View File

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

9
src/events/sl-change.ts Normal file
View File

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

9
src/events/sl-clear.ts Normal file
View File

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

9
src/events/sl-close.ts Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More