Compare commits

..

295 Commits

Author SHA1 Message Date
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
Cory LaViska
deec097267 2.0.0 2023-01-24 11:43:38 -05:00
Cory LaViska
873e280700 update changelog 2023-01-24 11:38:17 -05:00
Cory LaViska
5d047f7a93 remove beta disclaimer 2023-01-23 12:55:45 -05:00
Cory LaViska
f24ab23752 add more design tokens to docs 2023-01-23 12:55:36 -05:00
Cory LaViska
44ecc8ce56 fixes #1141 2023-01-23 11:36:45 -05:00
Cory LaViska
e758b1d9bb whitespace 2023-01-23 11:29:50 -05:00
Cory LaViska
5cdbaa873d fixes #1141 2023-01-23 11:29:12 -05:00
Cory LaViska
e9aca6cedb fix part names; closes #1142 2023-01-23 10:51:24 -05:00
Cory LaViska
7c3896ed42 fix select input color; closes #1143 2023-01-23 10:44:19 -05:00
Cory LaViska
93158e8e90 fixes #1141 2023-01-22 13:34:32 -05:00
Cory LaViska
5f9bbdfa06 fixes #1139 2023-01-20 11:38:04 -05:00
Cory LaViska
3a0f486e98 fix label colors in checkbox, radio, and switch 2023-01-19 14:34:43 -05:00
Cory LaViska
29c671c0f4 fixes #1138 2023-01-19 14:22:29 -05:00
Cory LaViska
88c4bef5e7 Revert "Add JSON format for design tokens (#1129)"
This reverts commit 4a3f2caf59.
2023-01-19 11:37:52 -05:00
Cory LaViska
6066bc468b update config 2023-01-18 09:29:39 -05:00
Cory LaViska
efd944d822 remove contain:strict 2023-01-18 09:07:37 -05:00
Jared White
4a3f2caf59 Add JSON format for design tokens (#1129)
* Initial example of a JSON schema and converter for design tokens

* Clean up script and relocate file

* Update token JSON format and finish build process
2023-01-17 13:22:02 -05:00
Cory LaViska
511182b41b add padding to offset scrollbar; fixes #1132 2023-01-17 11:56:16 -05:00
Cory LaViska
1088a51ed5 fixes 1134 2023-01-17 10:45:19 -05:00
Cory LaViska
e3e0842bdd reorder importss 2023-01-17 10:44:07 -05:00
Cory LaViska
e4c908b08b add missing docs 2023-01-17 10:33:21 -05:00
Cory LaViska
f86578a213 fix tab panel display 2023-01-13 15:57:02 -05:00
Cory LaViska
3c2f5ec48e sort this, eslint 2023-01-13 15:43:55 -05:00
Cory LaViska
fec7ef17aa prettier 2023-01-13 14:42:21 -05:00
Cory LaViska
29ff99dd76 sigh 2023-01-13 14:37:45 -05:00
Cory LaViska
6b9b410bdc *put table back up* 2023-01-13 14:37:34 -05:00
Cory LaViska
b45a9d55ca update deps 2023-01-13 14:35:28 -05:00
Cory LaViska
7ce079b7a1 *flip table* 2023-01-13 14:29:25 -05:00
Cory LaViska
b0ba9ff14f remove expect 2023-01-13 14:22:45 -05:00
Cory LaViska
f665bf984b fix types 2023-01-13 14:22:06 -05:00
Cory LaViska
ac429a62c0 disable lint 2023-01-13 14:16:16 -05:00
Cory LaViska
dc909d10b6 fix words 2023-01-13 14:08:44 -05:00
Cory LaViska
aa65077b12 add getFormControls() method 2023-01-13 14:06:58 -05:00
Cory LaViska
7e37c51856 revert example 2023-01-13 12:52:02 -05:00
Cory LaViska
6e26daf804 add form attribute; fixes #1130 2023-01-13 12:34:33 -05:00
Cory LaViska
25c2d2d5bf upgrade CEM plugin 2023-01-13 10:39:59 -05:00
Cory LaViska
edc9e69f30 add @documentation tag 2023-01-12 10:26:25 -05:00
Cory LaViska
2e7ac38678 fixes #1127 2023-01-12 09:34:41 -05:00
Cory LaViska
1a68c825c0 update deps section 2023-01-11 15:29:51 -05:00
Cory LaViska
2cbdeeade0 remove cspell comment 2023-01-10 17:34:00 -05:00
Cory LaViska
79624f63ed add en-GB 2023-01-10 17:33:55 -05:00
xdev1
01a8ec36ec #1108 - simplification of translation files (#1120)
Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
2023-01-10 17:33:23 -05:00
Jens Gabe
60324885ed Updated Danish translations (#1123)
Fixed typos and copy/paste translations from other languages
2023-01-10 17:30:01 -05:00
Cory LaViska
0c2f43b837 fixes #1125 2023-01-10 17:10:18 -05:00
Cory LaViska
0df27cf730 never return -0 2023-01-10 16:14:30 -05:00
Cory LaViska
68ed69292c add validation tests; fixes #1065 2023-01-10 15:05:44 -05:00
Cory LaViska
62c58b3a8c simplify validation logic 2023-01-10 15:05:07 -05:00
Cory LaViska
1fbb809057 update docs comment 2023-01-10 15:04:53 -05:00
Cory LaViska
e2d2f5d670 rename FormSubmitController; remove this.invalid 2023-01-10 13:24:06 -05:00
Cory LaViska
acef0da2c1 remove unused classes 2023-01-10 12:28:44 -05:00
Cory LaViska
3c66d2ab99 fixes #1121 2023-01-10 10:48:55 -05:00
Cory LaViska
31f16c4680 fix changelog 2023-01-09 16:31:05 -05:00
Cory LaViska
8056379fdd support multiple properties in watch decorator 2023-01-09 16:07:59 -05:00
Cory LaViska
9a6b9a7841 fixes #1119 2023-01-09 14:54:17 -05:00
Cory LaViska
02fc39ebe0 add parts 2023-01-09 14:25:26 -05:00
Cory LaViska
a90b22c05d add test 2023-01-09 14:25:21 -05:00
Cory LaViska
f5dd4f2aca fixes #1117 2023-01-09 14:20:17 -05:00
Cory LaViska
d5b3489b22 fixes #1066 2023-01-09 13:06:34 -05:00
Cory LaViska
8ee5f19184 pin source-map to beta; fixes #1066 2023-01-09 12:55:35 -05:00
Cory LaViska
d0a32d48b1 upgrade CEM and fix comment 2023-01-09 10:47:10 -05:00
Cory LaViska
bf527437a0 add stylesheet to test runner; fix tests 2023-01-09 10:35:37 -05:00
Cory LaViska
ae3070ac45 fixes #1107 2023-01-09 09:40:51 -05:00
Cory LaViska
6af68343a7 stop removing eslint comments 2023-01-09 09:39:47 -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
Cory LaViska
e632b51eb8 fixes #1110 2023-01-06 16:43:31 -05:00
Cory LaViska
c8e633c4a1 fix color picker bug 2023-01-06 15:24:57 -05:00
Cory LaViska
724f4a59db remove log 2023-01-06 13:28:48 -05:00
Cory LaViska
041364fb7d ignore swatch whitespace 2023-01-06 13:28:30 -05:00
Cory LaViska
fbcb4d8dbd fixes #1109 2023-01-06 12:25:03 -05:00
Cory LaViska
27a6b4a8c9 fixes #1108 2023-01-06 11:50:10 -05:00
Cory LaViska
c814e9e94e 2.0.0-beta.88 2023-01-05 15:43:08 -05:00
Cory LaViska
0e957c0cd4 update version 2023-01-05 15:41:32 -05:00
Cory LaViska
b330657e0a focus on disabled menu items and fix aria-checked 2023-01-05 15:13:21 -05:00
Cory LaViska
a0fce64fd9 closes #1070 2023-01-05 14:50:19 -05:00
Cory LaViska
b183a04fba add auto 2023-01-05 13:33:10 -05:00
Cory LaViska
7645b997b2 fixes #1105 2023-01-05 13:30:39 -05:00
Cory LaViska
d81e2f1470 fixes #1063 2023-01-05 12:43:17 -05:00
Cory LaViska
f50fe72df2 don't show scrollbar 2023-01-05 12:40:22 -05:00
Cory LaViska
dcca64a986 use public method for validity 2023-01-05 11:54:10 -05:00
Cory LaViska
c216cfe0fd fix min/max types 2023-01-05 11:29:51 -05:00
Cory LaViska
192f15e3b7 update cem plugin 2023-01-05 11:29:34 -05:00
Cory LaViska
01be3daf6d update docs 2023-01-04 14:51:30 -05:00
Cory LaViska
d36eec5637 add info about updateComplete 2023-01-04 14:04:24 -05:00
Cory LaViska
121464fa2d make swatches an attribute 2023-01-04 12:37:16 -05:00
Cory LaViska
ee0254e822 fixes #1097 2023-01-04 11:29:11 -05:00
Cory LaViska
27f634402c fix switch default value; #1103 2023-01-04 11:04:25 -05:00
Cory LaViska
67fbe3b34e fixes #1101 2023-01-04 09:58:56 -05:00
Cory LaViska
164ebce990 remove tts test 2023-01-03 15:17:39 -05:00
Cory LaViska
571ae704e0 update changelog 2023-01-03 15:10:51 -05:00
Cory LaViska
ad305fb653 Remove orphaned code 2023-01-03 15:09:57 -05:00
Cory LaViska
fc0541ce53 make internal 2023-01-03 15:08:49 -05:00
Cory LaViska
c8555f448c reorg and add private keyword 2023-01-03 15:04:07 -05:00
Cory LaViska
96e41198ec remove comment 2023-01-03 14:24:27 -05:00
Cory LaViska
57064aef4d remove void 2023-01-03 13:36:12 -05:00
Cory LaViska
cf200aa58a update tests 2023-01-03 10:37:39 -05:00
Cory LaViska
388a4f85a4 Revert "Remove the need to bind member methods in the connectedCallback (#1081)"
This reverts commit 5f8556b1b2.
2023-01-03 10:19:25 -05:00
Jeremiah Hoyet
5f8556b1b2 Remove the need to bind member methods in the connectedCallback (#1081)
* Remove the need to bind member methods in the connectedCallback

* Remove console.log
2023-01-03 10:15:12 -05:00
Cory LaViska
e411b57124 fixes #1096 2023-01-03 10:10:14 -05:00
Cory LaViska
0120e7429d update changelog 2023-01-03 09:49:56 -05:00
sowiner
377dbe28eb add zh-tw translations (#1086)
* add zh-tw translations

* add numOptionsSelected translate
2023-01-03 09:48:56 -05:00
Cory LaViska
b25b1d5750 Update changelog 2023-01-03 09:05:16 -05:00
Alan Chambers
0e1b792bf7 Update make-react.js script to use new @lit-labs/react createComponent options object (#1090)
* Update react wrapper signature

Updated react createComponent to use new options object

* removed unused pascalCase import
2023-01-03 08:57:27 -05:00
Cory LaViska
417f0d17c9 don't scroll when refocusing 2022-12-28 17:21:11 -05:00
Cory LaViska
d9252fe755 ignore disabled controls 2022-12-28 17:18:48 -05:00
Cory LaViska
c5555ab5fe Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2022-12-28 16:50:37 -05:00
Cory LaViska
eb61dc7d91 update bootstrap-icons 2022-12-28 16:50:35 -05:00
Bünyamin Eskiocak
a4c522f090 Added control--checked and control--indeterminate parts to <sl-checkbox> (#1085)
* removed checked and indeterminate parts from doc

* Revert "removed checked and indeterminate parts from doc"

This reverts commit 5e702387e1ace4e5d6b424f861df8e6a3a1a4fdd.

* control--checked & control--indeterminate parts
2022-12-28 16:40:59 -05:00
Cory LaViska
a8fe8c3e71 update hover styles 2022-12-28 16:36:07 -05:00
Cory LaViska
87000306c0 remove unused import 2022-12-28 16:25:33 -05:00
Cory LaViska
ae07b7d0a8 update changelog 2022-12-28 16:22:08 -05:00
Cory LaViska
626b76610f fix example 2022-12-28 16:22:05 -05:00
Cory LaViska
b4a1e1b0c9 fix docs 2022-12-28 16:12:41 -05:00
Cory LaViska
913243f8c1 more finishing touches + tests 2022-12-28 16:07:37 -05:00
Cory LaViska
563ed81984 remove example 2022-12-28 16:07:22 -05:00
Cory LaViska
fcbf339a86 remove unused selectors 2022-12-28 16:07:17 -05:00
Cory LaViska
70585e1d2a finishing touches 2022-12-28 15:31:42 -05:00
Cory LaViska
479e568296 update docs 2022-12-28 15:31:05 -05:00
Cory LaViska
a473e41ab3 fix docs 2022-12-28 14:43:01 -05:00
Cory LaViska
92f6a2d8e9 update changelog 2022-12-28 11:42:34 -05:00
Cory LaViska
06dc5740bf updates 2022-12-28 11:42:08 -05:00
Cory LaViska
fe524e0fac ignore clear button keys 2022-12-20 20:49:10 -05:00
Cory LaViska
ea7de2eb70 document slots 2022-12-20 18:44:57 -05:00
Cory LaViska
b8d02537a6 add grouping example 2022-12-20 18:44:33 -05:00
Cory LaViska
24744ef8c5 docs + cleanup wrapper 2022-12-20 18:24:25 -05:00
Cory LaViska
69997466be update example 2022-12-20 18:23:51 -05:00
Cory LaViska
f7d7fdf5b1 rerorder props 2022-12-20 18:23:19 -05:00
Cory LaViska
c0013c5639 refactor into set function 2022-12-20 17:53:55 -05:00
Cory LaViska
935040204f fix alignment to match other controls 2022-12-20 17:53:45 -05:00
Cory LaViska
2ffbf9b017 update examples 2022-12-20 17:43:50 -05:00
Cory LaViska
0f67b9a9d1 remove unused var 2022-12-20 13:51:53 -05:00
Cory LaViska
c5dee51233 restore example 2022-12-20 13:50:48 -05:00
Cory LaViska
b07238d536 temporarily disable a11y bug 2022-12-20 13:37:30 -05:00
Cory LaViska
e2a65c28f4 update select examples 2022-12-20 13:37:05 -05:00
Cory LaViska
1f457cdde0 keep on keepin on 2022-12-20 13:36:53 -05:00
Cory LaViska
46fda5f0a6 upgrade 2022-12-20 13:36:06 -05:00
Cory LaViska
e22c2f839b don't select disabled options 2022-12-20 13:00:27 -05:00
Cory LaViska
5bff912162 loosen up that type 2022-12-20 13:00:13 -05:00
Cory LaViska
f3010cecbe fix tests 2022-12-20 12:13:39 -05:00
Cory LaViska
2dc275defd fix validity and events 2022-12-20 11:40:49 -05:00
Cory LaViska
9f79445292 improve scroll on open 2022-12-20 10:31:55 -05:00
Cory LaViska
10cb26b81e focus after update 2022-12-20 10:21:41 -05:00
Cory LaViska
3722e0ad91 focus after open 2022-12-19 18:17:41 -05:00
Cory LaViska
f28a0ec743 fix in screen readers 2022-12-19 17:46:31 -05:00
Cory LaViska
a42b393bf1 Merge branch 'next' into select 2022-12-17 11:29:58 -05:00
Cory LaViska
41f50777bd update changelog 2022-12-17 11:29:25 -05:00
Cory LaViska
6afc3ba12e select rewrite (incomplete) 2022-12-17 11:27:30 -05:00
Alessandro
d8b7040a9e fix(tree): add initial sync (#1080) 2022-12-17 11:26:42 -05:00
235 changed files with 14361 additions and 5705 deletions

View File

@@ -1,7 +1,16 @@
/* eslint-env node */
module.exports = {
plugins: ['@typescript-eslint', 'wc', 'lit', 'lit-a11y', 'chai-expect', 'chai-friendly', 'import'],
plugins: [
'@typescript-eslint',
'wc',
'lit',
'lit-a11y',
'chai-expect',
'chai-friendly',
'import',
'sort-imports-es6-autofix'
],
extends: [
'eslint:recommended',
'plugin:wc/recommended',
@@ -13,7 +22,6 @@ module.exports = {
es2021: true,
browser: true
},
reportUnusedDisableDirectives: true,
parserOptions: {
sourceType: 'module'
},
@@ -151,7 +159,7 @@ module.exports = {
'prefer-rest-params': 'warn',
'prefer-spread': 'warn',
'prefer-template': 'off',
'no-else-return': 'warn',
'no-else-return': 'off',
'func-names': ['warn', 'never'],
'one-var': ['warn', 'never'],
'operator-assignment': 'warn',
@@ -172,22 +180,12 @@ module.exports = {
}
],
'import/no-duplicates': 'warn',
'import/order': [
'warn',
'sort-imports-es6-autofix/sort-imports-es6': [
2,
{
groups: ['builtin', 'external', 'internal', 'unknown', 'parent', 'sibling', 'index', 'object', 'type'],
pathGroups: [
{
pattern: 'dist/**',
group: 'external'
}
],
alphabetize: {
order: 'asc',
caseInsensitive: true
},
'newlines-between': 'never',
warnOnUnassignedImports: true
ignoreCase: true,
ignoreMemberSort: false,
memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single']
}
],
'wc/guard-super-call': 'off'

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

@@ -8,6 +8,10 @@
"apos",
"atrule",
"autocorrect",
"autofix",
"autoload",
"autoloader",
"autoloading",
"autoplay",
"bezier",
"boxicons",
@@ -20,11 +24,13 @@
"codebases",
"codepen",
"colocated",
"colour",
"combobox",
"Composability",
"Consolas",
"contenteditable",
"copydir",
"Cotte",
"coverpage",
"crossorigin",
"crutchcorn",
@@ -44,12 +50,14 @@
"fieldsets",
"formaction",
"formdata",
"formenctype",
"formmethod",
"formnovalidate",
"formtarget",
"FOUC",
"FOUCE",
"fullscreen",
"gestern",
"giga",
"globby",
"Grayscale",
@@ -67,10 +75,12 @@
"jsonata",
"keydown",
"keyframes",
"Kool",
"labelledby",
"Laravel",
"LaViska",
"listbox",
"listitem",
"litelement",
"lowercasing",
"Lucide",
@@ -103,9 +113,13 @@
"rgba",
"roadmap",
"Roboto",
"roledescription",
"Sapan",
"saturationl",
"Schilp",
"scrollbars",
"scrollend",
"scroller",
"Segoe",
"semibold",
"slotchange",
@@ -118,6 +132,7 @@
"tabpanel",
"templating",
"tera",
"testid",
"textareas",
"textfield",
"tinycolor",
@@ -140,6 +155,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;
@@ -45,7 +45,7 @@ export default {
case ts.SyntaxKind.ClassDeclaration: {
const className = node.name.getText();
const classDoc = moduleDoc?.declarations?.find(declaration => declaration.name === className);
const customTags = ['title', 'animation', 'dependency', 'since', 'status'];
const customTags = ['animation', 'dependency', 'documentation', 'since', 'status', 'title'];
let customComments = '/**';
node.jsDoc?.forEach(jsDoc => {
@@ -81,6 +81,7 @@ export default {
break;
// Value-only metadata tags
case 'documentation':
case 'since':
case 'status':
case 'title':

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)
@@ -45,6 +47,7 @@
- [Menu](/components/menu)
- [Menu Item](/components/menu-item)
- [Menu Label](/components/menu-label)
- [Option](/components/option)
- [Progress Bar](/components/progress-bar)
- [Progress Ring](/components/progress-ring)
- [QR Code](/components/qr-code)
@@ -91,6 +94,7 @@
- [Border Radius](/tokens/border-radius)
- [Transition](/tokens/transition)
- [Z-index](/tokens/z-index)
- [More](/tokens/more)
- Tutorials

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

@@ -57,7 +57,18 @@
`;
})
.join('')}
</tbody>
<tr>
<td class="nowrap"><code>updateComplete</code></td>
<td>
A read-only promise that resolves when the component has
<a href="/getting-started/usage?id=component-rendering-and-updating">finished updating</a>.
</td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
`;
return table.outerHTML;
@@ -553,7 +564,7 @@
result += `
## Dependencies
This component imports the following dependencies.
This component automatically imports the following dependencies.
${createDependenciesList(component.tagName, getAllComponents(metadata))}
`;

View File

@@ -47,10 +47,10 @@
</sl-button>
<sl-menu>
<sl-menu-label>Toggle <kbd>\\</kbd></sl-menu-label>
<sl-menu-item value="light">Light</sl-menu-item>
<sl-menu-item value="dark">Dark</sl-menu-item>
<sl-menu-item type="checkbox" value="light">Light</sl-menu-item>
<sl-menu-item type="checkbox" value="dark">Dark</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item value="auto">Auto</sl-menu-item>
<sl-menu-item type="checkbox" value="auto">Auto</sl-menu-item>
</sl-menu>
`;
document.querySelector('.sidebar-toggle').insertAdjacentElement('afterend', dropdown);

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

@@ -91,19 +91,19 @@ This example demonstrates all of the baked-in animations and easings. Animations
const easings = getEasingNames();
animations.map(name => {
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
const option = Object.assign(document.createElement('sl-option'), {
textContent: name,
value: name
});
animationName.appendChild(menuItem);
animationName.appendChild(option);
});
easings.map(name => {
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
const option = Object.assign(document.createElement('sl-option'), {
textContent: name,
value: name
});
easingName.appendChild(menuItem);
easingName.appendChild(option);
});
animationName.addEventListener('sl-change', () => (animation.name = animationName.value));

View File

@@ -203,9 +203,9 @@ Dropdown menus can be placed in a prefix or suffix slot to provide additional op
<sl-icon label="More options" name="three-dots"></sl-icon>
</sl-button>
<sl-menu>
<sl-menu-item checked>Web Design</sl-menu-item>
<sl-menu-item>Web Development</sl-menu-item>
<sl-menu-item>Marketing</sl-menu-item>
<sl-menu-item type="checkbox" checked>Web Design</sl-menu-item>
<sl-menu-item type="checkbox">Web Development</sl-menu-item>
<sl-menu-item type="checkbox">Marketing</sl-menu-item>
</sl-menu>
</sl-dropdown>
</sl-breadcrumb-item>
@@ -235,9 +235,11 @@ const App = () => (
<SlIcon label="More options" name="three-dots"></SlIcon>
</SlButton>
<SlMenu>
<SlMenuItem checked>Web Design</SlMenuItem>
<SlMenuItem>Web Development</SlMenuItem>
<SlMenuItem>Marketing</SlMenuItem>
<SlMenuItem type="checkbox" checked>
Web Design
</SlMenuItem>
<SlMenuItem type="checkbox">Web Development</SlMenuItem>
<SlMenuItem type="checkbox">Marketing</SlMenuItem>
</SlMenu>
</SlDropdown>
</SlBreadcrumbItem>

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

@@ -70,6 +70,34 @@ const App = () => (
);
```
### Swatches
Use the `swatches` attribute to add convenient presets to the color picker. Any format the color picker can parse is acceptable (including CSS color names), but each value must be separated by a semicolon (`;`). Alternatively, you can pass an array of color values to this property using JavaScript.
```html preview
<sl-color-picker
label="Select a color"
swatches="
#d0021b; #f5a623; #f8e71c; #8b572a; #7ed321; #417505; #bd10e0; #9013fe;
#4a90e2; #50e3c2; #b8e986; #000; #444; #888; #ccc; #fff;
"
></sl-color-picker>
```
```jsx react
import { SlColorPicker } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlColorPicker
label="Select a color"
swatches="
#d0021b; #f5a623; #f8e71c; #8b572a; #7ed321; #417505; #bd10e0; #9013fe;
#4a90e2; #50e3c2; #b8e986; #000; #444; #888; #ccc; #fff;
"
/>
);
```
### Sizes
Use the `size` attribute to change the color picker's trigger size.

View File

@@ -14,7 +14,7 @@ Dropdowns are designed to work well with [menus](/components/menu) to provide a
<sl-menu-item>Dropdown Item 2</sl-menu-item>
<sl-menu-item>Dropdown Item 3</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item checked>Checked</sl-menu-item>
<sl-menu-item type="checkbox" checked>Checkbox</sl-menu-item>
<sl-menu-item disabled>Disabled</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>
@@ -42,7 +42,9 @@ const App = () => (
<SlMenuItem>Dropdown Item 2</SlMenuItem>
<SlMenuItem>Dropdown Item 3</SlMenuItem>
<SlDivider />
<SlMenuItem checked>Checked</SlMenuItem>
<SlMenuItem type="checkbox" checked>
Checkbox
</SlMenuItem>
<SlMenuItem disabled>Disabled</SlMenuItem>
<SlDivider />
<SlMenuItem>
@@ -62,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

@@ -20,9 +20,9 @@ All available icons in the `default` icon library are shown below. Click or tap
<sl-icon slot="prefix" name="search"></sl-icon>
</sl-input>
<sl-select value="outline">
<sl-menu-item value="outline">Outlined</sl-menu-item>
<sl-menu-item value="fill">Filled</sl-menu-item>
<sl-menu-item value="all">All icons</sl-menu-item>
<sl-option value="outline">Outlined</sl-option>
<sl-option value="fill">Filled</sl-option>
<sl-option value="all">All icons</sl-option>
</sl-select>
</div>
<div class="icon-list"></div>
@@ -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

@@ -77,25 +77,13 @@ const App = () => <SlInput placeholder="Clearable" clearable />;
Add the `password-toggle` attribute to add a toggle button that will show the password when activated.
```html preview
<sl-input type="password" placeholder="Password Toggle" size="small" password-toggle></sl-input>
<br />
<sl-input type="password" placeholder="Password Toggle" size="medium" password-toggle></sl-input>
<br />
<sl-input type="password" placeholder="Password Toggle" size="large" password-toggle></sl-input>
<sl-input type="password" placeholder="Password Toggle" password-toggle></sl-input>
```
```jsx react
import { SlInput } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlInput type="password" placeholder="Password Toggle" size="small" password-toggle />
<br />
<SlInput type="password" placeholder="Password Toggle" size="medium" password-toggle />
<br />
<SlInput type="password" placeholder="Password Toggle" size="large" password-toggle />
</>
);
const App = () => <SlInput type="password" placeholder="Password Toggle" size="medium" password-toggle />;
```
### Filled Inputs
@@ -112,6 +100,46 @@ import { SlInput } from '@shoelace-style/shoelace/dist/react';
const App = () => <SlInput placeholder="Type something" filled />;
```
### Disabled
Use the `disabled` attribute to disable an input.
```html preview
<sl-input placeholder="Disabled" disabled></sl-input>
```
```jsx react
import { SlInput } from '@shoelace-style/shoelace/dist/react';
const App = () => <SlInput placeholder="Disabled" disabled />;
```
### Sizes
Use the `size` attribute to change an input's size.
```html preview
<sl-input placeholder="Small" size="small"></sl-input>
<br />
<sl-input placeholder="Medium" size="medium"></sl-input>
<br />
<sl-input placeholder="Large" size="large"></sl-input>
```
```jsx react
import { SlInput } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlInput placeholder="Small" size="small" />
<br />
<SlInput placeholder="Medium" size="medium" />
<br />
<SlInput placeholder="Large" size="large" />
</>
);
```
### Pill
Use the `pill` attribute to give inputs rounded edges.
@@ -164,58 +192,6 @@ const App = () => (
);
```
### Disabled
Use the `disabled` attribute to disable an input.
```html preview
<sl-input placeholder="Disabled" size="small" disabled></sl-input>
<br />
<sl-input placeholder="Disabled" size="medium" disabled></sl-input>
<br />
<sl-input placeholder="Disabled" size="large" disabled></sl-input>
```
```jsx react
import { SlInput } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlInput placeholder="Disabled" size="small" disabled />
<br />
<SlInput placeholder="Disabled" size="medium" disabled />
<br />
<SlInput placeholder="Disabled" size="large" disabled />
</>
);
```
### Sizes
Use the `size` attribute to change an input's size.
```html preview
<sl-input placeholder="Small" size="small"></sl-input>
<br />
<sl-input placeholder="Medium" size="medium"></sl-input>
<br />
<sl-input placeholder="Large" size="large"></sl-input>
```
```jsx react
import { SlInput } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlInput placeholder="Small" size="small" />
<br />
<SlInput placeholder="Medium" size="medium" />
<br />
<SlInput placeholder="Large" size="large" />
</>
);
```
### Prefix & Suffix Icons
Use the `prefix` and `suffix` slots to add icons.
@@ -271,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;
}
@@ -291,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

@@ -8,7 +8,7 @@
<sl-menu-item>Option 2</sl-menu-item>
<sl-menu-item>Option 3</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item checked>Checked</sl-menu-item>
<sl-menu-item type="checkbox" checked>Checkbox</sl-menu-item>
<sl-menu-item disabled>Disabled</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>
@@ -31,7 +31,9 @@ const App = () => (
<SlMenuItem>Option 2</SlMenuItem>
<SlMenuItem>Option 3</SlMenuItem>
<SlDivider />
<SlMenuItem checked>Checked</SlMenuItem>
<SlMenuItem type="checkbox" checked>
Checkbox
</SlMenuItem>
<SlMenuItem disabled>Disabled</SlMenuItem>
<SlDivider />
<SlMenuItem>
@@ -48,30 +50,6 @@ const App = () => (
## Examples
### Checked
Use the `checked` attribute to draw menu items in a checked state.
```html preview
<sl-menu style="max-width: 200px;">
<sl-menu-item>Option 1</sl-menu-item>
<sl-menu-item checked>Option 2</sl-menu-item>
<sl-menu-item>Option 3</sl-menu-item>
</sl-menu>
```
```jsx react
import { SlMenu, SlMenuItem } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlMenu style={{ maxWidth: '200px' }}>
<SlMenuItem>Option 1</SlMenuItem>
<SlMenuItem checked>Option 2</SlMenuItem>
<SlMenuItem>Option 3</SlMenuItem>
</SlMenu>
);
```
### Disabled
Add the `disabled` attribute to disable the menu item so it cannot be selected.
@@ -150,6 +128,34 @@ const App = () => (
);
```
### Checkbox Menu Items
Set the `type` attribute to `checkbox` to create a menu item that will toggle on and off when selected. You can use the `checked` attribute to set the initial state.
Checkbox menu items are visually indistinguishable from regular menu items. Their ability to be toggled is primarily inferred from context, much like you'd find in the menu of a native app.
```html preview
<sl-menu style="max-width: 200px;">
<sl-menu-item type="checkbox">Autosave</sl-menu-item>
<sl-menu-item type="checkbox" checked>Check Spelling</sl-menu-item>
<sl-menu-item type="checkbox">Word Wrap</sl-menu-item>
</sl-menu>
```
```jsx react
import { SlMenu, SlMenuItem } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlMenu style={{ maxWidth: '200px' }}>
<SlMenuItem type="checkbox">Autosave</SlMenuItem>
<SlMenuItem type="checkbox" checked>
Check Spelling
</SlMenuItem>
<SlMenuItem type="checkbox">Word Wrap</SlMenuItem>
</SlMenu>
);
```
### Value & Selection
The `value` attribute can be used to assign a hidden value, such as a unique identifier, to a menu item. When an item is selected, the `sl-select` event will be emitted and a reference to the item will be available at `event.detail.item`. You can use this reference to access the selected item's value, its checked state, and more.
@@ -159,6 +165,10 @@ The `value` attribute can be used to assign a hidden value, such as a unique ide
<sl-menu-item value="opt-1">Option 1</sl-menu-item>
<sl-menu-item value="opt-2">Option 2</sl-menu-item>
<sl-menu-item value="opt-3">Option 3</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item type="checkbox" value="opt-4">Checkbox 4</sl-menu-item>
<sl-menu-item type="checkbox" value="opt-5">Checkbox 5</sl-menu-item>
<sl-menu-item type="checkbox" value="opt-6">Checkbox 6</sl-menu-item>
</sl-menu>
<script>
@@ -167,11 +177,12 @@ The `value` attribute can be used to assign a hidden value, such as a unique ide
menu.addEventListener('sl-select', event => {
const item = event.detail.item;
// Toggle checked state
item.checked = !item.checked;
// Log value
console.log(`Selected value: ${item.value}`);
if (item.type === 'checkbox') {
console.log(`Selected value: ${item.value} (${item.checked ? 'checked' : 'unchecked'})`);
} else {
console.log(`Selected value: ${item.value}`);
}
});
</script>
```

79
docs/components/option.md Normal file
View File

@@ -0,0 +1,79 @@
# Option
[component-header:sl-option]
```html preview
<sl-select label="Select one">
<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>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
## Examples
### Disabled
Use the `disabled` attribute to disable an option and prevent it from being selected.
```html preview
<sl-select label="Select one">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2" disabled>Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2" disabled>
Option 2
</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Prefix & Suffix
Add icons to the start and end of menu items using the `prefix` and `suffix` slots.
```html preview
<sl-select label="Select one">
<sl-option value="option-1">
<sl-icon slot="prefix" name="envelope"></sl-icon>
Email
<sl-icon slot="suffix" name="patch-check"></sl-icon>
</sl-option>
<sl-option value="option-2">
<sl-icon slot="prefix" name="telephone"></sl-icon>
Phone
<sl-icon slot="suffix" name="patch-check"></sl-icon>
</sl-option>
<sl-option value="option-3">
<sl-icon slot="prefix" name="chat-dots"></sl-icon>
Chat
<sl-icon slot="suffix" name="patch-check"></sl-icon>
</sl-option>
</sl-select>
```
[component-metadata:sl-option]

View File

@@ -17,18 +17,18 @@ Popup doesn't provide any styles — just positioning! The popup's preferred pla
<div class="popup-overview-options">
<sl-select label="Placement" name="placement" value="top" class="popup-overview-select">
<sl-menu-item value="top">top</sl-menu-item>
<sl-menu-item value="top-start">top-start</sl-menu-item>
<sl-menu-item value="top-end">top-end</sl-menu-item>
<sl-menu-item value="bottom">bottom</sl-menu-item>
<sl-menu-item value="bottom-start">bottom-start</sl-menu-item>
<sl-menu-item value="bottom-end">bottom-end</sl-menu-item>
<sl-menu-item value="right">right</sl-menu-item>
<sl-menu-item value="right-start">right-start</sl-menu-item>
<sl-menu-item value="right-end">right-end</sl-menu-item>
<sl-menu-item value="left">left</sl-menu-item>
<sl-menu-item value="left-start">left-start</sl-menu-item>
<sl-menu-item value="left-end">left-end</sl-menu-item>
<sl-option value="top">top</sl-option>
<sl-option value="top-start">top-start</sl-option>
<sl-option value="top-end">top-end</sl-option>
<sl-option value="bottom">bottom</sl-option>
<sl-option value="bottom-start">bottom-start</sl-option>
<sl-option value="bottom-end">bottom-end</sl-option>
<sl-option value="right">right</sl-option>
<sl-option value="right-start">right-start</sl-option>
<sl-option value="right-end">right-end</sl-option>
<sl-option value="left">left</sl-option>
<sl-option value="left-start">left-start</sl-option>
<sl-option value="left-end">left-end</sl-option>
</sl-select>
<sl-input type="number" name="distance" label="distance" value="0"></sl-input>
<sl-input type="number" name="skidding" label="Skidding" value="0"></sl-input>
@@ -382,18 +382,18 @@ Since placement is preferred when using `flip`, you can observe the popup's curr
</sl-popup>
<sl-select label="Placement" value="top">
<sl-menu-item value="top">top</sl-menu-item>
<sl-menu-item value="top-start">top-start</sl-menu-item>
<sl-menu-item value="top-end">top-end</sl-menu-item>
<sl-menu-item value="bottom">bottom</sl-menu-item>
<sl-menu-item value="bottom-start">bottom-start</sl-menu-item>
<sl-menu-item value="bottom-end">bottom-end</sl-menu-item>
<sl-menu-item value="right">right</sl-menu-item>
<sl-menu-item value="right-start">right-start</sl-menu-item>
<sl-menu-item value="right-end">right-end</sl-menu-item>
<sl-menu-item value="left">left</sl-menu-item>
<sl-menu-item value="left-start">left-start</sl-menu-item>
<sl-menu-item value="left-end">left-end</sl-menu-item>
<sl-option value="top">top</sl-option>
<sl-option value="top-start">top-start</sl-option>
<sl-option value="top-end">top-end</sl-option>
<sl-option value="bottom">bottom</sl-option>
<sl-option value="bottom-start">bottom-start</sl-option>
<sl-option value="bottom-end">bottom-end</sl-option>
<sl-option value="right">right</sl-option>
<sl-option value="right-start">right-start</sl-option>
<sl-option value="right-end">right-end</sl-option>
<sl-option value="left">left</sl-option>
<sl-option value="left-start">left-start</sl-option>
<sl-option value="left-end">left-end</sl-option>
</sl-select>
</div>
@@ -525,7 +525,7 @@ Use the `distance` attribute to change the distance between the popup and its an
const popup = container.querySelector('sl-popup');
const distance = container.querySelector('sl-range');
distance.addEventListener('sl-change', () => (popup.distance = distance.value));
distance.addEventListener('sl-input', () => (popup.distance = distance.value));
</script>
```
@@ -621,7 +621,7 @@ The `skidding` attribute is similar to `distance`, but instead allows you to off
const popup = container.querySelector('sl-popup');
const skidding = container.querySelector('sl-range');
skidding.addEventListener('sl-change', () => (popup.skidding = skidding.value));
skidding.addEventListener('sl-input', () => (popup.skidding = skidding.value));
</script>
```
@@ -692,25 +692,25 @@ By default, the arrow will be aligned as close to the center of the _anchor_ as
<div class="popup-arrow-options">
<sl-select label="Placement" name="placement" value="top" class="popup-overview-select">
<sl-menu-item value="top">top</sl-menu-item>
<sl-menu-item value="top-start">top-start</sl-menu-item>
<sl-menu-item value="top-end">top-end</sl-menu-item>
<sl-menu-item value="bottom">bottom</sl-menu-item>
<sl-menu-item value="bottom-start">bottom-start</sl-menu-item>
<sl-menu-item value="bottom-end">bottom-end</sl-menu-item>
<sl-menu-item value="right">right</sl-menu-item>
<sl-menu-item value="right-start">right-start</sl-menu-item>
<sl-menu-item value="right-end">right-end</sl-menu-item>
<sl-menu-item value="left">left</sl-menu-item>
<sl-menu-item value="left-start">left-start</sl-menu-item>
<sl-menu-item value="left-end">left-end</sl-menu-item>
<sl-option value="top">top</sl-option>
<sl-option value="top-start">top-start</sl-option>
<sl-option value="top-end">top-end</sl-option>
<sl-option value="bottom">bottom</sl-option>
<sl-option value="bottom-start">bottom-start</sl-option>
<sl-option value="bottom-end">bottom-end</sl-option>
<sl-option value="right">right</sl-option>
<sl-option value="right-start">right-start</sl-option>
<sl-option value="right-end">right-end</sl-option>
<sl-option value="left">left</sl-option>
<sl-option value="left-start">left-start</sl-option>
<sl-option value="left-end">left-end</sl-option>
</sl-select>
<sl-select label="Arrow Placement" name="arrow-placement" value="anchor">
<sl-menu-item value="anchor">anchor</sl-menu-item>
<sl-menu-item value="start">start</sl-menu-item>
<sl-menu-item value="end">end</sl-menu-item>
<sl-menu-item value="center">center</sl-menu-item>
<sl-option value="anchor">anchor</sl-option>
<sl-option value="start">start</sl-option>
<sl-option value="end">end</sl-option>
<sl-option value="center">center</sl-option>
</sl-select>
</div>
@@ -881,10 +881,10 @@ Use the `sync` attribute to make the popup the same width or height as the ancho
</sl-popup>
<sl-select value="width" label="Sync">
<sl-menu-item value="width">Width</sl-menu-item>
<sl-menu-item value="height">Height</sl-menu-item>
<sl-menu-item value="both">Both</sl-menu-item>
<sl-menu-item value="">None</sl-menu-item>
<sl-option value="width">Width</sl-option>
<sl-option value="height">Height</sl-option>
<sl-option value="both">Both</sl-option>
<sl-option value="">None</sl-option>
</sl-select>
</div>

View File

@@ -2,8 +2,6 @@
[component-header:sl-qr-code]
Generates a [QR code](https://www.qrcode.com/) and renders it using the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
QR codes are useful for providing small pieces of information to users who can quickly scan them with a smartphone. Most smartphones have built-in QR code scanners, so simply pointing the camera at a QR code will decode it and allow the user to visit a website, dial a phone number, read a message, etc.
```html preview
@@ -19,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

@@ -56,7 +56,7 @@ import { SlRating } from '@shoelace-style/shoelace/dist/react';
const App = () => <SlRating label="Rating" precision={0.5} value={2.5} />;
```
## Symbol Sizes
### Symbol Sizes
Set the `--symbol-size` custom property to adjust the size.
@@ -98,6 +98,99 @@ import { SlRating } from '@shoelace-style/shoelace/dist/react';
const App = () => <SlRating label="Rating" disabled value={3} />;
```
### Detecting Hover
Use the `sl-hover` event to detect when the user hovers over (or touch and drag) the rating. This lets you hook into values as the user interacts with the rating, but before they select a value.
The event has a payload with `phase` and `value` properties. The `phase` property tells when hovering starts, moves to a new value, and ends. The `value` property tells what the rating's value would be if the user were to commit to the hovered value.
```html preview
<div class="detect-hover">
<sl-rating label="Rating"></sl-rating>
<span></span>
</div>
<script>
const rating = document.querySelector('.detect-hover > sl-rating');
const span = rating.nextElementSibling;
const terms = ['No rating', 'Terrible', 'Bad', 'OK', 'Good', 'Excellent'];
rating.addEventListener('sl-hover', event => {
span.textContent = terms[event.detail.value];
// Clear feedback when hovering stops
if (event.detail.phase === 'end') {
span.textContent = '';
}
});
</script>
<style>
.detect-hover span {
position: relative;
top: -4px;
left: 8px;
border-radius: var(--sl-border-radius-small);
background: var(--sl-color-neutral-900);
color: var(--sl-color-neutral-0);
text-align: center;
padding: 4px 6px;
}
.detect-hover span:empty {
display: none;
}
</style>
```
```jsx react
import { useState } from 'react';
import { SlRating } from '@shoelace-style/shoelace/dist/react';
const terms = ['No rating', 'Terrible', 'Bad', 'OK', 'Good', 'Excellent'];
const css = `
.detect-hover span {
position: relative;
top: -4px;
left: 8px;
border-radius: var(--sl-border-radius-small);
background: var(--sl-color-neutral-900);
color: var(--sl-color-neutral-0);
text-align: center;
padding: 4px 6px;
}
.detect-hover span:empty {
display: none;
}
`;
function handleHover(event) {
rating.addEventListener('sl-hover', event => {
setFeedback(terms[event.detail.value]);
// Clear feedback when hovering stops
if (event.detail.phase === 'end') {
setFeedback('');
}
});
}
const App = () => {
const [feedback, setFeedback] = useState(true);
return (
<>
<div class="detect-hover">
<SlRating label="Rating" onSlHover={handleHover} />
<span>{feedback}</span>
</div>
<style>{css}</style>
</>
);
};
```
### Custom Icons
You can provide custom icons by passing a function to the `getSymbol` property.
@@ -112,7 +205,6 @@ You can provide custom icons by passing a function to the `getSymbol` property.
```
```jsx react
import '@shoelace-style/shoelace/dist/components/icon/icon';
import { SlRating } from '@shoelace-style/shoelace/dist/react';
const App = () => (
@@ -142,7 +234,6 @@ You can also use the `getSymbol` property to render different icons based on val
```
```jsx react
import '@shoelace-style/shoelace/dist/components/icon/icon';
import { SlRating } from '@shoelace-style/shoelace/dist/react';
function getSymbol(value) {

View File

@@ -4,28 +4,26 @@
```html preview
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item value="option-4">Option 4</sl-menu-item>
<sl-menu-item value="option-5">Option 5</sl-menu-item>
<sl-menu-item value="option-6">Option 6</sl-menu-item>
<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-option value="option-4">Option 4</sl-option>
<sl-option value="option-5">Option 5</sl-option>
<sl-option value="option-6">Option 6</sl-option>
</sl-select>
```
```jsx react
import { SlDivider, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlDivider />
<SlMenuItem value="option-4">Option 4</SlMenuItem>
<SlMenuItem value="option-5">Option 5</SlMenuItem>
<SlMenuItem value="option-6">Option 6</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
<SlOption value="option-4">Option 4</SlOption>
<SlOption value="option-5">Option 5</SlOption>
<SlOption value="option-6">Option 6</SlOption>
</SlSelect>
);
```
@@ -40,20 +38,20 @@ Use the `label` attribute to give the select an accessible label. For labels tha
```html preview
<sl-select label="Select one">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<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>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect label="Select one">
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
@@ -64,20 +62,20 @@ Add descriptive help text to a select with the `help-text` attribute. For help t
```html preview
<sl-select label="Experience" help-text="Please tell us your skill level.">
<sl-menu-item value="1">Novice</sl-menu-item>
<sl-menu-item value="2">Intermediate</sl-menu-item>
<sl-menu-item value="3">Advanced</sl-menu-item>
<sl-option value="1">Novice</sl-option>
<sl-option value="2">Intermediate</sl-option>
<sl-option value="3">Advanced</sl-option>
</sl-select>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect label="Experience" help-text="Please tell us your skill level.">
<SlMenuItem value="1">Novice</SlMenuItem>
<SlMenuItem value="2">Intermediate</SlMenuItem>
<SlMenuItem value="3">Advanced</SlMenuItem>
<SlOption value="1">Novice</SlOption>
<SlOption value="2">Intermediate</SlOption>
<SlOption value="3">Advanced</SlOption>
</SlSelect>
);
```
@@ -88,44 +86,44 @@ Use the `placeholder` attribute to add a placeholder.
```html preview
<sl-select placeholder="Select one">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<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>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Select one">
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Clearable
Use the `clearable` attribute to make the control clearable.
Use the `clearable` attribute to make the control clearable. The clear button only appears when an option is selected.
```html preview
<sl-select placeholder="Clearable" clearable>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-select clearable value="option-1">
<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>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Clearable" clearable>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
@@ -136,20 +134,20 @@ Add the `filled` attribute to draw a filled select.
```html preview
<sl-select filled>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<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>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect filled>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
@@ -160,20 +158,20 @@ Use the `pill` attribute to give selects rounded edges.
```html preview
<sl-select pill>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<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>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect pill>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
@@ -184,227 +182,167 @@ Use the `disabled` attribute to disable a select.
```html preview
<sl-select placeholder="Disabled" disabled>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<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>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Disabled" disabled>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Setting the Selection
Use the `value` attribute to set the current selection. When users interact with the control, its `value` will update to reflect the newly selected menu item's value. Note that the value must be an array when using the [`multiple`](#multiple) option.
```html preview
<sl-select value="option-2">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
```
```jsx react
import { SlDivider, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect value="option-2">
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
</SlSelect>
);
```
### Setting the Selection Imperatively
To programmatically set the selection, update the `value` property as shown below. Note that the value must be an array when using the [`multiple`](#multiple) option.
```html preview
<div class="selecting-example">
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
<br />
<sl-button data-option="option-1">Set 1</sl-button>
<sl-button data-option="option-2">Set 2</sl-button>
<sl-button data-option="option-3">Set 3</sl-button>
</div>
<script>
const container = document.querySelector('.selecting-example');
const select = container.querySelector('sl-select');
[...container.querySelectorAll('sl-button')].map(button => {
button.addEventListener('click', () => {
select.value = button.dataset.option;
});
});
</script>
```
```jsx react
import { useState } from 'react';
import { SlButton, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => {
const [value, setValue] = useState('option-1');
return (
<>
<SlSelect value={value} onSlChange={event => setValue(event.target.value)}>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
</SlSelect>
<br />
<SlButton onClick={() => setValue('option-1')}>Set 1</SlButton>
<SlButton onClick={() => setValue('option-2')}>Set 2</SlButton>
<SlButton onClick={() => setValue('option-3')}>Set 3</SlButton>
</>
);
};
```
### Multiple
To allow multiple options to be selected, use the `multiple` attribute. With this option, `value` will be an array of strings instead of a string. It's a good practice to use `clearable` when this option is enabled.
To allow multiple options to be selected, use the `multiple` attribute. It's a good practice to use `clearable` when this option is enabled. To set multiple values at once, set `value` to a space-delimited list of values.
```html preview
<sl-select placeholder="Select a few" multiple clearable>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item value="option-4">Option 4</sl-menu-item>
<sl-menu-item value="option-5">Option 5</sl-menu-item>
<sl-menu-item value="option-6">Option 6</sl-menu-item>
<sl-select label="Select a Few" value="option-1 option-2 option-3" multiple clearable>
<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-option value="option-4">Option 4</sl-option>
<sl-option value="option-5">Option 5</sl-option>
<sl-option value="option-6">Option 6</sl-option>
</sl-select>
```
```jsx react
import { SlDivider, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Select a few" multiple clearable>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlDivider />
<SlMenuItem value="option-4">Option 4</SlMenuItem>
<SlMenuItem value="option-5">Option 5</SlMenuItem>
<SlMenuItem value="option-6">Option 6</SlMenuItem>
<SlSelect label="Select a Few" value="option-1 option-2 option-3" multiple clearable>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
<SlOption value="option-4">Option 4</SlOption>
<SlOption value="option-5">Option 5</SlOption>
<SlOption value="option-6">Option 6</SlOption>
</SlSelect>
);
```
?> When using the `multiple` option, the value will be an array instead of a string. You may need to [set the selection imperatively](#setting-the-selection-imperatively) unless you're using a framework that supports binding properties declaratively.
?> Note that multi-select options may wrap, causing the control to expand vertically. You can use the `max-options-visible` attribute to control the maximum number of selected options to show at once.
### Grouping Options
### Setting Initial Values
Options can be grouped visually using menu labels and dividers.
Use the `value` attribute to set the initial selection. When using `multiple`, use space-delimited values to select more than one option.
```html preview
<sl-select placeholder="Select one">
<sl-menu-label>Group 1</sl-menu-label>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-label>Group 2</sl-menu-label>
<sl-menu-item value="option-4">Option 4</sl-menu-item>
<sl-menu-item value="option-5">Option 5</sl-menu-item>
<sl-menu-item value="option-6">Option 6</sl-menu-item>
<sl-select value="option-1 option-2" multiple clearable>
<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-option value="option-4">Option 4</sl-option>
</sl-select>
```
```jsx react
import { SlDivider, SlMenuItem, SlMenuLabel, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlDivider, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placeholder="Select one">
<SlMenuLabel>Group 1</SlMenuLabel>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlDivider></SlDivider>
<SlMenuLabel>Group 2</SlMenuLabel>
<SlMenuItem value="option-4">Option 4</SlMenuItem>
<SlMenuItem value="option-5">Option 5</SlMenuItem>
<SlMenuItem value="option-6">Option 6</SlMenuItem>
<SlSelect value="option-1 option-2" multiple clearable>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
);
```
### Grouping Options
Use `<sl-divider>` to group listbox items visually. You can also use `<small>` to provide labels, but they won't be announced by most assistive devices.
```html preview
<sl-select>
<small>Section 1</small>
<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-divider></sl-divider>
<small>Section 2</small>
<sl-option value="option-4">Option 4</sl-option>
<sl-option value="option-5">Option 5</sl-option>
<sl-option value="option-6">Option 6</sl-option>
</sl-select>
```
```jsx react
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
<SlOption value="option-4">Option 4</SlOption>
<SlOption value="option-5">Option 5</SlOption>
<SlOption value="option-6">Option 6</SlOption>
</SlSelect>
);
```
### Sizes
Use the `size` attribute to change a select's size.
Use the `size` attribute to change a select's size. Note that size does not apply to listbox options.
```html preview
<sl-select placeholder="Small" size="small" multiple>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-select placeholder="Small" size="small">
<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>
<br />
<sl-select placeholder="Medium" size="medium" multiple>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-select placeholder="Medium" size="medium">
<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>
<br />
<sl-select placeholder="Large" size="large" multiple>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-select placeholder="Large" size="large">
<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>
```
```jsx react
import { SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlSelect placeholder="Small" size="small" multiple>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlSelect placeholder="Small" size="small">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Medium" size="medium" multiple>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlSelect placeholder="Medium" size="medium">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Large" size="large" multiple>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlSelect placeholder="Large" size="large">
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
</>
);
@@ -412,88 +350,82 @@ const App = () => (
### Placement
The preferred placement of the select's menu can be set with the `placement` attribute. Note that the actual position may vary to ensure the panel remains in the viewport. Valid placements are `top` and `bottom`.
The preferred placement of the select's listbox can be set with the `placement` attribute. Note that the actual position may vary to ensure the panel remains in the viewport. Valid placements are `top` and `bottom`.
```html preview
<sl-select placement="top">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<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>
```
```jsx react
import {
SlMenuItem,
SlOption,
SlSelect
} from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlSelect placement="top">
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlDropdown>
);
```
### Prefix & Suffix Icons
### Prefix Icons
Use the `prefix` and `suffix` slots to add icons.
Use the `prefix` slot to prepend an icon to the control.
```html preview
<sl-select placeholder="Small" size="small">
<sl-select placeholder="Small" size="small" clearable>
<sl-icon name="house" slot="prefix"></sl-icon>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-icon name="chat" slot="suffix"></sl-icon>
<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>
<br />
<sl-select placeholder="Medium" size="medium">
<sl-select placeholder="Medium" size="medium" clearable>
<sl-icon name="house" slot="prefix"></sl-icon>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-icon name="chat" slot="suffix"></sl-icon>
<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>
<br />
<sl-select placeholder="Large" size="large">
<sl-select placeholder="Large" size="large" clearable>
<sl-icon name="house" slot="prefix"></sl-icon>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
<sl-icon name="chat" slot="suffix"></sl-icon>
<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>
```
```jsx react
import { SlIcon, SlMenuItem, SlSelect } from '@shoelace-style/shoelace/dist/react';
import { SlIcon, SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<>
<SlSelect placeholder="Small" size="small">
<SlIcon name="house" slot="prefix"></SlIcon>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlIcon name="chat" slot="suffix"></SlIcon>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Medium" size="medium">
<SlIcon name="house" slot="prefix"></SlIcon>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlIcon name="chat" slot="suffix"></SlIcon>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
<br />
<SlSelect placeholder="Large" size="large">
<SlIcon name="house" slot="prefix"></SlIcon>
<SlMenuItem value="option-1">Option 1</SlMenuItem>
<SlMenuItem value="option-2">Option 2</SlMenuItem>
<SlMenuItem value="option-3">Option 3</SlMenuItem>
<SlIcon name="chat" slot="suffix"></SlIcon>
<SlOption value="option-1">Option 1</SlOption>
<SlOption value="option-2">Option 2</SlOption>
<SlOption value="option-3">Option 3</SlOption>
</SlSelect>
</>
);

View File

@@ -379,9 +379,9 @@ Try resizing the example below with each option and notice how the panels respon
</sl-split-panel>
<sl-select label="Primary Panel" value="" style="max-width: 200px; margin-top: 1rem;">
<sl-menu-item value="">None</sl-menu-item>
<sl-menu-item value="start">Start</sl-menu-item>
<sl-menu-item value="end">End</sl-menu-item>
<sl-option value="">None</sl-option>
<sl-option value="start">Start</sl-option>
<sl-option value="end">End</sl-option>
</sl-select>
</div>

View File

@@ -79,9 +79,9 @@ The `selection` attribute lets you change the selection behavior of the tree.
```html preview
<sl-select id="selection-mode" value="single" label="Selection">
<sl-menu-item value="single">Single</sl-menu-item>
<sl-menu-item value="multiple">Multiple</sl-menu-item>
<sl-menu-item value="leaf">Leaf</sl-menu-item>
<sl-option value="single">Single</sl-option>
<sl-option value="multiple">Multiple</sl-option>
<sl-option value="leaf">Leaf</sl-option>
</sl-select>
<br />

View File

@@ -53,10 +53,10 @@ The form will not be submitted if a required field is incomplete.
<sl-input name="name" label="Name" required></sl-input>
<br />
<sl-select label="Favorite Animal" clearable required>
<sl-menu-item value="birds">Birds</sl-menu-item>
<sl-menu-item value="cats">Cats</sl-menu-item>
<sl-menu-item value="dogs">Dogs</sl-menu-item>
<sl-menu-item value="other">Other</sl-menu-item>
<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>
<br />
<sl-textarea name="comment" label="Comment" required></sl-textarea>
@@ -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-menu-item value="birds">Birds</sl-menu-item>
<sl-menu-item value="cats">Cats</sl-menu-item>
<sl-menu-item value="dogs">Dogs</sl-menu-item>
<sl-menu-item value="other">Other</sl-menu-item>
<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,42 +318,183 @@ 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(control) {
.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(control) {
.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(control) {
.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(control) {
.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.
```js
import { getFormControls } from '@shoelace-style/shoelace/dist/utilities/form.js';
const form = document.querySelector('#my-form');
const formControls = getFormControls(form);
console.log(formControls); // e.g. [input, sl-input, ...]
```
?> You probably don't need this function! If you're gathering form data for submission, you probably want to use [Data Serialization](#data-serializing) instead.

View File

@@ -4,7 +4,19 @@ You can use Shoelace via CDN or by installing it locally. You can also [cherry p
If you're using a framework, make sure to check out the pages for [React](/frameworks/react), [Vue](/frameworks/vue), and [Angular](/frameworks/angular).
## CDN Installation (Easiest)
## Autoloading (Experimental)
The autoloader is the simplest and most efficient way to use Shoelace. A lightweight script watches the DOM for unregistered Shoelace elements and lazy loads them for you. This works for elements already on the page and elements that get added later on.
<!-- 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>
```
?> While convenient, one caveat of autoloading is you may see a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/).
## CDN Installation
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.
@@ -13,7 +25,7 @@ The easiest way to install Shoelace is with the CDN. Just add the following tags
<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.
?> If you're only using a handful of components, it will be more efficient to [autoload](#autoloading-experimental) or [cherry pick](#cherry-picking) the ones you need.
### Dark Theme
@@ -68,7 +80,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.

View File

@@ -149,6 +149,36 @@ A clever way to use this method is to hide the `<body>` with `opacity: 0` and ad
</script>
```
## Component Rendering and Updating
Shoelace components are built with [Lit](https://lit.dev/), a tiny library that makes authoring custom elements easier, more maintainable, and a lot of fun! As a Shoelace user, here is some helpful information about rendering and updating you should probably be aware of.
To optimize performance and reduce re-renders, Lit batches component updates. This means changing multiple attributes or properties at the same time will result in just a single re-render. In most cases, this isn't an issue, but there may be times you'll need to wait for the component to update before continuing.
Consider this example. We're going to change the `checked` property of the checkbox and observe its corresponding `checked` attribute, which happens to reflect.
```js
const checkbox = document.querySelector('sl-checkbox');
checkbox.checked = true;
console.log(checkbox.hasAttribute('checked')); // false
```
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');
checkbox.checked = true;
checkbox.updateComplete.then(() => {
console.log(checkbox.hasAttribute('checked')); // true
});
```
This time we see an empty string, which means the boolean attribute is now present!
?> Avoid using `setTimeout()` or `requestAnimationFrame()` in situations like this. They might work, but it's far more reliable to use `updateComplete` instead.
## Code Completion
### VS Code

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,7 +6,145 @@ 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).
?> During the beta period, these restrictions may be relaxed in the event of a mission-critical bug. 🐛
## Next
- 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)
## 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
- [More than 150 discussions](https://github.com/shoelace-style/shoelace/discussions) have been started on GitHub
- [Over 500 people](https://discord.com/invite/mg8f26C) have joined the Shoelace community on Discord
- [Over 300 million CDN hits](https://www.jsdelivr.com/package/npm/@shoelace-style/shoelace) per month
- [Over 13,000 npm downloads](https://www.npmjs.com/package/@shoelace-style/shoelace) per week
- [73rd most popular project](https://www.jsdelivr.com/statistics) on jsDelivr
- [#2 product of the day](https://www.producthunt.com/products/shoelace-css) on Product Hunt (July 25, 2020)
I'd like to extend a very special thank you to every single contributor who worked to make this possible. Everyone who's filed a bug, submitted a PR, requested a feature, started a discussion, helped with testing, and advocated for the project. You are just as responsible for Shoelace's success as I am. I'd also like to thank the folks at [Font&nbsp;Awesome](https://fontawesome.com/) for recognizing Shoelace's potential and [believing in me](https://blog.fontawesome.com/shoelace-joins-font-awesome/) to make it happen.
Thank you! And keep building _awesome_ stuff!
Without further ado, here are the notes for this release.
- Added support for the `inert` attribute on `<sl-menu-item>` to allow hidden menu items to not accept focus [#1107](https://github.com/shoelace-style/shoelace/issues/1107)
- Added the `tag` part to `<sl-select>`
- Added `sl-hover` event to `<sl-rating>` [#1125](https://github.com/shoelace-style/shoelace/issues/1125)
- Added the `@documentation` tag with a link to the docs for each component
- Added the `form` attribute to all form controls to allow placing them outside of a `<form>` element [#1130](https://github.com/shoelace-style/shoelace/issues/1130)
- Added the `getFormControls()` function as an alternative to `HTMLFormElement.elements`
- Added missing docs for the `header-actions` slot in `<sl-dialog>` and `<sl-drawer>`
- Added `hue-slider-handle` and `opacity-slider-handle` parts to `<sl-color-picker>` and correct other part names in the docs [#1142](https://github.com/shoelace-style/shoelace/issues/1142)
- Fixed a bug in `<sl-select>` that prevented placeholders from showing when `multiple` was used [#1109](https://github.com/shoelace-style/shoelace/issues/1109)
- Fixed a bug in `<sl-select>` that caused tags to not be rounded when using the `pill` attribute [#1117](https://github.com/shoelace-style/shoelace/issues/1117)
- Fixed a bug in `<sl-select>` where the `sl-change` and `sl-input` events didn't weren't emitted when removing tags [#1119](https://github.com/shoelace-style/shoelace/issues/1119)
- Fixed a bug in `<sl-select>` that caused the listbox to scroll to the first selected item when selecting multiple items [#1138](https://github.com/shoelace-style/shoelace/issues/1138)
- Fixed a bug in `<sl-select>` where the input color and input hover color wasn't using the correct design tokens [#1143](https://github.com/shoelace-style/shoelace/issues/1143)
- Fixed a bug in `<sl-color-picker>` that logged a console error when parsing swatches with whitespace
- Fixed a bug in `<sl-color-picker>` that caused selected colors to be wrong due to incorrect HSV calculations
- Fixed a bug in `<sl-color-picker>` that prevented the initial value from being set correct when assigned as a property [#1141](https://github.com/shoelace-style/shoelace/issues/1141)
- Fixed a bug in `<sl-radio-button>` that caused the checked button's right border to be incorrect [#1110](https://github.com/shoelace-style/shoelace/issues/1110)
- Fixed a bug in `<sl-spinner>` that caused the animation to stop working correctly in Safari [#1121](https://github.com/shoelace-style/shoelace/issues/1121)
- Fixed a bug that prevented the entire `<sl-tab-panel>` to be hidden when inactive
- Fixed a bug that caused the value of `<sl-radio-group>` to be `undefined` depending on where the radio was activated [#1134](https://github.com/shoelace-style/shoelace/issues/1134)
- Fixed a bug that caused body content to shift when scroll locking was enabled [#1132](https://github.com/shoelace-style/shoelace/issues/1132)
- Fixed a bug in `<sl-icon>` that caused icons to sometimes be clipped in Safari
- Fixed a bug that prevented label colors from inheriting by default in `<sl-checkbox>`, `<sl-radio>`, and `<sl-switch>`
- Fixed a bug in `<sl-radio-group>` that caused an extra margin between the host element and the internal fieldset [#1139](https://github.com/shoelace-style/shoelace/issues/1139)
- Refactored the `ShoelaceFormControl` interface to remove the `invalid` property, allowing a more intuitive API for controlling validation internally
- Renamed the internal `FormSubmitController` to `FormControlController` to better reflect what it's used for
- Updated Lit to 2.6.1
- Updated Floating UI to 1.1.0
- Updated all other dependencies to latest versions
## 2.0.0-beta.88
This release includes a complete rewrite of `<sl-select>` to improve accessibility and simplify its internals.
- 🚨 BREAKING: rewrote `<sl-select>`
- Accessibility has been significantly improved, especially in screen readers
- You must use `<sl-option>` instead of `<sl-menu-item>` for options now
- The `suffix` slot was removed because it was confusing to users and its position made the clear button inaccessible
- The `max-tags-visible` attribute has been renamed to `max-options-visible`
- Many parts have been removed or renamed (please see the docs for more details)
- 🚨 BREAKING: removed the `sl-label-change` event from `<sl-menu-item>` (listen for `slotchange` instead)
- 🚨 BREAKING: removed type to select logic from `<sl-menu>` (this was added specifically for `<sl-select>` which no longer uses `<sl-menu>`)
- 🚨 BREAKING: swatches in `<sl-color-picker>` are no longer present by default (but you can set them using the `swatches` attribute now)
- 🚨 BREAKING: improved the accessibility of `<sl-menu-item>` so checked items are announced as such
- Checkbox menu items must now have `type="checkbox"` before applying the `checked` attribute
- Checkbox menu items will now toggle their `checked` state on their own when selected
- Disabled menu items will now receive focus, but are still not selectable
- Added the `<sl-option>` component
- Added Traditional Chinese translation [#1086](https://github.com/shoelace-style/shoelace/pull/1086)
- Added support for `swatches` to be an attribute of `<sl-color-picker>` so swatches can be defined declaratively (it was previously a property; use a `;` to separate color values)
- Fixed a bug in `<sl-tree-item>` where the checked/indeterminate states could get out of sync when using the `multiple` option [#1076](https://github.com/shoelace-style/shoelace/issues/1076)
- Fixed a bug in `<sl-tree>` that caused `sl-selection-change` to emit before the DOM updated [#1096](https://github.com/shoelace-style/shoelace/issues/1096)
- Fixed a bug that prevented `<sl-switch>` from submitting a default value of `on` when no value was provided [#1103](https://github.com/shoelace-style/shoelace/discussions/1103)
- Fixed a bug in `<sl-textarea>` that caused the scrollbar to show sometimes when using `resize="auto"`
- Fixed a bug in `<sl-input>` and `<sl-textarea>` that caused its validation states to be out of sync in some cases [#1063](https://github.com/shoelace-style/shoelace/issues/1063)
- Reorganized all components to make class structures more consistent
- Updated some incorrect default values for design tokens in the docs [#1097](https://github.com/shoelace-style/shoelace/issues/1097)
- Updated non-public fields to use the `private` keyword (these were previously private only by convention, but now TypeScript will warn you)
- Updated the hover style of `<sl-menu-item>` to be consistent with `<sl-option>`
- Updated the status of `<sl-tree>` and `<sl-tree-item>` from experimental to stable
- Updated React wrappers to use the latest API from `@lit-labs/react` [#1090](https://github.com/shoelace-style/shoelace/pull/1090)
- Updated Bootstrap Icons to 1.10.3
## 2.0.0-beta.87
@@ -167,8 +305,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

View File

@@ -188,8 +188,6 @@ Please do not make any changes to `prettier.config.cjs` without consulting the m
Components should be composable, meaning you can easily reuse them with and within other components. This reduces the overall size of the library, expedites feature development, and maintains a consistent user experience.
The `<sl-select>` component, for example, makes use of the dropdown, input, menu, and menu item components. Because it's offloading most of its functionality and styles to lower-level components, the select component remains lightweight and its appearance is consistent with other form controls and menus.
### Component Structure
All components have a host element, which is a reference to the `<sl-*>` element itself. Make sure to always set the host element's `display` property to the appropriate value depending on your needs, as the default is `inline` per the custom element spec.
@@ -202,6 +200,23 @@ All components have a host element, which is a reference to the `<sl-*>` element
Aside from `display`, avoid setting styles on the host element when possible. The reason for this is that styles applied to the host element are not encapsulated. Instead, create a base element that wraps the component's internals and style that instead. This convention also makes it easier to use BEM in components, as the base element can serve as the "block" entity.
When authoring components, please try to follow the same structure and conventions found in other components. Classes, for example, generally follow this structure:
- Static properties/methods
- Private/public properties (that are _not_ reactive)
- `@query` decorators
- `@state` decorators
- `@property` decorators
- Lifecycle methods (`connectedCallback()`, `disconnectedCallback()`, `firstUpdated()`, etc.)
- Private methods
- `@watch` decorators
- Public methods
- The `render()` method
Please avoid using the `public` keyword for class fields. It's simply too verbose when combined with decorators, property names, and arguments. However, _please do_ add `private` in front of any property or method that is intended to be private.
?> This might seem like a lot, but it's fairly intuitive once you start working with the library. However, don't let this structure prevent you from submitting a PR. [Code can change](https://www.abeautifulsite.net/posts/code-can-change/) and nobody will chastise you for "getting it wrong." At the same time, encouraging consistency helps keep the library maintainable and easy for others to understand. (A lint rule that helps with things like this would be a very welcome PR!)
### Class Names
All components use a [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM), so styles are completely encapsulated from the rest of the document. As a result, class names used _inside_ a component won't conflict with class names _outside_ the component, so we're free to name them anything we want.
@@ -314,6 +329,20 @@ render() {
This results in a consistent, easy to understand structure for parts. In this example, the `icon` part will target the host element and the `icon__base` part will target the icon's `base` part.
### Dependencies
TL;DR a component is a dependency if and only if it's rendered inside another component's shadow root.
Many Shoelace components use other Shoelace components internally. For example, `<sl-button>` uses both `<sl-icon>` and `<sl-spinner>` for its caret icon and loading state, respectively. Since these components appear in the button's shadow root, they are considered dependencies of Button. Since dependencies are automatically loaded, users only need to import the button and everything will work as expected.
Contrast this to `<sl-select>` and `<sl-option>`. At first, one might assume that Option is a dependency of Select. After all, you can't really use Select without slotting in at least one Option. However, Option _is not_ a dependency of Select! The reason is because no Option is rendered in the Select's shadow root. Since the options are provided by the user, it's up to them to import both components independently.
People often suggest that Shoelace should auto-load Select + Option, Menu + Menu Item, Breadcrumb + Breadcrumb Item, etc. Although some components are designed to work together, they're technically not dependencies so eagerly loading them may not be desirable. What if someone wants to roll their own component with a superset of features? They wouldn't be able to if Shoelace automatically imported it!
Similarly, in the case of `<sl-radio-group>` there was originally only `<sl-radio>`, but now you can use either `<sl-radio>` or `<sl-radio-button>` as child elements. Which component(s) should be auto-loaded dependencies in this case? Had Radio been a dependency of Radio Group, users that only wanted Radio Buttons would be forced to register both with no way to opt out and no way to provide their own customized version.
For non-dependencies, _the user_ should decide what gets registered, even if it comes with a minor inconvenience.
### Form Controls
Form controls should support submission and validation through the following conventions:

View File

@@ -2,11 +2,11 @@
Border radius tokens are used to give sharp edges a more subtle, rounded effect. They use rem units so they scale with the base font size. The pixel values displayed are based on a 16px font size.
| Token | Value | Example |
| ---------------------------- | -------------- | -------------------------------------------------------------------------------------------------------- |
| `--sl-border-radius-small` | 0.125rem (2px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-small);"></div> |
| `--sl-border-radius-medium` | 0.25rem (4px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-medium);"></div> |
| `--sl-border-radius-large` | 0.5rem (8px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-large);"></div> |
| `--sl-border-radius-x-large` | 1rem (16px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-x-large);"></div> |
| `--sl-border-radius-circle` | 50% | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-circle);"></div> |
| `--sl-border-radius-pill` | 9999px | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-pill); width: 6rem;"></div> |
| Token | Value | Example |
| ---------------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
| `--sl-border-radius-small` | 0.1875rem (3px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-small);"></div> |
| `--sl-border-radius-medium` | 0.25rem (4px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-medium);"></div> |
| `--sl-border-radius-large` | 0.5rem (8px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-large);"></div> |
| `--sl-border-radius-x-large` | 1rem (16px) | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-x-large);"></div> |
| `--sl-border-radius-circle` | 50% | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-circle);"></div> |
| `--sl-border-radius-pill` | 9999px | <div class="border-radius-demo" style="border-radius: var(--sl-border-radius-pill); width: 6rem;"></div> |

155
docs/tokens/more.md Normal file
View File

@@ -0,0 +1,155 @@
# More Design Tokens
All of the design tokens described herein are considered relatively stable. However, some changes might occur in future versions to address mission critical bugs or improvements. If such changes occur, they _will not_ be considered breaking changes and will be clearly documented in the [changelog](/resources/changelog).
Most design tokens are consistent across the light and dark theme. Those that vary will show both values.
?> Currently, the source of design tokens is considered to be [`light.css`](https://github.com/shoelace-style/shoelace/blob/next/src/themes/light.css). The dark theme, [dark.css](https://github.com/shoelace-style/shoelace/blob/next/src/themes/dark.css), mirrors all of the same tokens with dark mode-specific values where appropriate. Work is planned to move all design tokens to a single file, perhaps JSON or YAML, in the near future.
## Focus Rings
Focus ring tokens control the appearance of focus rings. Note that form inputs use `--sl-input-focus-ring-*` tokens instead.
| Token | Value |
| ------------------------ | ------------------------------------------------------------------------------------- |
| `--sl-focus-ring-color` | var(--sl-color-primary-600) (light theme)<br>var(--sl-color-primary-700) (dark theme) |
| `--sl-focus-ring-style` | solid |
| `--sl-focus-ring-width` | 3px |
| `--sl-focus-ring` | var(--sl-focus-ring-style) var(--sl-focus-ring-width) var(--sl-focus-ring-color) |
| `--sl-focus-ring-offset` | 1px |
## Buttons
Button tokens control the appearance of buttons. In addition, buttons also currently use some form input tokens such as `--sl-input-height-*` and `--sl-input-border-*`. More button tokens may be added in the future to make it easier to style them more independently.
| Token | Value |
| ------------------------------ | --------------------------- |
| `--sl-button-font-size-small` | var(--sl-font-size-x-small) |
| `--sl-button-font-size-medium` | var(--sl-font-size-small) |
| `--sl-button-font-size-large` | var(--sl-font-size-medium) |
## Form Inputs
Form input tokens control the appearance of form controls such as [input](/components/input), [select](/components/select), [textarea](/components/textarea), etc.
| Token | Value |
| --------------------------------------- | -------------------------------- |
| `--sl-input-height-small` | 1.875rem; (30px @ 16px base) |
| `--sl-input-height-medium` | 2.5rem; (40px @ 16px base) |
| `--sl-input-height-large` | 3.125rem; (50px @ 16px base) |
| `--sl-input-background-color` | var(--sl-color-neutral-0) |
| `--sl-input-background-color-hover` | var(--sl-input-background-color) |
| `--sl-input-background-color-focus` | var(--sl-input-background-color) |
| `--sl-input-background-color-disabled` | var(--sl-color-neutral-100) |
| `--sl-input-border-color` | var(--sl-color-neutral-300) |
| `--sl-input-border-color-hover` | var(--sl-color-neutral-400) |
| `--sl-input-border-color-focus` | var(--sl-color-primary-500) |
| `--sl-input-border-color-disabled` | var(--sl-color-neutral-300) |
| `--sl-input-border-width` | 1px |
| `--sl-input-required-content` | "\*" |
| `--sl-input-required-content-offset` | -2px |
| `--sl-input-required-content-color` | var(--sl-input-label-color) |
| `--sl-input-border-radius-small` | var(--sl-border-radius-medium) |
| `--sl-input-border-radius-medium` | var(--sl-border-radius-medium) |
| `--sl-input-border-radius-large` | var(--sl-border-radius-medium) |
| `--sl-input-font-family` | var(--sl-font-sans) |
| `--sl-input-font-weight` | var(--sl-font-weight-normal) |
| `--sl-input-font-size-small` | var(--sl-font-size-small) |
| `--sl-input-font-size-medium` | var(--sl-font-size-medium) |
| `--sl-input-font-size-large` | var(--sl-font-size-large) |
| `--sl-input-letter-spacing` | var(--sl-letter-spacing-normal) |
| `--sl-input-color` | var(--sl-color-neutral-700) |
| `--sl-input-color-hover` | var(--sl-color-neutral-700) |
| `--sl-input-color-focus` | var(--sl-color-neutral-700) |
| `--sl-input-color-disabled` | var(--sl-color-neutral-900) |
| `--sl-input-icon-color` | var(--sl-color-neutral-500) |
| `--sl-input-icon-color-hover` | var(--sl-color-neutral-600) |
| `--sl-input-icon-color-focus` | var(--sl-color-neutral-600) |
| `--sl-input-placeholder-color` | var(--sl-color-neutral-500) |
| `--sl-input-placeholder-color-disabled` | var(--sl-color-neutral-600) |
| `--sl-input-spacing-small` | var(--sl-spacing-small) |
| `--sl-input-spacing-medium` | var(--sl-spacing-medium) |
| `--sl-input-spacing-large` | var(--sl-spacing-large) |
| `--sl-input-focus-ring-color` | hsl(198.6 88.7% 48.4% / 40%) |
| `--sl-input-focus-ring-offset` | 0 |
## Filled Form Inputs
Filled form input tokens control the appearance of form controls using the `filled` variant.
| Token | Value |
| --------------------------------------------- | --------------------------- |
| `--sl-input-filled-background-color` | var(--sl-color-neutral-100) |
| `--sl-input-filled-background-color-hover` | var(--sl-color-neutral-100) |
| `--sl-input-filled-background-color-focus` | var(--sl-color-neutral-100) |
| `--sl-input-filled-background-color-disabled` | var(--sl-color-neutral-100) |
| `--sl-input-filled-color` | var(--sl-color-neutral-800) |
| `--sl-input-filled-color-hover` | var(--sl-color-neutral-800) |
| `--sl-input-filled-color-focus` | var(--sl-color-neutral-700) |
| `--sl-input-filled-color-disabled` | var(--sl-color-neutral-800) |
## Form Labels
Form label tokens control the appearance of labels in form controls.
| Token | Value |
| ----------------------------------- | -------------------------- |
| `--sl-input-label-font-size-small` | var(--sl-font-size-small) |
| `--sl-input-label-font-size-medium` | var(--sl-font-size-medium) |
| `--sl-input-label-font-size-large` | var(--sl-font-size-large) |
| `--sl-input-label-color` | inherit |
## Help Text
Help text tokens control the appearance of help text in form controls.
| Token | Value |
| --------------------------------------- | --------------------------- |
| `--sl-input-help-text-font-size-small` | var(--sl-font-size-x-small) |
| `--sl-input-help-text-font-size-medium` | var(--sl-font-size-small) |
| `--sl-input-help-text-font-size-large` | var(--sl-font-size-medium) |
| `--sl-input-help-text-color` | var(--sl-color-neutral-500) |
## Toggles
Toggle tokens control the appearance of toggles such as [checkbox](/components/checkbox), [radio](/components/radio), [switch](/components/switch), etc.
| Token | Value |
| ------------------------- | --------------------------- |
| `--sl-toggle-size-small` | 0.875rem (14px @ 16px base) |
| `--sl-toggle-size-medium` | 1.125rem (18px @ 16px base) |
| `--sl-toggle-size-large` | 1.375rem (22px @ 16px base) |
## Overlays
Overlay tokens control the appearance of overlays as used in [dialog](/components/dialog), [drawer](/components/drawer), etc.
| Token | Value |
| ------------------------------- | ------------------------- |
| `--sl-overlay-background-color` | hsl(240 3.8% 46.1% / 33%) |
## Panels
Panel tokens control the appearance of panels such as those used in [dialog](/components/dialog), [drawer](/components/drawer), [menu](/components/menu), etc.
| Token | Value |
| ----------------------------- | --------------------------- |
| `--sl-panel-background-color` | var(--sl-color-neutral-0) |
| `--sl-panel-border-color` | var(--sl-color-neutral-200) |
| `--sl-panel-border-width` | 1px |
## Tooltips
Tooltip tokens control the appearance of tooltips. This includes the [tooltip](/components/tooltip) component as well as other implementations, such [range tooltips](/components/range).
| Token | Value |
| ------------------------------- | ---------------------------------------------------- |
| `--sl-tooltip-border-radius` | var(--sl-border-radius-medium) |
| `--sl-tooltip-background-color` | var(--sl-color-neutral-800) |
| `--sl-tooltip-color` | var(--sl-color-neutral-0) |
| `--sl-tooltip-font-family` | var(--sl-font-sans) |
| `--sl-tooltip-font-weight` | var(--sl-font-weight-normal) |
| `--sl-tooltip-font-size` | var(--sl-font-size-small) |
| `--sl-tooltip-line-height` | var(--sl-line-height-dense) |
| `--sl-tooltip-padding` | var(--sl-spacing-2x-small) var(--sl-spacing-x-small) |
| `--sl-tooltip-arrow-size` | 6px |

View File

@@ -10,7 +10,7 @@ The default font stack is designed to be simple and highly available on as many
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `--sl-font-sans` | -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' | <span style="font-family: var(--sl-font-sans)">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-font-serif` | Georgia, 'Times New Roman', serif | <span style="font-family: var(--sl-font-serif)">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-font-mono` | Menlo, Monaco, 'Courier New', monospace | <span style="font-family: var(--sl-font-mono)">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-font-mono` | SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; | <span style="font-family: var(--sl-font-mono)">The quick brown fox jumped over the lazy dog.</span> |
## Font Size
@@ -41,18 +41,18 @@ Font sizes use `rem` units so they scale with the base font size. The pixel valu
| Token | Value | Example |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `--sl-letter-spacing-denser` | ? | <span style="letter-spacing: var(--sl-letter-spacing-denser);">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-letter-spacing-denser` | -0.03em | <span style="letter-spacing: var(--sl-letter-spacing-denser);">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-letter-spacing-dense` | -0.015em | <span style="letter-spacing: var(--sl-letter-spacing-dense);">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-letter-spacing-normal` | normal | <span style="letter-spacing: var(--sl-letter-spacing-normal);">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-letter-spacing-loose` | 0.075em | <span style="letter-spacing: var(--sl-letter-spacing-loose);">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-letter-spacing-looser` | ? | <span style="letter-spacing: var(--sl-letter-spacing-looser);">The quick brown fox jumped over the lazy dog.</span> |
| `--sl-letter-spacing-looser` | 0.15em | <span style="letter-spacing: var(--sl-letter-spacing-looser);">The quick brown fox jumped over the lazy dog.</span> |
## Line Height
| Token | Value | Example |
| ------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--sl-line-height-denser` | ? | <div style="line-height: var(--sl-line-height-denser);">The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.</div> |
| `--sl-line-height-denser` | 1 | <div style="line-height: var(--sl-line-height-denser);">The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.</div> |
| `--sl-line-height-dense` | 1.4 | <div style="line-height: var(--sl-line-height-dense);">The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.</div> |
| `--sl-line-height-normal` | 1.8 | <div style="line-height: var(--sl-line-height-normal);">The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.</div> |
| `--sl-line-height-loose` | 2.2 | <div style="line-height: var(--sl-line-height-loose);">The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.</div> |
| `--sl-line-height-looser` | ? | <div style="line-height: var(--sl-line-height-looser);">The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.</div> |
| `--sl-line-height-looser` | 2.6 | <div style="line-height: var(--sl-line-height-looser);">The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.<br>The quick brown fox jumped over the lazy dog.</div> |

4843
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.0.0-beta.87",
"version": "2.2.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/*",
@@ -51,72 +52,75 @@
"lint:fix": "eslint src --max-warnings 0 --fix",
"ts-check": "tsc --noEmit --project ./tsconfig.json",
"create": "plop --plopfile scripts/plop/plopfile.js",
"test": "web-test-runner",
"test:component": "npm run test -- --watch --group",
"test:watch": "web-test-runner --watch",
"test": "web-test-runner --group default",
"test:component": "web-test-runner -- --watch --group",
"test:watch": "web-test-runner --watch --group default",
"spellcheck": "cspell \"**/*.{js,ts,json,html,css,md}\" --no-progress",
"list-outdated-dependencies": "npm-check-updates --format repo --peer",
"update-dependencies": "npm-check-updates --peer -u && npm install && npm run lint:fix && npm run prettier && npm run verify"
"update-dependencies": "npm-check-updates --peer -u && npm install"
},
"engines": {
"node": ">=14.17.0"
},
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
"@floating-ui/dom": "^1.0.7",
"@lit-labs/react": "^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.3",
"lit": "^2.4.1",
"@shoelace-style/localize": "^3.1.0",
"composed-offset-position": "^0.0.4",
"lit": "^2.6.1",
"qr-creator": "^1.0.0"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "^0.6.6",
"@custom-elements-manifest/analyzer": "^0.6.8",
"@open-wc/testing": "^3.1.7",
"@types/mocha": "^10.0.0",
"@types/react": "^18.0.25",
"@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0",
"@types/mocha": "^10.0.1",
"@types/react": "^18.0.26",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"@web/dev-server-esbuild": "^0.3.3",
"@web/test-runner": "^0.15.0",
"@web/test-runner-commands": "^0.6.5",
"@web/test-runner-playwright": "^0.9.0",
"bootstrap-icons": "^1.10.2",
"browser-sync": "^2.27.10",
"cem-plugin-vs-code-custom-data-generator": "^1.3.0",
"chalk": "^5.1.2",
"bootstrap-icons": "^1.10.3",
"browser-sync": "^2.27.11",
"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",
"cspell": "^6.14.2",
"cspell": "^6.18.1",
"del": "^7.0.0",
"download": "^8.0.0",
"esbuild": "^0.15.14",
"eslint": "^8.27.0",
"esbuild": "^0.16.17",
"eslint": "^8.31.0",
"eslint-plugin-chai-expect": "^3.0.0",
"eslint-plugin-chai-friendly": "^0.7.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-lit": "^1.6.1",
"eslint-plugin-lit-a11y": "^2.2.3",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-lit": "^1.8.2",
"eslint-plugin-lit-a11y": "^2.3.0",
"eslint-plugin-markdown": "^3.0.0",
"eslint-plugin-wc": "^1.3.2",
"eslint-plugin-sort-imports-es6-autofix": "^0.6.0",
"eslint-plugin-wc": "^1.4.0",
"front-matter": "^4.0.2",
"get-port": "^6.1.2",
"globby": "^13.1.2",
"husky": "^8.0.2",
"jsonata": "^1.8.6",
"lint-staged": "^13.0.3",
"globby": "^13.1.3",
"husky": "^8.0.3",
"jsonata": "^2.0.1",
"lint-staged": "^13.1.0",
"lunr": "^2.3.9",
"npm-check-updates": "^16.4.1",
"npm-check-updates": "^16.6.2",
"open": "^8.4.0",
"pascal-case": "^3.1.2",
"plop": "^3.1.1",
"prettier": "^2.7.1",
"prettier": "^2.8.2",
"react": "^18.2.0",
"recursive-copy": "^2.0.14",
"sinon": "^14.0.2",
"sinon": "^15.0.1",
"source-map": "^0.7.4",
"strip-css-comments": "^5.0.0",
"tslib": "^2.4.1",
"typescript": "4.8.4",
"typescript": "4.9.4",
"user-agent-data-types": "^0.3.0"
},
"lint-staged": {

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

@@ -3,7 +3,6 @@ import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
import { deleteSync } from 'del';
import { pascalCase } from 'pascal-case';
import prettier from 'prettier';
import prettierConfig from '../prettier.config.cjs';
import { getAllComponents } from './shared.js';
@@ -40,14 +39,14 @@ components.map(component => {
import { createComponent } from '@lit-labs/react';
import Component from '../../${importPath}';
export default createComponent(
React,
'${component.tagName}',
Component,
{
export default createComponent({
tagName: '${component.tagName}',
elementClass: Component,
react: React,
events: {
${events}
}
);
});
`,
Object.assign(prettierConfig, {
parser: 'babel-ts'

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

@@ -8,9 +8,9 @@ import type { CSSResultGroup } from 'lit';
/**
* @summary Short summary of the component's intended use.
*
* @since 2.0
* @documentation https://shoelace.style/components/{{ tagWithoutPrefix tag }}
* @status experimental
* @since 2.0
*
* @dependency sl-example
*

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

@@ -1,14 +1,14 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { animateTo, stopAnimations } from '../../internal/animate';
import { waitForEvent } from '../../internal/event';
import ShoelaceElement from '../../internal/shoelace-element';
import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { LocalizeController } from '../../utilities/localize';
import '../icon-button/icon-button';
import { animateTo, stopAnimations } from '../../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './alert.styles';
import type { CSSResultGroup } from 'lit';
@@ -16,9 +16,9 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
/**
* @summary Alerts are used to display important messages inline or as toast notifications.
*
* @since 2.0
* @documentation https://shoelace.style/components/alert
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
@@ -73,6 +73,57 @@ export default class SlAlert extends ShoelaceElement {
this.base.hidden = !this.open;
}
private restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
}
}
private handleCloseClick() {
this.hide();
}
private handleMouseMove() {
this.restartAutoHide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sl-show');
if (this.duration < Infinity) {
this.restartAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.emit('sl-after-hide');
}
}
@watch('duration')
handleDurationChange() {
this.restartAutoHide();
}
/** Shows the alert. */
async show() {
if (this.open) {
@@ -129,57 +180,6 @@ export default class SlAlert extends ShoelaceElement {
});
}
restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
}
}
handleCloseClick() {
this.hide();
}
handleMouseMove() {
this.restartAutoHide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sl-show');
if (this.duration < Infinity) {
this.restartAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.emit('sl-after-hide');
}
}
@watch('duration')
handleDurationChange() {
this.restartAutoHide();
}
render() {
return html`
<div

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,16 +1,16 @@
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import '../icon/icon';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './animated-image.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction.
*
* @since 2.0
* @documentation https://shoelace.style/components/animated-image
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
@@ -29,11 +29,11 @@ import type { CSSResultGroup } from 'lit';
export default class SlAnimatedImage extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.animated-image__animated') animatedImage: HTMLImageElement;
@state() frozenFrame: string;
@state() isLoaded = false;
@query('.animated-image__animated') animatedImage: HTMLImageElement;
/** The path to the image to load. */
@property() src: string;
@@ -43,11 +43,11 @@ export default class SlAnimatedImage extends ShoelaceElement {
/** Plays the animation. When this attribute is remove, the animation will pause. */
@property({ type: Boolean, reflect: true }) play: boolean;
handleClick() {
private handleClick() {
this.play = !this.play;
}
handleLoad() {
private handleLoad() {
const canvas = document.createElement('canvas');
const { width, height } = this.animatedImage;
canvas.width = width;
@@ -61,7 +61,7 @@ export default class SlAnimatedImage extends ShoelaceElement {
}
}
handleError() {
private handleError() {
this.emit('sl-error');
}

View File

@@ -1,16 +1,16 @@
import { html } from 'lit';
import { customElement, property, queryAsync } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import styles from './animation.styles';
import { animations } from './animations';
import { customElement, property, queryAsync } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './animation.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Animate elements declaratively with nearly 100 baked-in presets, or roll your own with custom keyframes. Powered by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API).
*
* @since 2.0
* @documentation https://shoelace.style/components/animation
* @status stable
* @since 2.0
*
* @event sl-cancel - Emitted when the animation is canceled.
* @event sl-finish - Emitted when the animation finishes.
@@ -100,68 +100,24 @@ export default class SlAnimation extends ShoelaceElement {
this.destroyAnimation();
}
@watch('name')
@watch('delay')
@watch('direction')
@watch('duration')
@watch('easing')
@watch('endDelay')
@watch('fill')
@watch('iterations')
@watch('iterationsStart')
@watch('keyframes')
handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
}
handleAnimationFinish() {
private handleAnimationFinish() {
this.play = false;
this.hasStarted = false;
this.emit('sl-finish');
}
handleAnimationCancel() {
private handleAnimationCancel() {
this.play = false;
this.hasStarted = false;
this.emit('sl-cancel');
}
@watch('play')
handlePlayChange() {
if (this.animation) {
if (this.play && !this.hasStarted) {
this.hasStarted = true;
this.emit('sl-start');
}
if (this.play) {
this.animation.play();
} else {
this.animation.pause();
}
return true;
}
return false;
}
@watch('playbackRate')
handlePlaybackRateChange() {
if (this.animation) {
this.animation.playbackRate = this.playbackRate;
}
}
handleSlotChange() {
private handleSlotChange() {
this.destroyAnimation();
this.createAnimation();
}
async createAnimation() {
private async createAnimation() {
const easing = animations.easings[this.easing] ?? this.easing;
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
const slot = await this.defaultSlot;
@@ -196,7 +152,7 @@ export default class SlAnimation extends ShoelaceElement {
return true;
}
destroyAnimation() {
private destroyAnimation() {
if (this.animation) {
this.animation.cancel();
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
@@ -205,6 +161,52 @@ export default class SlAnimation extends ShoelaceElement {
}
}
@watch([
'name',
'delay',
'direction',
'duration',
'easing',
'endDelay',
'fill',
'iterations',
'iterationsStart',
'keyframes'
])
handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
}
@watch('play')
handlePlayChange() {
if (this.animation) {
if (this.play && !this.hasStarted) {
this.hasStarted = true;
this.emit('sl-start');
}
if (this.play) {
this.animation.play();
} else {
this.animation.pause();
}
return true;
}
return false;
}
@watch('playbackRate')
handlePlaybackRateChange() {
if (this.animation) {
this.animation.playbackRate = this.playbackRate;
}
}
/** Clears all keyframe effects caused by this animation and aborts its playback. */
cancel() {
this.animation?.cancel();

View File

@@ -1,7 +1,10 @@
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
// used to display text, we're going to relax this rule.
const ignoredRules = ['color-contrast'];
describe('<sl-avatar>', () => {
let el: SlAvatar;
@@ -11,7 +14,7 @@ describe('<sl-avatar>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('should default to circle styling', () => {
@@ -36,7 +39,7 @@ describe('<sl-avatar>', () => {
* the image element to pass accessibility.
* https://html.spec.whatwg.org/multipage/images.html#ancillary-images
*/
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "image" part, with src and a role of presentation', () => {
@@ -59,7 +62,7 @@ describe('<sl-avatar>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "initials" part, with initials as the text node', () => {
@@ -76,7 +79,7 @@ describe('<sl-avatar>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('appends the appropriate class on the "base" part', () => {
@@ -94,7 +97,7 @@ describe('<sl-avatar>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('should accept as an assigned child in the shadow root', () => {
@@ -109,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

@@ -1,17 +1,17 @@
import { html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import '../icon/icon';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, state } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './avatar.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Avatars are used to represent a person or object.
*
* @since 2.0
* @documentation https://shoelace.style/components/avatar
* @status stable
* @since 2.0
*
* @dependency sl-icon
*

View File

@@ -1,15 +1,15 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './badge.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Badges are used to draw attention and display statuses or counts.
*
* @since 2.0
* @documentation https://shoelace.style/components/badge
* @status stable
* @since 2.0
*
* @slot - The badge's content.
*

View File

@@ -1,17 +1,17 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { HasSlotController } from '../../internal/slot';
import styles from './breadcrumb-item.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Breadcrumb Items are used inside [breadcrumbs](/components/breadcrumb) to represent different links.
*
* @since 2.0
* @documentation https://shoelace.style/components/breadcrumb-item
* @status stable
* @since 2.0
*
* @slot - The breadcrumb item's label.
* @slot prefix - An optional prefix, usually an icon or icon button.

View File

@@ -1,6 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import type SlBreadcrumb from './breadcrumb';
// The default link color just misses AA contrast, but the next step up is way too dark. Maybe we can solve this in the
// future with a prefers-contrast media query.
const ignoredRules = ['color-contrast'];
describe('<sl-breadcrumb>', () => {
let el: SlBreadcrumb;
@@ -17,7 +21,7 @@ describe('<sl-breadcrumb>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('should render sl-icon as separator', () => {
@@ -44,7 +48,7 @@ describe('<sl-breadcrumb>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('should accept "separator" as an assigned child in the shadow root', () => {
@@ -76,7 +80,7 @@ describe('<sl-breadcrumb>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
});
@@ -96,7 +100,7 @@ describe('<sl-breadcrumb>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
});
});

View File

@@ -1,17 +1,17 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { LocalizeController } from '../../utilities/localize';
import '../icon/icon';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './breadcrumb.styles';
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item';
import type { CSSResultGroup } from 'lit';
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item';
/**
* @summary Breadcrumbs provide a group of links so users can easily navigate a website's hierarchy.
*
* @since 2.0
* @documentation https://shoelace.style/components/breadcrumb
* @status stable
* @since 2.0
*
* @slot - One or more breadcrumb items to display.
* @slot separator - The separator to use between breadcrumb items. Works best with `<sl-icon>`.
@@ -24,12 +24,12 @@ import type { CSSResultGroup } from 'lit';
export default class SlBreadcrumb extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('slot') defaultSlot: HTMLSlotElement;
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
private readonly localize = new LocalizeController(this);
private separatorDir = this.localize.dir();
@query('slot') defaultSlot: HTMLSlotElement;
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
/**
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
* screen readers and other assistive devices to provide more context for users.
@@ -49,7 +49,7 @@ export default class SlBreadcrumb extends ShoelaceElement {
return clone;
}
handleSlotChange() {
private handleSlotChange() {
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
) as SlBreadcrumbItem[];

View File

@@ -1,4 +1,4 @@
import { expect, fixture, html, elementUpdated } from '@open-wc/testing';
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import type SlButtonGroup from './button-group';
describe('<sl-button-group>', () => {

View File

@@ -1,14 +1,14 @@
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './button-group.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Button groups can be used to group related buttons into sections.
*
* @since 2.0
* @documentation https://shoelace.style/components/button-group
* @status stable
* @since 2.0
*
* @slot - One or more `<sl-button>` elements to display in the button group.
*
@@ -28,27 +28,27 @@ export default class SlButtonGroup extends ShoelaceElement {
*/
@property() label = '';
handleFocus(event: CustomEvent) {
private handleFocus(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--focus');
}
handleBlur(event: CustomEvent) {
private handleBlur(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--focus');
}
handleMouseOver(event: CustomEvent) {
private handleMouseOver(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--hover');
}
handleMouseOut(event: CustomEvent) {
private handleMouseOut(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--hover');
}
handleSlotChange() {
private handleSlotChange() {
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
slottedElements.forEach(el => {

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;
}
@@ -562,7 +562,13 @@ export default css`
}
/* Add a visual separator between solid buttons */
:host(.sl-button-group__button:not(.sl-button-group__button--first, .sl-button-group__button--radio, [variant='default']):not(:hover))
:host(
.sl-button-group__button:not(
.sl-button-group__button--first,
.sl-button-group__button--radio,
[variant='default']
):not(:hover)
)
.button:after {
content: '';
position: absolute;

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

@@ -1,29 +1,30 @@
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { html, literal } from 'lit/static-html.js';
import { FormSubmitController } from '../../internal/form';
import ShoelaceElement from '../../internal/shoelace-element';
import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import { LocalizeController } from '../../utilities/localize';
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, 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';
import { LocalizeController } from '../../utilities/localize';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './button.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
/**
* @summary Buttons represent actions that are available to the user.
*
* @since 2.0
* @documentation https://shoelace.style/components/button
* @status stable
* @since 2.0
*
* @dependency sl-icon
* @dependency sl-spinner
*
* @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.
@@ -39,11 +40,9 @@ import type { CSSResultGroup } from 'lit';
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
private readonly formSubmitController = new FormSubmitController(this, {
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;
@@ -53,11 +52,14 @@ 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);
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
@@ -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,9 +149,87 @@ 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.invalid = !(this.button as HTMLButtonElement).checkValidity();
this.formControlController.updateValidity();
}
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick() {
if (this.type === 'submit') {
this.formControlController.submit(this);
}
if (this.type === 'reset') {
this.formControlController.reset(this);
}
}
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;
}
private isLink() {
return this.href ? true : false;
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
if (this.isButton()) {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
}
@@ -160,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();
@@ -169,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()) {
@@ -178,57 +271,14 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
return true;
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
if (this.isButton()) {
(this.button as HTMLButtonElement).setCustomValidity(message);
this.invalid = !(this.button as HTMLButtonElement).checkValidity();
this.formControlController.updateValidity();
}
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
handleClick(event: MouseEvent) {
if (this.disabled || this.loading) {
event.preventDefault();
event.stopPropagation();
return;
}
if (this.type === 'submit') {
this.formSubmitController.submit(this);
}
if (this.type === 'reset') {
this.formSubmitController.reset(this);
}
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.isButton()) {
this.button.disabled = this.disabled;
this.invalid = !(this.button as HTMLButtonElement).checkValidity();
}
}
private isButton() {
return this.href ? false : true;
}
private isLink() {
return this.href ? true : false;
}
render() {
const isLink = this.isLink();
const tag = isLink ? literal`a` : literal`button`;
@@ -271,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

@@ -1,16 +1,16 @@
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { customElement } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './card.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Cards can be used to group related subjects in a container.
*
* @since 2.0
* @documentation https://shoelace.style/components/card
* @status stable
* @since 2.0
*
* @slot - The card's main content.
* @slot header - An optional header for the card.

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,11 +9,12 @@ export default css`
}
.checkbox {
position: relative;
display: inline-flex;
align-items: top;
font-family: var(--sl-input-font-family);
font-weight: var(--sl-input-font-weight);
color: var(--sl-input-color);
color: var(--sl-input-label-color);
vertical-align: middle;
cursor: pointer;
}

View File

@@ -1,4 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
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';
@@ -48,8 +50,7 @@ describe('<sl-checkbox>', () => {
it('should be valid by default', async () => {
const el = await fixture<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
expect(el.invalid).to.be.false;
expect(el.checkValidity()).to.be.true;
});
it('should emit sl-change and sl-input when clicked', async () => {
@@ -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`
@@ -139,25 +155,62 @@ describe('<sl-checkbox>', () => {
expect(formData!.get('a')).to.equal('on');
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-checkbox name="a" value="1" checked></sl-checkbox>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const checkbox = form.querySelector('sl-checkbox')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <sl-checkbox></sl-checkbox> `);
// Submitting the form after setting custom validity should not trigger the handler
checkbox.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await checkbox.updateComplete;
await aTimeout(100);
expect(checkbox.checkValidity()).to.be.false;
expect(checkbox.checkValidity()).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;
expect(submitHandler).to.not.have.been.called;
await clickOnElement(checkbox);
await checkbox.updateComplete;
expect(checkbox.hasAttribute('data-user-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-user-valid')).to.be.false;
});
it('should be invalid when required and unchecked', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <sl-checkbox required></sl-checkbox> `);
expect(checkbox.checkValidity()).to.be.false;
});
it('should be valid when required and checked', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <sl-checkbox required checked></sl-checkbox> `);
expect(checkbox.checkValidity()).to.be.true;
});
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>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-checkbox form="f" name="a" value="1" checked></sl-checkbox>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
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;
});
});
@@ -256,5 +309,7 @@ describe('<sl-checkbox>', () => {
expect(indeterminateIcon).to.be.null;
});
runFormControlBaseTests('sl-checkbox');
});
});

View File

@@ -1,22 +1,22 @@
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import '../icon/icon';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { defaultValue } from '../../internal/default-value';
import { FormControlController } from '../../internal/form';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import { FormSubmitController } from '../../internal/form';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import '../icon/icon';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './checkbox.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
/**
* @summary Checkboxes allow the user to toggle an option on or off.
*
* @since 2.0
* @documentation https://shoelace.style/components/checkbox
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
@@ -26,6 +26,7 @@ import type { CSSResultGroup } from 'lit';
* @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.
@@ -39,17 +40,16 @@ import type { CSSResultGroup } from 'lit';
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('input[type="checkbox"]') input: HTMLInputElement;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this, {
private readonly formControlController = new FormControlController(this, {
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
defaultValue: (control: SlCheckbox) => control.defaultChecked,
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
});
@query('input[type="checkbox"]') input: HTMLInputElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
/** The name of the checkbox, submitted as a name/value pair with form data. */
@@ -64,9 +64,6 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
/** Disables the checkbox. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Draws the checkbox in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
@@ -79,8 +76,66 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue('checked') defaultChecked = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** 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.invalid = !this.input.checkValidity();
this.formControlController.updateValidity();
}
private handleClick() {
this.checked = !this.checked;
this.indeterminate = false;
this.emit('sl-change');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleInput() {
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');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked; // force a sync update
this.input.indeterminate = this.indeterminate; // force a sync update
this.formControlController.updateValidity();
}
/** Simulates a click on the checkbox. */
@@ -98,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();
}
@@ -114,45 +174,15 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
*/
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.invalid = !this.input.checkValidity();
}
handleClick() {
this.checked = !this.checked;
this.indeterminate = false;
this.emit('sl-change');
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleInput() {
this.emit('sl-input');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('checked', { waitUntilFirstUpdate: true })
@watch('indeterminate', { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked; // force a sync update
this.input.indeterminate = this.indeterminate; // force a sync update
this.invalid = !this.input.checkValidity();
this.formControlController.updateValidity();
}
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"
@@ -180,11 +210,15 @@ 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}
/>
<span part="control" class="checkbox__control">
<span
part="control${this.checked ? ' control--checked' : ''}${this.indeterminate ? ' control--indeterminate' : ''}"
class="checkbox__control"
>
${this.checked
? html`
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
@@ -202,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,7 +1,9 @@
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
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';
import type SlColorPicker from './color-picker';
describe('<sl-color-picker>', () => {
@@ -93,8 +95,33 @@ describe('<sl-color-picker>', () => {
expect(inputHandler).to.have.been.calledOnce;
});
it('should emit sl-change and sl-input when clicking on a swatch', async () => {
it('should render the correct swatches when passing a string of color values', async () => {
const el = await fixture<SlColorPicker>(
html` <sl-color-picker swatches="red; #008000; rgb(0,0,255);"></sl-color-picker> `
);
const swatches = [...el.shadowRoot!.querySelectorAll('[part~="swatch"] > div')];
expect(swatches.length).to.equal(3);
expect(getComputedStyle(swatches[0]).backgroundColor).to.equal('rgb(255, 0, 0)');
expect(getComputedStyle(swatches[1]).backgroundColor).to.equal('rgb(0, 128, 0)');
expect(getComputedStyle(swatches[2]).backgroundColor).to.equal('rgb(0, 0, 255)');
});
it('should render the correct swatches when passing an array of color values', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
el.swatches = ['red', '#008000', 'rgb(0,0,255)'];
await el.updateComplete;
const swatches = [...el.shadowRoot!.querySelectorAll('[part~="swatch"] > div')];
expect(swatches.length).to.equal(3);
expect(getComputedStyle(swatches[0]).backgroundColor).to.equal('rgb(255, 0, 0)');
expect(getComputedStyle(swatches[1]).backgroundColor).to.equal('rgb(0, 128, 0)');
expect(getComputedStyle(swatches[2]).backgroundColor).to.equal('rgb(0, 0, 255)');
});
it('should emit sl-change and sl-input when clicking on a swatch', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker swatches="red; green; blue;"></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const swatch = el.shadowRoot!.querySelector<HTMLElement>('[part~="swatch"]')!;
const changeHandler = sinon.spy();
@@ -290,19 +317,151 @@ describe('<sl-color-picker>', () => {
it('should display a color with opacity when an initial value with opacity is provided', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker opacity value="#ff000050"></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]');
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const previewButton = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="preview"]');
const previewColor = getComputedStyle(previewButton!).getPropertyValue('--preview-color');
expect(trigger!.style.color).to.equal('rgba(255, 0, 0, 0.314)');
expect(previewColor.startsWith('hsla(0deg, 100%, 50%, 0.31')).to.be.true;
expect(trigger.style.color).to.equal('rgba(255, 0, 0, 0.314)');
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`
<form>
<sl-color-picker name="a" value="#ffcc00"></sl-color-picker>
</form>
`);
const formData = new FormData(form);
expect(formData.get('a')).to.equal('#ffcc00');
});
it('should serialize its name and value with JSON', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-color-picker name="a" value="#ffcc00"></sl-color-picker>
</form>
`);
const json = serialize(form);
expect(json.a).to.equal('#ffcc00');
});
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>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-color-picker form="f" name="a" value="#ffcc00"></sl-color-picker>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('#ffcc00');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-color-picker name="a" value="#FFFFFF"></sl-color-picker>
<sl-color-picker name="a" value="#ffffff"></sl-color-picker>
<sl-button type="reset">Reset</sl-button>
</form>
`);
@@ -316,7 +475,7 @@ describe('<sl-color-picker>', () => {
await oneEvent(form, 'reset');
await colorPicker.updateComplete;
expect(colorPicker.value).to.equal('#FFFFFF');
expect(colorPicker.value).to.equal('#ffffff');
colorPicker.defaultValue = '';
@@ -327,4 +486,70 @@ describe('<sl-color-picker>', () => {
expect(colorPicker.value).to.equal('');
});
});
describe('when using constraint validation', () => {
it('should be valid by default', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
expect(el.checkValidity()).to.be.true;
});
it('should be invalid when required and empty', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
expect(el.checkValidity()).to.be.false;
});
it('should be invalid when required and disabled is removed', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker disabled required></sl-color-picker> `);
el.disabled = false;
await el.updateComplete;
expect(el.checkValidity()).to.be.false;
});
it('should receive the correct validation attributes ("states") when valid', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker required value="#fff"></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!;
const grid = el.shadowRoot!.querySelector('[part~="grid"]')!;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-required')).to.be.true;
expect(el.hasAttribute('data-optional')).to.be.false;
expect(el.hasAttribute('data-invalid')).to.be.false;
expect(el.hasAttribute('data-valid')).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.false;
await clickOnElement(trigger);
await aTimeout(500);
await clickOnElement(grid);
await el.updateComplete;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.true;
});
it('should receive the correct validation attributes ("states") when invalid', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!;
const grid = el.shadowRoot!.querySelector('[part~="grid"]')!;
expect(el.hasAttribute('data-required')).to.be.true;
expect(el.hasAttribute('data-optional')).to.be.false;
expect(el.hasAttribute('data-invalid')).to.be.true;
expect(el.hasAttribute('data-valid')).to.be.false;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.false;
await clickOnElement(trigger);
await aTimeout(500);
await clickOnElement(grid);
await el.updateComplete;
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
expect(el.hasAttribute('data-user-valid')).to.be.true;
});
});
runFormControlBaseTests('sl-color-picker');
});

View File

@@ -1,27 +1,29 @@
import { TinyColor } from '@ctrl/tinycolor';
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { defaultValue } from '../../internal/default-value';
import { drag } from '../../internal/drag';
import { FormSubmitController } from '../../internal/form';
import { clamp } from '../../internal/math';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import { LocalizeController } from '../../utilities/localize';
import '../button-group/button-group';
import '../button/button';
import '../dropdown/dropdown';
import '../icon/icon';
import '../input/input';
import '../visually-hidden/visually-hidden';
import { clamp } from '../../internal/math';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { defaultValue } from '../../internal/default-value';
import { drag } from '../../internal/drag';
import { FormControlController } from '../../internal/form';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize';
import { styleMap } from 'lit/directives/style-map.js';
import { TinyColor } from '@ctrl/tinycolor';
import { watch } from '../../internal/watch';
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 { CSSResultGroup } from 'lit';
import type SlInputEvent from '../../events/sl-input';
const hasEyeDropper = 'EyeDropper' in window;
@@ -37,9 +39,9 @@ declare const EyeDropper: EyeDropperConstructor;
/**
* @summary Color pickers allow the user to select a color.
*
* @since 2.0
* @documentation https://shoelace.style/components/color-picker
* @status stable
* @since 2.0
*
* @dependency sl-button
* @dependency sl-button-group
@@ -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.
@@ -58,23 +63,25 @@ declare const EyeDropper: EyeDropperConstructor;
* @csspart swatch - Each individual swatch.
* @csspart grid - The color grid.
* @csspart grid-handle - The color grid's handle.
* @csspart hue-slider - The hue slider.
* @csspart opacity-slider - The opacity slider.
* @csspart slider - Hue and opacity sliders.
* @csspart slider-handle - Hue and opacity slider handles.
* @csspart hue-slider - The hue slider.
* @csspart hue-slider-handle - The hue slider's handle.
* @csspart opacity-slider - The opacity slider.
* @csspart opacity-slider-handle - The opacity slider's handle.
* @csspart preview - The preview color.
* @csspart input - The text input.
* @csspart eye-dropper-button - The eye dropper button.
* @csspart eye-dropper-button__button - The eye dropper button's exported `button` part.
* @csspart eye-dropper-button__base - The eye dropper button's exported `button` part.
* @csspart eye-dropper-button__prefix - The eye dropper button's exported `prefix` part.
* @csspart eye-dropper-button__label - The eye dropper button's exported `label` part.
* @csspart eye-dropper-button__button-suffix - The eye dropper button's exported `suffix` part.
* @csspart eye-dropper-button__suffix - The eye dropper button's exported `suffix` part.
* @csspart eye-dropper-button__caret - The eye dropper button's exported `caret` part.
* @csspart format-button - The format button.
* @csspart format-button__button - The format button's exported `button` part.
* @csspart format-button__base - The format button's exported `button` part.
* @csspart format-button__prefix - The format button's exported `prefix` part.
* @csspart format-button__label - The format button's exported `label` part.
* @csspart format-button__button-suffix - The format button's exported `suffix` part.
* @csspart format-button__suffix - The format button's exported `suffix` part.
* @csspart format-button__caret - The format button's exported `caret` part.
*
* @cssproperty --grid-width - The width of the color grid.
@@ -88,25 +95,24 @@ declare const EyeDropper: EyeDropperConstructor;
export default class SlColorPicker extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('[part~="input"]') input: SlInput;
@query('[part~="preview"]') previewButton: HTMLButtonElement;
@query('.color-dropdown') dropdown: SlDropdown;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly formControlController = new FormControlController(this);
private isSafeValue = false;
private lastValueEmitted: string;
private readonly localize = new LocalizeController(this);
@query('[part~="base"]') base: HTMLElement;
@query('[part~="input"]') input: SlInput;
@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 = '';
@state() private hue = 0;
@state() private saturation = 100;
@state() private lightness = 100;
@state() private brightness = 100;
@state() private alpha = 100;
@state() invalid = false;
/**
* The current value of the color picker. The value's format will vary based the `format` attribute. To get the value
@@ -158,107 +164,53 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
@property({ type: Boolean }) uppercase = false;
/**
* An array of predefined color swatches to display. Can include any format the color picker can parse, including
* HEX(A), RGB(A), HSL(A), HSV(A), and CSS color names.
* One or more predefined color swatches to display as presets in the color picker. Can include any format the color
* picker can parse, including HEX(A), RGB(A), HSL(A), HSV(A), and CSS color names. Each color must be separated by a
* semicolon (`;`). Alternatively, you can pass an array of color values to this property using JavaScript.
*/
@property({ attribute: false }) swatches: string[] = [
'#d0021b',
'#f5a623',
'#f8e71c',
'#8b572a',
'#7ed321',
'#417505',
'#bd10e0',
'#9013fe',
'#4a90e2',
'#50e3c2',
'#b8e986',
'#000',
'#444',
'#888',
'#ccc',
'#fff'
];
@property() swatches: string | string[] = '';
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@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();
if (this.value) {
this.setColor(this.value);
this.inputValue = this.value;
this.lastValueEmitted = this.value;
this.syncValues();
} else {
this.isEmpty = true;
this.inputValue = '';
this.lastValueEmitted = '';
}
this.handleFocusIn = this.handleFocusIn.bind(this);
this.handleFocusOut = this.handleFocusOut.bind(this);
this.addEventListener('focusin', this.handleFocusIn);
this.addEventListener('focusout', this.handleFocusOut);
}
/** 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(
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
);
if (currentColor === null) {
return '';
}
switch (format) {
case 'hex':
return currentColor.hex;
case 'hexa':
return currentColor.hexa;
case 'rgb':
return currentColor.rgb.string;
case 'rgba':
return currentColor.rgba.string;
case 'hsl':
return currentColor.hsl.string;
case 'hsla':
return currentColor.hsla.string;
case 'hsv':
return currentColor.hsv.string;
case 'hsva':
return currentColor.hsva.string;
default:
return '';
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('focusin', this.handleFocusIn);
this.removeEventListener('focusout', this.handleFocusOut);
}
getBrightness(lightness: number) {
return clamp(-1 * ((200 * lightness) / (this.saturation - 200)), 0, 100);
firstUpdated() {
this.input.updateComplete.then(() => {
this.formControlController.updateValidity();
});
}
getLightness(brightness: number) {
return clamp(((((200 - this.saturation) * brightness) / 100) * 5) / 10, 0, 100);
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (!this.inline && this.input.invalid) {
// 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();
}
return this.input.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.invalid = this.input.invalid;
}
handleCopy() {
private handleCopy() {
this.input.select();
document.execCommand('copy');
this.previewButton.focus();
@@ -270,7 +222,17 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleFormatToggle() {
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;
this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl' | 'hsv';
@@ -279,7 +241,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
this.emit('sl-input');
}
handleAlphaDrag(event: PointerEvent) {
private handleAlphaDrag(event: PointerEvent) {
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__alpha')!;
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
const { width } = container.getBoundingClientRect();
@@ -303,7 +265,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleHueDrag(event: PointerEvent) {
private handleHueDrag(event: PointerEvent) {
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__hue')!;
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
const { width } = container.getBoundingClientRect();
@@ -327,7 +289,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleGridDrag(event: PointerEvent) {
private handleGridDrag(event: PointerEvent) {
const grid = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__grid')!;
const handle = grid.querySelector<HTMLElement>('.color-picker__grid-handle')!;
const { width, height } = grid.getBoundingClientRect();
@@ -342,7 +304,6 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
onMove: (x, y) => {
this.saturation = clamp((x / width) * 100, 0, 100);
this.brightness = clamp(100 - (y / height) * 100, 0, 100);
this.lightness = this.getLightness(this.brightness);
this.syncValues();
if (this.value !== oldValue) {
@@ -356,7 +317,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleAlphaKeyDown(event: KeyboardEvent) {
private handleAlphaKeyDown(event: KeyboardEvent) {
const increment = event.shiftKey ? 10 : 1;
const oldValue = this.value;
@@ -390,7 +351,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleHueKeyDown(event: KeyboardEvent) {
private handleHueKeyDown(event: KeyboardEvent) {
const increment = event.shiftKey ? 10 : 1;
const oldValue = this.value;
@@ -424,35 +385,31 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleGridKeyDown(event: KeyboardEvent) {
private handleGridKeyDown(event: KeyboardEvent) {
const increment = event.shiftKey ? 10 : 1;
const oldValue = this.value;
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.saturation = clamp(this.saturation - increment, 0, 100);
this.lightness = this.getLightness(this.brightness);
this.syncValues();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.saturation = clamp(this.saturation + increment, 0, 100);
this.lightness = this.getLightness(this.brightness);
this.syncValues();
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.brightness = clamp(this.brightness + increment, 0, 100);
this.lightness = this.getLightness(this.brightness);
this.syncValues();
}
if (event.key === 'ArrowDown') {
event.preventDefault();
this.brightness = clamp(this.brightness - increment, 0, 100);
this.lightness = this.getLightness(this.brightness);
this.syncValues();
}
@@ -462,7 +419,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleInputChange(event: CustomEvent) {
private handleInputChange(event: SlChangeEvent) {
const target = event.target as HTMLInputElement;
const oldValue = this.value;
@@ -482,12 +439,14 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleInputInput(event: CustomEvent) {
private handleInputInput(event: SlInputEvent) {
this.formControlController.updateValidity();
// Prevent the <sl-input>'s sl-input event from bubbling up
event.stopPropagation();
}
handleInputKeyDown(event: KeyboardEvent) {
private handleInputKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
const oldValue = this.value;
@@ -507,11 +466,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleTouchMove(event: TouchEvent) {
private handleInputInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleTouchMove(event: TouchEvent) {
event.preventDefault();
}
parseColor(colorString: string) {
private parseColor(colorString: string) {
const color = new TinyColor(colorString);
if (!color.isValid) {
return null;
@@ -591,34 +555,33 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
};
}
setColor(colorString: string) {
private setColor(colorString: string) {
const newColor = this.parseColor(colorString);
if (newColor === null) {
return false;
}
this.hue = newColor.hsla.h;
this.saturation = newColor.hsla.s;
this.lightness = newColor.hsla.l;
this.brightness = this.getBrightness(newColor.hsla.l);
this.alpha = this.opacity ? newColor.hsla.a * 100 : 100;
this.hue = newColor.hsva.h;
this.saturation = newColor.hsva.s;
this.brightness = newColor.hsva.v;
this.alpha = this.opacity ? newColor.hsva.a * 100 : 100;
this.syncValues();
return true;
}
setLetterCase(string: string) {
private setLetterCase(string: string) {
if (typeof string !== 'string') {
return '';
}
return this.uppercase ? string.toUpperCase() : string.toLowerCase();
}
async syncValues() {
private async syncValues() {
const currentColor = this.parseColor(
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
`hsva(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha / 100})`
);
if (currentColor === null) {
@@ -645,11 +608,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
this.isSafeValue = false;
}
handleAfterHide() {
private handleAfterHide() {
this.previewButton.classList.remove('color-picker__preview-color--copied');
}
handleEyeDropper() {
private handleEyeDropper() {
if (!hasEyeDropper) {
return;
}
@@ -658,13 +621,22 @@ 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
});
}
selectSwatch(color: string) {
private selectSwatch(color: string) {
const oldValue = this.value;
if (!this.disabled) {
@@ -677,6 +649,21 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
/** Generates a hex string from HSV values. Hue must be 0-360. All other arguments must be 0-100. */
private getHexString(hue: number, saturation: number, brightness: number, alpha = 100) {
const color = new TinyColor(`hsva(${hue}, ${saturation}, ${brightness}, ${alpha / 100})`);
if (!color.isValid) {
return '';
}
return color.toHex8String();
}
// Prevents nested components from leaking events
private stopNestedEventPropagation(event: CustomEvent) {
event.stopImmediatePropagation();
}
@watch('format', { waitUntilFirstUpdate: true })
handleFormatChange() {
this.syncValues();
@@ -693,34 +680,125 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
if (!newValue) {
this.hue = 0;
this.saturation = 100;
this.saturation = 0;
this.brightness = 100;
this.lightness = this.getLightness(this.brightness);
this.alpha = 100;
}
if (!this.isSafeValue && oldValue !== undefined) {
if (!this.isSafeValue) {
const newColor = this.parseColor(newValue);
if (newColor !== null) {
this.inputValue = this.value;
this.hue = newColor.hsla.h;
this.saturation = newColor.hsla.s;
this.lightness = newColor.hsla.l;
this.brightness = this.getBrightness(newColor.hsla.l);
this.alpha = newColor.hsla.a * 100;
this.hue = newColor.hsva.h;
this.saturation = newColor.hsva.s;
this.brightness = newColor.hsva.v;
this.alpha = newColor.hsva.a * 100;
this.syncValues();
} else {
this.inputValue = oldValue;
this.inputValue = oldValue ?? '';
}
}
}
if (this.value !== this.lastValueEmitted) {
this.lastValueEmitted = this.value;
/** 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(
`hsva(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha / 100})`
);
if (currentColor === null) {
return '';
}
switch (format) {
case 'hex':
return currentColor.hex;
case 'hexa':
return currentColor.hexa;
case 'rgb':
return currentColor.rgb.string;
case 'rgba':
return currentColor.rgba.string;
case 'hsl':
return currentColor.hsl.string;
case 'hsla':
return currentColor.hsla.string;
case 'hsv':
return currentColor.hsv.string;
case 'hsva':
return currentColor.hsva.string;
default:
return '';
}
}
/** 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.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 });
if (!this.disabled) {
// By standards we have to emit a `sl-invalid` event here synchronously.
this.formControlController.emitInvalidEvent();
}
return false;
}
return this.input.reportValidity();
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
const gridHandleX = this.saturation;
const gridHandleY = 100 - this.brightness;
const swatches = Array.isArray(this.swatches)
? this.swatches // allow arrays for legacy purposes
: this.swatches.split(';').filter(color => color.trim() !== '');
const colorPicker = html`
<div
@@ -728,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"
@@ -745,7 +824,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
<div
part="grid"
class="color-picker__grid"
style=${styleMap({ backgroundColor: `hsl(${this.hue}deg, 100%, 50%)` })}
style=${styleMap({ backgroundColor: this.getHexString(this.hue, 100, 100) })}
@pointerdown=${this.handleGridDrag}
@touchmove=${this.handleTouchMove}
>
@@ -758,10 +837,10 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
style=${styleMap({
top: `${gridHandleY}%`,
left: `${gridHandleX}%`,
backgroundColor: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%)`
backgroundColor: this.getHexString(this.hue, this.saturation, this.brightness, this.alpha)
})}
role="application"
aria-label="HSL"
aria-label="HSV"
tabindex=${ifDefined(this.disabled ? undefined : '0')}
@keydown=${this.handleGridKeyDown}
></span>
@@ -776,7 +855,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
@touchmove=${this.handleTouchMove}
>
<span
part="slider-handle"
part="slider-handle hue-slider-handle"
class="color-picker__slider-handle"
style=${styleMap({
left: `${this.hue === 0 ? 0 : 100 / (360 / this.hue)}%`
@@ -805,13 +884,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
style=${styleMap({
backgroundImage: `linear-gradient(
to right,
hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, 0%) 0%,
hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%) 100%
${this.getHexString(this.hue, this.saturation, this.brightness, 0)} 0%
${this.getHexString(this.hue, this.saturation, this.brightness, 100)} 100%
)`
})}
></div>
<span
part="slider-handle"
part="slider-handle opacity-slider-handle"
class="color-picker__slider-handle"
style=${styleMap({
left: `${this.alpha}%`
@@ -836,7 +915,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
class="color-picker__preview color-picker__transparent-bg"
aria-label=${this.localize.term('copy')}
style=${styleMap({
'--preview-color': `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
'--preview-color': this.getHexString(this.hue, this.saturation, this.brightness, this.alpha)
})}
@click=${this.handleCopy}
></button>
@@ -852,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>
@@ -873,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>
@@ -890,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"
@@ -902,10 +989,18 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
</sl-button-group>
</div>
${this.swatches.length > 0
${swatches.length > 0
? html`
<div part="swatches" class="color-picker__swatches">
${this.swatches.map(swatch => {
${swatches.map(swatch => {
const parsedColor = this.parseColor(swatch);
// If we can't parse it, skip it
if (!parsedColor) {
console.error(`Unable to parse swatch color: "${swatch}"`, this);
return '';
}
return html`
<div
part="swatch"
@@ -915,9 +1010,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
aria-label=${swatch}
@click=${() => this.selectSwatch(swatch)}
@keydown=${(event: KeyboardEvent) =>
!this.disabled && event.key === 'Enter' && this.setColor(swatch)}
!this.disabled && event.key === 'Enter' && this.setColor(parsedColor.hexa)}
>
<div class="color-picker__swatch-color" style=${styleMap({ backgroundColor: swatch })}></div>
<div
class="color-picker__swatch-color"
style=${styleMap({ backgroundColor: parsedColor.hexa })}
></div>
</div>
`;
})}
@@ -952,10 +1050,11 @@ 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({
color: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
color: this.getHexString(this.hue, this.saturation, this.brightness, this.alpha)
})}
type="button"
>

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;
@@ -183,7 +185,7 @@ describe('<sl-details>', () => {
await first.show();
await second.show();
expect(firstBody.clientHeight).to.equal(200);
expect(secondBody.clientHeight).to.equal(400);
expect(firstBody.clientHeight).to.equal(232); // 200 + 16px + 16px (vertical padding)
expect(secondBody.clientHeight).to.equal(432); // 400 + 16px + 16px (vertical padding)
});
});

View File

@@ -1,21 +1,21 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate';
import { waitForEvent } from '../../internal/event';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { LocalizeController } from '../../utilities/localize';
import '../icon/icon';
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './details.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Details show a brief summary and expand to show additional content.
*
* @since 2.0
* @documentation https://shoelace.style/components/details
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
@@ -42,13 +42,13 @@ import type { CSSResultGroup } from 'lit';
export default class SlDetails extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.details') details: HTMLElement;
@query('.details__header') header: HTMLElement;
@query('.details__body') body: HTMLElement;
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
private readonly localize = new LocalizeController(this);
/**
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
@@ -66,27 +66,7 @@ export default class SlDetails extends ShoelaceElement {
this.body.style.height = this.open ? 'auto' : '0';
}
/** Shows the details. */
async show() {
if (this.open || this.disabled) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the details */
async hide() {
if (!this.open || this.disabled) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
handleSummaryClick() {
private handleSummaryClick() {
if (!this.disabled) {
if (this.open) {
this.hide();
@@ -98,7 +78,7 @@ export default class SlDetails extends ShoelaceElement {
}
}
handleSummaryKeyDown(event: KeyboardEvent) {
private handleSummaryKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
@@ -157,6 +137,26 @@ export default class SlDetails extends ShoelaceElement {
}
}
/** Shows the details. */
async show() {
if (this.open || this.disabled) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the details */
async hide() {
if (!this.open || this.disabled) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
const isRtl = this.localize.dir() === 'rtl';

View File

@@ -1,30 +1,31 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { animateTo, stopAnimations } from '../../internal/animate';
import { waitForEvent } from '../../internal/event';
import Modal from '../../internal/modal';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import ShoelaceElement from '../../internal/shoelace-element';
import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { LocalizeController } from '../../utilities/localize';
import '../icon-button/icon-button';
import { animateTo, stopAnimations } from '../../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import Modal from '../../internal/modal';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './dialog.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Dialogs, sometimes called "modals", appear above the page and require the user's immediate attention.
*
* @since 2.0
* @documentation https://shoelace.style/components/dialog
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
* @slot - The dialog's main content.
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
*
* @event sl-show - Emitted when the dialog opens.
@@ -64,15 +65,15 @@ import type { CSSResultGroup } from 'lit';
export default class SlDialog extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal: Modal;
private originalTrigger: HTMLElement | null;
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
/**
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
@@ -112,26 +113,6 @@ export default class SlDialog extends ShoelaceElement {
unlockBodyScrolling(this);
}
/** Shows the dialog. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dialog */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
const slRequestClose = this.emit('sl-request-close', {
cancelable: true,
@@ -147,15 +128,15 @@ export default class SlDialog extends ShoelaceElement {
this.hide();
}
addOpenListeners() {
private addOpenListeners() {
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
removeOpenListeners() {
private removeOpenListeners() {
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
handleDocumentKeyDown(event: KeyboardEvent) {
private handleDocumentKeyDown(event: KeyboardEvent) {
if (this.open && event.key === 'Escape') {
event.stopPropagation();
this.requestClose('keyboard');
@@ -254,6 +235,26 @@ export default class SlDialog extends ShoelaceElement {
}
}
/** Shows the dialog. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dialog */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div

View File

@@ -1,14 +1,14 @@
import { customElement, property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './divider.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Dividers are used to visually separate or group elements.
*
* @since 2.0
* @documentation https://shoelace.style/components/divider
* @status stable
* @since 2.0
*
* @cssproperty --color - The color of the divider.
* @cssproperty --width - The width of the divider.

View File

@@ -1,31 +1,32 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { animateTo, stopAnimations } from '../../internal/animate';
import { waitForEvent } from '../../internal/event';
import Modal from '../../internal/modal';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import ShoelaceElement from '../../internal/shoelace-element';
import { HasSlotController } from '../../internal/slot';
import { uppercaseFirstLetter } from '../../internal/string';
import { watch } from '../../internal/watch';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { LocalizeController } from '../../utilities/localize';
import '../icon-button/icon-button';
import { animateTo, stopAnimations } from '../../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { uppercaseFirstLetter } from '../../internal/string';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import Modal from '../../internal/modal';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './drawer.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Drawers slide in from a container to expose additional options and information.
*
* @since 2.0
* @documentation https://shoelace.style/components/drawer
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
* @slot - The drawer's main content.
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
*
* @event sl-show - Emitted when the drawer opens.
@@ -72,15 +73,15 @@ import type { CSSResultGroup } from 'lit';
export default class SlDrawer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal: Modal;
private originalTrigger: HTMLElement | null;
@query('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
/**
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
@@ -132,26 +133,6 @@ export default class SlDrawer extends ShoelaceElement {
unlockBodyScrolling(this);
}
/** Shows the drawer. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the drawer */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
const slRequestClose = this.emit('sl-request-close', {
cancelable: true,
@@ -167,15 +148,15 @@ export default class SlDrawer extends ShoelaceElement {
this.hide();
}
addOpenListeners() {
private addOpenListeners() {
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
removeOpenListeners() {
private removeOpenListeners() {
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
handleDocumentKeyDown(event: KeyboardEvent) {
private handleDocumentKeyDown(event: KeyboardEvent) {
if (this.open && !this.contained && event.key === 'Escape') {
event.stopPropagation();
this.requestClose('keyboard');
@@ -296,6 +277,26 @@ export default class SlDrawer extends ShoelaceElement {
}
}
/** Shows the drawer. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the drawer */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div

View File

@@ -36,7 +36,6 @@ export default css`
font-family: var(--sl-font-sans);
font-size: var(--sl-font-size-medium);
font-weight: var(--sl-font-weight-normal);
color: var(--color);
box-shadow: var(--sl-shadow-large);
border-radius: var(--sl-border-radius-medium);
pointer-events: none;

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

@@ -1,28 +1,27 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { animateTo, stopAnimations } from '../../internal/animate';
import { waitForEvent } from '../../internal/event';
import { scrollIntoView } from '../../internal/scroll';
import ShoelaceElement from '../../internal/shoelace-element';
import { getTabbableBoundary } from '../../internal/tabbable';
import { watch } from '../../internal/watch';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { LocalizeController } from '../../utilities/localize';
import '../popup/popup';
import { animateTo, stopAnimations } from '../../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { getTabbableBoundary } from '../../internal/tabbable';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './dropdown.styles';
import type { CSSResultGroup } from 'lit';
import type SlButton from '../button/button';
import type SlIconButton from '../icon-button/icon-button';
import type SlMenuItem from '../menu-item/menu-item';
import type SlMenu from '../menu/menu';
import type SlPopup from '../popup/popup';
import type { CSSResultGroup } from 'lit';
import type SlSelectEvent from '../../events/sl-select';
/**
* @summary Dropdowns expose additional content that "drops down" in a panel.
*
* @since 2.0
* @documentation https://shoelace.style/components/dropdown
* @status stable
* @since 2.0
*
* @dependency sl-popup
*
@@ -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();
@@ -266,12 +260,6 @@ export default class SlDropdown extends ShoelaceElement {
});
}
}
// Other keys bring focus to the menu and initiate type-to-select behavior
const ignoredKeys = ['Tab', 'Shift', 'Meta', 'Ctrl', 'Alt'];
if (this.open && !ignoredKeys.includes(event.key)) {
menu.typeToSelect(event);
}
}
}
@@ -347,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);
@@ -356,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,4 +1,4 @@
import { expect, fixture, html, elementUpdated } from '@open-wc/testing';
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import type SlFormatBytes from './format-bytes';
describe('<sl-format-bytes>', () => {

View File

@@ -1,12 +1,12 @@
import { customElement, property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { LocalizeController } from '../../utilities/localize';
import ShoelaceElement from '../../internal/shoelace-element';
/**
* @summary Formats a number as a human readable bytes value.
*
* @since 2.0
* @documentation https://shoelace.style/components/format-bytes
* @status stable
* @since 2.0
*/
@customElement('sl-format-bytes')
export default class SlFormatBytes extends ShoelaceElement {

View File

@@ -1,13 +1,13 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import ShoelaceElement from '../../internal/shoelace-element';
/**
* @summary Formats a date/time using the specified locale and options.
*
* @since 2.0
* @documentation https://shoelace.style/components/format-date
* @status stable
* @since 2.0
*/
@customElement('sl-format-date')
export default class SlFormatDate extends ShoelaceElement {

View File

@@ -1,12 +1,12 @@
import { customElement, property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { LocalizeController } from '../../utilities/localize';
import ShoelaceElement from '../../internal/shoelace-element';
/**
* @summary Formats a number using the specified locale and options.
*
* @since 2.0
* @documentation https://shoelace.style/components/format-number
* @status stable
* @since 2.0
*/
@customElement('sl-format-number')
export default class SlFormatNumber extends ShoelaceElement {

View File

@@ -20,7 +20,7 @@ export default css`
color: inherit;
padding: var(--sl-spacing-x-small);
cursor: pointer;
transition: var(--sl-transition-medium) color;
transition: var(--sl-transition-x-fast) color;
-webkit-appearance: none;
}

View File

@@ -1,17 +1,17 @@
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { html, literal } from 'lit/static-html.js';
import ShoelaceElement from '../../internal/shoelace-element';
import '../icon/icon';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html, literal } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './icon-button.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
*
* @since 2.0
* @documentation https://shoelace.style/components/icon-button
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
@@ -24,10 +24,10 @@ import type { CSSResultGroup } from 'lit';
export default class SlIconButton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@state() private hasFocus = false;
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property() name?: string;
@@ -58,6 +58,23 @@ export default class SlIconButton extends ShoelaceElement {
/** Disables the button. */
@property({ type: Boolean, reflect: true }) disabled = false;
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
/** Simulates a click on the icon button. */
click() {
this.button.click();
@@ -73,23 +90,6 @@ export default class SlIconButton extends ShoelaceElement {
this.button.blur();
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
render() {
const isLink = this.href ? true : false;
const tag = isLink ? literal`a` : literal`button`;

View File

@@ -8,7 +8,6 @@ export default css`
display: inline-block;
width: 1em;
height: 1em;
contain: strict;
box-sizing: content-box !important;
}

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

@@ -1,23 +1,25 @@
import { html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import styles from './icon.styles';
import { getIconLibrary, unwatchIcon, watchIcon } from './library';
import { html } from 'lit';
import { requestIcon } from './request';
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './icon.styles';
import type { CSSResultGroup } from 'lit';
let parser: DOMParser;
/**
* @summary Icons are symbols that can be used to represent various options within an application.
*
* @since 2.0
* @documentation https://shoelace.style/components/icon
* @status stable
* @since 2.0
*
* @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 {
@@ -65,11 +67,6 @@ export default class SlIcon extends ShoelaceElement {
return this.src;
}
// Fetches the icon and redraws it. Used to handle library registrations.
redraw() {
this.setIcon();
}
@watch('label')
handleLabelChange() {
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
@@ -85,9 +82,7 @@ export default class SlIcon extends ShoelaceElement {
}
}
@watch('name')
@watch('src')
@watch('library')
@watch(['name', 'src', 'library'])
async setIcon() {
const library = getIconLibrary(this.library);
const url = this.getUrl();
@@ -109,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');
@@ -129,10 +125,6 @@ export default class SlIcon extends ShoelaceElement {
}
}
handleChange() {
this.setIcon();
}
render() {
return html` ${unsafeSVG(this.svg)} `;
}

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

@@ -43,7 +43,7 @@ export function registerIconLibrary(
// Redraw watched icons
watchedIcons.forEach(icon => {
if (icon.library === name) {
icon.redraw();
icon.setIcon();
}
});
}

View File

@@ -1,21 +1,21 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { drag } from '../../internal/drag';
import { clamp } from '../../internal/math';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import { LocalizeController } from '../../utilities/localize';
import '../icon/icon';
import { clamp } from '../../internal/math';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { drag } from '../../internal/drag';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './image-comparer.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Compare visual differences between similar photos with a sliding panel.
*
* @since 2.0
* @documentation https://shoelace.style/components/image-comparer
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
@@ -38,15 +38,15 @@ import type { CSSResultGroup } from 'lit';
export default class SlImageComparer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
private readonly localize = new LocalizeController(this);
/** The position of the divider as a percentage. */
@property({ type: Number, reflect: true }) position = 50;
handleDrag(event: PointerEvent) {
private handleDrag(event: PointerEvent) {
const { width } = this.base.getBoundingClientRect();
const isRtl = this.localize.dir() === 'rtl';
@@ -61,7 +61,7 @@ export default class SlImageComparer extends ShoelaceElement {
});
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';

View File

@@ -1,4 +1,4 @@
import { expect, fixture, html, waitUntil, aTimeout } from '@open-wc/testing';
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import type SlInclude from './include';

View File

@@ -1,16 +1,16 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import styles from './include.styles';
import { html } from 'lit';
import { requestInclude } from './request';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './include.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Includes give you the power to embed external HTML files into the page.
*
* @since 2.0
* @documentation https://shoelace.style/components/include
* @status stable
* @since 2.0
*
* @event sl-load - Emitted when the included file is loaded.
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
@@ -34,7 +34,7 @@ export default class SlInclude extends ShoelaceElement {
*/
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
executeScript(script: HTMLScriptElement) {
private executeScript(script: HTMLScriptElement) {
// Create a copy of the script and swap it out so the browser executes it
const newScript = document.createElement('script');
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));

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 {

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