mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-19 15:34:15 +00:00
Compare commits
218 Commits
v2.0.0-bet
...
autoload
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7240f4f8f4 | ||
|
|
17ee89a5e8 | ||
|
|
f2177dccaf | ||
|
|
6aaf17b81a | ||
|
|
d113d13792 | ||
|
|
ab9cb5f185 | ||
|
|
76fd7aa28d | ||
|
|
8f17bf4e9d | ||
|
|
0f0f71af9b | ||
|
|
e624701022 | ||
|
|
4cedfc3201 | ||
|
|
d88d9fc81a | ||
|
|
051baa4ff5 | ||
|
|
57c3d7009b | ||
|
|
a27fd4d2e9 | ||
|
|
79ac425e2b | ||
|
|
857f318f9c | ||
|
|
c0966bf767 | ||
|
|
86cecc9e30 | ||
|
|
ec036d8e61 | ||
|
|
77b25f4581 | ||
|
|
a8d59b3329 | ||
|
|
5990fbd000 | ||
|
|
954d78dcd1 | ||
|
|
3ea31389dd | ||
|
|
d79799043a | ||
|
|
9f8ce58288 | ||
|
|
2371c5490f | ||
|
|
77abd42d66 | ||
|
|
218e78e947 | ||
|
|
8a1efac9b8 | ||
|
|
f9ae8327f6 | ||
|
|
7f3076d195 | ||
|
|
1fa79e64ae | ||
|
|
dde1010465 | ||
|
|
3a3a7347bc | ||
|
|
77c9750206 | ||
|
|
3d2e618be8 | ||
|
|
79feaae7fc | ||
|
|
b0f7dfb86b | ||
|
|
e1979b8f38 | ||
|
|
7e9ae32b9b | ||
|
|
480a1df246 | ||
|
|
ff798adb49 | ||
|
|
70a64262e9 | ||
|
|
5f65896150 | ||
|
|
c69db4919b | ||
|
|
a526e8a956 | ||
|
|
4970ba065e | ||
|
|
0292ed30c5 | ||
|
|
b64b1c2536 | ||
|
|
8f9eb012ba | ||
|
|
c8fd9f19d2 | ||
|
|
603aa93322 | ||
|
|
74203de094 | ||
|
|
4fa4682a45 | ||
|
|
34e0fb2fc1 | ||
|
|
50972f2b38 | ||
|
|
652ce6c9f1 | ||
|
|
8412b150b2 | ||
|
|
22b8ef4edf | ||
|
|
0865dede6f | ||
|
|
d638d811ad | ||
|
|
bc58472b7b | ||
|
|
226c856b1e | ||
|
|
a127b8722e | ||
|
|
9c573fb454 | ||
|
|
a346d18930 | ||
|
|
a32488baeb | ||
|
|
a4131caeda | ||
|
|
6c62a4f4c0 | ||
|
|
5b12de1edf | ||
|
|
f79a670ca3 | ||
|
|
e1ec60af62 | ||
|
|
dcbcc4c050 | ||
|
|
0eb3375bb9 | ||
|
|
c26a8810c8 | ||
|
|
872227e345 | ||
|
|
f22c529eab | ||
|
|
3430b33c3e | ||
|
|
1bc2a6ef76 | ||
|
|
5a94f5bf5b | ||
|
|
4277377189 | ||
|
|
d818980dea | ||
|
|
636f61006f | ||
|
|
d93e698baf | ||
|
|
f8d8291caa | ||
|
|
21bef1c2ea | ||
|
|
f0efb9253c | ||
|
|
cfd28f2608 | ||
|
|
a3844fe074 | ||
|
|
65e90f12f4 | ||
|
|
4335289d6a | ||
|
|
86cc721e03 | ||
|
|
4a28825ea7 | ||
|
|
19cf823da5 | ||
|
|
737b55d78d | ||
|
|
8493131db5 | ||
|
|
0d86c2af37 | ||
|
|
d6a7820a52 | ||
|
|
39ca1208f5 | ||
|
|
610a06bcb9 | ||
|
|
b8584c0581 | ||
|
|
ab19afeb66 | ||
|
|
41b5cb367f | ||
|
|
e65b09fdec | ||
|
|
15a4049a01 | ||
|
|
ce708fbba8 | ||
|
|
75bd7784fb | ||
|
|
6e092ccf7a | ||
|
|
b7b73ea3a9 | ||
|
|
9dab91e0d1 | ||
|
|
a3a802a37b | ||
|
|
358ad7bb30 | ||
|
|
0a555c53c7 | ||
|
|
b260a4dc29 | ||
|
|
1f1024f4ca | ||
|
|
9e92d92684 | ||
|
|
527bf79973 | ||
|
|
b281c5bbc1 | ||
|
|
f03de8925b | ||
|
|
776ab2c715 | ||
|
|
df967b7e84 | ||
|
|
a539058253 | ||
|
|
af70d88153 | ||
|
|
8dcffe270f | ||
|
|
c958f2e50a | ||
|
|
cedcd65c72 | ||
|
|
12f62075ad | ||
|
|
b8695b70a9 | ||
|
|
a4e371618a | ||
|
|
039ab175c3 | ||
|
|
7549e50fe4 | ||
|
|
3c2cda699e | ||
|
|
8685ddd049 | ||
|
|
c47ad40802 | ||
|
|
ef1f129b22 | ||
|
|
6bb508ef14 | ||
|
|
3596c8144d | ||
|
|
20903bb638 | ||
|
|
f45fb6848f | ||
|
|
400f9b76d5 | ||
|
|
38a9e98d9b | ||
|
|
e8fe783fb4 | ||
|
|
223ef32b70 | ||
|
|
0793a219a2 | ||
|
|
3bb92c095f | ||
|
|
deec097267 | ||
|
|
873e280700 | ||
|
|
5d047f7a93 | ||
|
|
f24ab23752 | ||
|
|
44ecc8ce56 | ||
|
|
e758b1d9bb | ||
|
|
5cdbaa873d | ||
|
|
e9aca6cedb | ||
|
|
7c3896ed42 | ||
|
|
93158e8e90 | ||
|
|
5f9bbdfa06 | ||
|
|
3a0f486e98 | ||
|
|
29c671c0f4 | ||
|
|
88c4bef5e7 | ||
|
|
6066bc468b | ||
|
|
efd944d822 | ||
|
|
4a3f2caf59 | ||
|
|
511182b41b | ||
|
|
1088a51ed5 | ||
|
|
e3e0842bdd | ||
|
|
e4c908b08b | ||
|
|
f86578a213 | ||
|
|
3c2f5ec48e | ||
|
|
fec7ef17aa | ||
|
|
29ff99dd76 | ||
|
|
6b9b410bdc | ||
|
|
b45a9d55ca | ||
|
|
7ce079b7a1 | ||
|
|
b0ba9ff14f | ||
|
|
f665bf984b | ||
|
|
ac429a62c0 | ||
|
|
dc909d10b6 | ||
|
|
aa65077b12 | ||
|
|
7e37c51856 | ||
|
|
6e26daf804 | ||
|
|
25c2d2d5bf | ||
|
|
edc9e69f30 | ||
|
|
2e7ac38678 | ||
|
|
1a68c825c0 | ||
|
|
2cbdeeade0 | ||
|
|
79624f63ed | ||
|
|
01a8ec36ec | ||
|
|
60324885ed | ||
|
|
0c2f43b837 | ||
|
|
0df27cf730 | ||
|
|
68ed69292c | ||
|
|
62c58b3a8c | ||
|
|
1fbb809057 | ||
|
|
e2d2f5d670 | ||
|
|
acef0da2c1 | ||
|
|
3c66d2ab99 | ||
|
|
31f16c4680 | ||
|
|
8056379fdd | ||
|
|
9a6b9a7841 | ||
|
|
02fc39ebe0 | ||
|
|
a90b22c05d | ||
|
|
f5dd4f2aca | ||
|
|
d5b3489b22 | ||
|
|
8ee5f19184 | ||
|
|
d0a32d48b1 | ||
|
|
bf527437a0 | ||
|
|
ae3070ac45 | ||
|
|
6af68343a7 | ||
|
|
48ccc95dd9 | ||
|
|
c6a6a77bbd | ||
|
|
e632b51eb8 | ||
|
|
c8e633c4a1 | ||
|
|
724f4a59db | ||
|
|
041364fb7d | ||
|
|
fbcb4d8dbd | ||
|
|
27a6b4a8c9 |
@@ -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'
|
||||
},
|
||||
@@ -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'
|
||||
|
||||
2
.github/workflows/node.js.yml
vendored
2
.github/workflows/node.js.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -3,6 +3,5 @@
|
||||
docs/dist
|
||||
docs/search.json
|
||||
dist
|
||||
examples
|
||||
node_modules
|
||||
src/react
|
||||
|
||||
16
cspell.json
16
cspell.json
@@ -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"
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
@@ -92,6 +94,7 @@
|
||||
- [Border Radius](/tokens/border-radius)
|
||||
- [Transition](/tokens/transition)
|
||||
- [Z-index](/tokens/z-index)
|
||||
- [More](/tokens/more)
|
||||
|
||||
- Tutorials
|
||||
|
||||
|
||||
BIN
docs/assets/examples/carousel/field.jpg
Normal file
BIN
docs/assets/examples/carousel/field.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/assets/examples/carousel/mountains.jpg
Normal file
BIN
docs/assets/examples/carousel/mountains.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/assets/examples/carousel/sunset.jpg
Normal file
BIN
docs/assets/examples/carousel/sunset.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/assets/examples/carousel/valley.jpg
Normal file
BIN
docs/assets/examples/carousel/valley.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/assets/examples/carousel/waterfall.jpg
Normal file
BIN
docs/assets/examples/carousel/waterfall.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@@ -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);
|
||||
|
||||
81
docs/components/carousel-item.md
Normal file
81
docs/components/carousel-item.md
Normal 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
1221
docs/components/carousel.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const App = () => (
|
||||
|
||||
### Getting the Selected Item
|
||||
|
||||
When dropdowns are used with [menus](/components/menu), you can listen for the `sl-select` event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
|
||||
When dropdowns are used with [menus](/components/menu), you can listen for the [`sl-select`](/components/menu#events) event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
|
||||
|
||||
```html preview
|
||||
<div class="dropdown-selection">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ Use [CSS parts](#css-parts) to customize the way form controls are drawn. This e
|
||||
|
||||
<style>
|
||||
.label-on-left {
|
||||
--label-width: 60px;
|
||||
--label-width: 3.75rem;
|
||||
--gap-width: 1rem;
|
||||
}
|
||||
|
||||
@@ -267,8 +267,7 @@ Use [CSS parts](#css-parts) to customize the way form controls are drawn. This e
|
||||
}
|
||||
|
||||
.label-on-left::part(form-control-help-text) {
|
||||
grid-column: span 2;
|
||||
padding-left: calc(var(--label-width) + var(--gap-width));
|
||||
grid-column-start: 2;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -295,13 +295,15 @@ This example demonstrates custom validation styles using `data-user-invalid` and
|
||||
required
|
||||
></sl-input>
|
||||
|
||||
<sl-select label="Favorite Animal" help-text="Select the best option." clearable required>
|
||||
<sl-select name="animal" label="Favorite Animal" help-text="Select the best option." clearable required>
|
||||
<sl-option value="birds">Birds</sl-option>
|
||||
<sl-option value="cats">Cats</sl-option>
|
||||
<sl-option value="dogs">Dogs</sl-option>
|
||||
<sl-option value="other">Other</sl-option>
|
||||
</sl-select>
|
||||
|
||||
<sl-checkbox value="accept" required>Accept terms and conditions</sl-checkbox>
|
||||
|
||||
<sl-button type="submit" variant="primary">Submit</sl-button>
|
||||
<sl-button type="reset" variant="default">Reset</sl-button>
|
||||
</form>
|
||||
@@ -316,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(combobox) {
|
||||
.validity-styles sl-select[data-user-invalid]::part(combobox),
|
||||
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
}
|
||||
|
||||
.validity-styles [data-user-invalid]::part(form-control-label),
|
||||
.validity-styles [data-user-invalid]::part(form-control-help-text) {
|
||||
.validity-styles [data-user-invalid]::part(form-control-help-text),
|
||||
.validity-styles sl-checkbox[data-user-invalid]::part(label) {
|
||||
color: var(--sl-color-danger-700);
|
||||
}
|
||||
|
||||
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.validity-styles sl-input:focus-within[data-user-invalid]::part(base),
|
||||
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox) {
|
||||
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox),
|
||||
.validity-styles sl-checkbox:focus-within[data-user-invalid]::part(control) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
||||
}
|
||||
|
||||
/* User valid styles */
|
||||
.validity-styles sl-input[data-user-valid]::part(base),
|
||||
.validity-styles sl-select[data-user-valid]::part(combobox) {
|
||||
.validity-styles sl-select[data-user-valid]::part(combobox),
|
||||
.validity-styles sl-checkbox[data-user-valid]::part(control) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
}
|
||||
|
||||
.validity-styles [data-user-valid]::part(form-control-label),
|
||||
.validity-styles [data-user-valid]::part(form-control-help-text) {
|
||||
.validity-styles [data-user-valid]::part(form-control-help-text),
|
||||
.validity-styles sl-checkbox[data-user-valid]::part(label) {
|
||||
color: var(--sl-color-success-700);
|
||||
}
|
||||
|
||||
.validity-styles sl-checkbox[data-user-valid]::part(control) {
|
||||
background-color: var(--sl-color-success-600);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.validity-styles sl-input:focus-within[data-user-valid]::part(base),
|
||||
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox) {
|
||||
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox),
|
||||
.validity-styles sl-checkbox:focus-within[data-user-valid]::part(control) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Inline Form Validation
|
||||
|
||||
By default, Shoelace form controls use the browser's tooltip-style error messages. No mechanism is provided to show errors inline, as there are too many opinions on how that would work when combined with native form controls and other custom elements. You can, however, implement your own solution using the following technique.
|
||||
|
||||
To disable the browser's error messages, you need to cancel the `sl-invalid` event. Then you can apply your own inline validation errors. This example demonstrates a primitive way to do this.
|
||||
|
||||
```html preview
|
||||
<form class="inline-validation">
|
||||
<sl-input
|
||||
name="name"
|
||||
label="Name"
|
||||
help-text="What would you like people to call you?"
|
||||
autocomplete="off"
|
||||
required
|
||||
></sl-input>
|
||||
|
||||
<div id="name-error" aria-live="polite" hidden></div>
|
||||
|
||||
<sl-button type="submit" variant="primary">Submit</sl-button>
|
||||
<sl-button type="reset" variant="default">Reset</sl-button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const form = document.querySelector('.inline-validation');
|
||||
const nameError = document.querySelector('#name-error');
|
||||
|
||||
// A form control is invalid
|
||||
form.addEventListener(
|
||||
'sl-invalid',
|
||||
event => {
|
||||
// Suppress the browser's constraint validation message
|
||||
event.preventDefault();
|
||||
|
||||
nameError.textContent = `Error: ${event.target.validationMessage}`;
|
||||
nameError.hidden = false;
|
||||
|
||||
event.target.focus();
|
||||
},
|
||||
{ capture: true } // you must use capture since sl-invalid doesn't bubble!
|
||||
);
|
||||
|
||||
// Handle form submit
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
setTimeout(() => alert('All fields are valid'), 50);
|
||||
});
|
||||
|
||||
// Handle form reset
|
||||
form.addEventListener('reset', event => {
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#name-error {
|
||||
font-size: var(--sl-input-help-text-font-size-medium);
|
||||
color: var(--sl-color-danger-700);
|
||||
}
|
||||
|
||||
#name-error ~ sl-button {
|
||||
margin-top: var(--sl-spacing-medium);
|
||||
}
|
||||
|
||||
.inline-validation sl-input {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* user invalid styles */
|
||||
.inline-validation sl-input[data-user-invalid]::part(base) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
}
|
||||
|
||||
.inline-validation [data-user-invalid]::part(form-control-label),
|
||||
.inline-validation [data-user-invalid]::part(form-control-help-text) {
|
||||
color: var(--sl-color-danger-700);
|
||||
}
|
||||
|
||||
.inline-validation sl-input:focus-within[data-user-invalid]::part(base) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
||||
}
|
||||
|
||||
/* User valid styles */
|
||||
.inline-validation sl-input[data-user-valid]::part(base) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
}
|
||||
|
||||
.inline-validation [data-user-valid]::part(form-control-label),
|
||||
.inline-validation [data-user-valid]::part(form-control-help-text) {
|
||||
color: var(--sl-color-success-700);
|
||||
}
|
||||
|
||||
.inline-validation sl-checkbox[data-user-valid]::part(control) {
|
||||
background-color: var(--sl-color-success-600);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.inline-validation sl-input:focus-within[data-user-valid]::part(base) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
!> This example is meant to demonstrate the concept of providing your own error messages inline. It is not intended to scale to more complex forms. Users who want this functionality are encouraged to build a more appropriate validation solution using the techniques shown below. Depending on how you implement this feature, custom error messages may affect the accessibility of your form controls.
|
||||
|
||||
## Getting Associated Form Controls
|
||||
|
||||
At this time, using [`HTMLFormElement.elements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) will not return Shoelace form controls because the browser is unaware of their status as custom element form controls. Fortunately, Shoelace provides an `elements()` function that does something very similar. However, instead of returning an [`HTMLFormControlsCollection`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection), it returns an array of HTML and Shoelace form controls in the order they appear in the DOM.
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ checkbox.checked = true;
|
||||
console.log(checkbox.hasAttribute('checked')); // false
|
||||
```
|
||||
|
||||
Most devs will expect this to be `true` instead of `false`, but the component hasn't had a chance to re-render yet so the attribute doesn't exist when `hasAttribute()` is called. Since changes are batched, we need to wait for the update before proceeding. This can be done using the `updateComplete` property, which is available on all Lit-based components.
|
||||
Most developers will expect this to be `true` instead of `false`, but the component hasn't had a chance to re-render yet so the attribute doesn't exist when `hasAttribute()` is called. Since changes are batched, we need to wait for the update before proceeding. This can be done using the `updateComplete` property, which is available on all Lit-based components.
|
||||
|
||||
```js
|
||||
const checkbox = document.querySelector('sl-checkbox');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,112 @@ 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 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
|
||||
|
||||
@@ -200,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
|
||||
|
||||
@@ -331,14 +331,18 @@ This results in a consistent, easy to understand structure for parts. In this ex
|
||||
|
||||
### Dependencies
|
||||
|
||||
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 `<sl-select>` without at least one `<sl-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 user provides the Options, it's up to them to import both `<sl-select>` and `<sl-option>`.
|
||||
|
||||
It is often deemed convenient to auto-load Select + Option, Menu + Menu Item, etc. Although some components are designed to work together, they're technically not dependencies so eagerly loading them may not be desirable. For example, what if someone wants to roll their own `<sl-option>` element with a superset of features? They wouldn't be able to if Shoelace automatically imported it. The user should decide what gets registered, in this case, because the "parent" element doesn't technically require it to render.
|
||||
|
||||
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:
|
||||
|
||||
155
docs/tokens/more.md
Normal file
155
docs/tokens/more.md
Normal 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 |
|
||||
4829
package-lock.json
generated
4829
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
62
package.json
62
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@shoelace-style/shoelace",
|
||||
"description": "A forward-thinking library of web components.",
|
||||
"version": "2.0.0-beta.88",
|
||||
"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/*",
|
||||
@@ -56,67 +57,70 @@
|
||||
"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.4",
|
||||
"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.3",
|
||||
"browser-sync": "^2.27.10",
|
||||
"cem-plugin-vs-code-custom-data-generator": "^1.3.1",
|
||||
"chalk": "^5.1.2",
|
||||
"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": {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
@@ -161,16 +161,18 @@ export default class SlAnimation extends ShoelaceElement {
|
||||
}
|
||||
}
|
||||
|
||||
@watch('name')
|
||||
@watch('delay')
|
||||
@watch('direction')
|
||||
@watch('duration')
|
||||
@watch('easing')
|
||||
@watch('endDelay')
|
||||
@watch('fill')
|
||||
@watch('iterations')
|
||||
@watch('iterationsStart')
|
||||
@watch('keyframes')
|
||||
@watch([
|
||||
'name',
|
||||
'delay',
|
||||
'direction',
|
||||
'duration',
|
||||
'easing',
|
||||
'endDelay',
|
||||
'fill',
|
||||
'iterations',
|
||||
'iterationsStart',
|
||||
'keyframes'
|
||||
])
|
||||
handleAnimationChange() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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>', () => {
|
||||
|
||||
@@ -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,22 +28,22 @@ export default class SlButtonGroup extends ShoelaceElement {
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
private handleFocus(event: CustomEvent) {
|
||||
private handleFocus(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleBlur(event: CustomEvent) {
|
||||
private handleBlur(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleMouseOver(event: CustomEvent) {
|
||||
private handleMouseOver(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleMouseOut(event: CustomEvent) {
|
||||
private handleMouseOut(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +40,9 @@ import type { CSSResultGroup } from 'lit';
|
||||
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
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;
|
||||
@@ -51,7 +52,8 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
|
||||
// Fall back to the closest containing form
|
||||
return input.closest('form');
|
||||
}
|
||||
},
|
||||
assumeInteractionOn: ['click']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
@@ -114,6 +116,14 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
/** Tells the browser where to open the link. Only used when `href` is present. */
|
||||
@property() target: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/**
|
||||
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
|
||||
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
|
||||
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
|
||||
* setting the attribute to an empty string or a value of your choice, respectively.
|
||||
*/
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
|
||||
@property() download?: string;
|
||||
|
||||
@@ -139,9 +149,38 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,22 +194,29 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled || this.loading) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.type === 'submit') {
|
||||
this.formSubmitController.submit(this);
|
||||
this.formControlController.submit(this);
|
||||
}
|
||||
|
||||
if (this.type === 'reset') {
|
||||
this.formSubmitController.reset(this);
|
||||
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;
|
||||
}
|
||||
@@ -181,10 +227,9 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
|
||||
@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();
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,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();
|
||||
@@ -212,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()) {
|
||||
@@ -221,11 +271,11 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
26
src/components/carousel-item/carousel-item.styles.ts
Normal file
26
src/components/carousel-item/carousel-item.styles.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
17
src/components/carousel-item/carousel-item.test.ts
Normal file
17
src/components/carousel-item/carousel-item.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
src/components/carousel-item/carousel-item.ts
Normal file
40
src/components/carousel-item/carousel-item.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/components/carousel/autoplay-controller.ts
Normal file
73
src/components/carousel/autoplay-controller.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
160
src/components/carousel/carousel.styles.ts
Normal file
160
src/components/carousel/carousel.styles.ts
Normal 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);
|
||||
}
|
||||
`;
|
||||
588
src/components/carousel/carousel.test.ts
Normal file
588
src/components/carousel/carousel.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
468
src/components/carousel/carousel.ts
Normal file
468
src/components/carousel/carousel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
178
src/components/carousel/scroll-controller.ts
Normal file
178
src/components/carousel/scroll-controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 +40,7 @@ import type { CSSResultGroup } from 'lit';
|
||||
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
// @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)
|
||||
@@ -49,7 +49,6 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
@query('input[type="checkbox"]') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() invalid = false;
|
||||
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
@@ -65,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;
|
||||
|
||||
@@ -80,8 +76,28 @@ 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.checkValidity();
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
@@ -99,6 +115,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
@@ -106,17 +127,15 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
|
||||
@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.checkValidity();
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('checked', { waitUntilFirstUpdate: true })
|
||||
@watch('indeterminate', { waitUntilFirstUpdate: true })
|
||||
@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.invalid = !this.checkValidity();
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Simulates a click on the checkbox. */
|
||||
@@ -134,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();
|
||||
}
|
||||
@@ -150,10 +174,15 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
*/
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.invalid = !this.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"
|
||||
@@ -181,6 +210,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
@@ -206,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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>', () => {
|
||||
@@ -315,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>
|
||||
`);
|
||||
@@ -341,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 = '';
|
||||
|
||||
@@ -352,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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
// @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('[part~="preview"]') previewButton: HTMLButtonElement;
|
||||
@query('.color-dropdown') dropdown: SlDropdown;
|
||||
@query('[part~="preview"]') previewButton: HTMLButtonElement;
|
||||
@query('[part~="trigger"]') trigger: HTMLButtonElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() private isDraggingGridHandle = false;
|
||||
@state() private isEmpty = false;
|
||||
@state() private inputValue = '';
|
||||
@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
|
||||
@@ -164,23 +170,44 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
*/
|
||||
@property() swatches: string | string[] = '';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
/**
|
||||
* 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 = '';
|
||||
|
||||
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 = '';
|
||||
}
|
||||
/** 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;
|
||||
}
|
||||
|
||||
private getBrightness(lightness: number) {
|
||||
return clamp(-1 * ((200 * lightness) / (this.saturation - 200)), 0, 100);
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleFocusIn = this.handleFocusIn.bind(this);
|
||||
this.handleFocusOut = this.handleFocusOut.bind(this);
|
||||
this.addEventListener('focusin', this.handleFocusIn);
|
||||
this.addEventListener('focusout', this.handleFocusOut);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('focusin', this.handleFocusIn);
|
||||
this.removeEventListener('focusout', this.handleFocusOut);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.input.updateComplete.then(() => {
|
||||
this.formControlController.updateValidity();
|
||||
});
|
||||
}
|
||||
|
||||
private handleCopy() {
|
||||
@@ -195,6 +222,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
});
|
||||
}
|
||||
|
||||
private handleFocusIn() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleFocusOut() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFormatToggle() {
|
||||
const formats = ['hex', 'rgb', 'hsl', 'hsv'];
|
||||
const nextIndex = (formats.indexOf(this.format) + 1) % formats.length;
|
||||
@@ -267,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,28 +392,24 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -387,7 +419,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
private handleInputChange(event: CustomEvent) {
|
||||
private handleInputChange(event: SlChangeEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const oldValue = this.value;
|
||||
|
||||
@@ -407,7 +439,9 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
private handleInputInput(event: CustomEvent) {
|
||||
private handleInputInput(event: SlInputEvent) {
|
||||
this.formControlController.updateValidity();
|
||||
|
||||
// Prevent the <sl-input>'s sl-input event from bubbling up
|
||||
event.stopPropagation();
|
||||
}
|
||||
@@ -432,6 +466,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
private handleInputInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -523,11 +562,10 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
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();
|
||||
|
||||
@@ -543,7 +581,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
|
||||
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) {
|
||||
@@ -583,7 +621,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
|
||||
eyeDropper
|
||||
.open()
|
||||
.then(colorSelectionResult => this.setColor(colorSelectionResult.sRGBHex))
|
||||
.then(colorSelectionResult => {
|
||||
const oldValue = this.value;
|
||||
|
||||
this.setColor(colorSelectionResult.sRGBHex);
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
this.emit('sl-input');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The user canceled, do nothing
|
||||
});
|
||||
@@ -602,8 +649,19 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
private getLightness(brightness: number) {
|
||||
return clamp(((((200 - this.saturation) * brightness) / 100) * 5) / 10, 0, 100);
|
||||
/** 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 })
|
||||
@@ -622,35 +680,57 @@ 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(
|
||||
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
|
||||
`hsva(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha / 100})`
|
||||
);
|
||||
|
||||
if (currentColor === null) {
|
||||
@@ -679,27 +759,38 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
if (!this.inline && this.input.invalid) {
|
||||
if (!this.inline && !this.validity.valid) {
|
||||
// If the input is inline and invalid, show the dropdown so the browser can focus on it
|
||||
this.dropdown.show();
|
||||
this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true });
|
||||
return this.checkValidity();
|
||||
|
||||
if (!this.disabled) {
|
||||
// By standards we have to emit a `sl-invalid` event here synchronously.
|
||||
this.formControlController.emitInvalidEvent();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.invalid = this.input.invalid;
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -707,7 +798,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
const gridHandleY = 100 - this.brightness;
|
||||
const swatches = Array.isArray(this.swatches)
|
||||
? this.swatches // allow arrays for legacy purposes
|
||||
: this.swatches.split(';').filter(color => color !== '');
|
||||
: this.swatches.split(';').filter(color => color.trim() !== '');
|
||||
|
||||
const colorPicker = html`
|
||||
<div
|
||||
@@ -715,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"
|
||||
@@ -732,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}
|
||||
>
|
||||
@@ -745,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>
|
||||
@@ -763,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)}%`
|
||||
@@ -792,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}%`
|
||||
@@ -823,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>
|
||||
@@ -839,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>
|
||||
@@ -860,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>
|
||||
@@ -877,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"
|
||||
@@ -950,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"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -341,7 +335,6 @@ export default class SlDropdown extends ShoelaceElement {
|
||||
}
|
||||
|
||||
addOpenListeners() {
|
||||
this.panel.addEventListener('sl-activate', this.handleMenuItemActivate);
|
||||
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
@@ -350,7 +343,6 @@ export default class SlDropdown extends ShoelaceElement {
|
||||
|
||||
removeOpenListeners() {
|
||||
if (this.panel) {
|
||||
this.panel.removeEventListener('sl-activate', this.handleMenuItemActivate);
|
||||
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
|
||||
@@ -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>', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -8,7 +8,6 @@ export default css`
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
contain: strict;
|
||||
box-sizing: content-box !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -80,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();
|
||||
@@ -104,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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
||||
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import { getFormControls } from '../../../dist/utilities/form.js';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands'; // must come from the same module
|
||||
import { serialize } from '../../utilities/form';
|
||||
import sinon from 'sinon';
|
||||
import type SlInput from './input';
|
||||
|
||||
describe('<sl-input>', () => {
|
||||
@@ -85,42 +88,93 @@ describe('<sl-input>', () => {
|
||||
it('should focus the input when clicking on the label', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input label="Name"></sl-input> `);
|
||||
const label = el.shadowRoot!.querySelector('[part~="form-control-label"]')!;
|
||||
const submitHandler = sinon.spy();
|
||||
const focusHandler = sinon.spy();
|
||||
|
||||
el.addEventListener('sl-focus', submitHandler);
|
||||
el.addEventListener('sl-focus', focusHandler);
|
||||
(label as HTMLLabelElement).click();
|
||||
await waitUntil(() => submitHandler.calledOnce);
|
||||
await waitUntil(() => focusHandler.calledOnce);
|
||||
|
||||
expect(submitHandler).to.have.been.calledOnce;
|
||||
expect(focusHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
describe('when using constraint validation', () => {
|
||||
it('should be valid by default', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input></sl-input> `);
|
||||
expect(el.invalid).to.be.false;
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
});
|
||||
|
||||
it('should be invalid when required and empty', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input required></sl-input> `);
|
||||
expect(el.reportValidity()).to.be.false;
|
||||
expect(el.invalid).to.be.true;
|
||||
});
|
||||
|
||||
it('should be invalid when the pattern does not match', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input pattern="^test" value="fail"></sl-input> `);
|
||||
expect(el.invalid).to.be.true;
|
||||
expect(el.reportValidity()).to.be.false;
|
||||
expect(el.checkValidity()).to.be.false;
|
||||
});
|
||||
|
||||
it('should be invalid when required and disabled is removed', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input disabled required></sl-input> `);
|
||||
el.disabled = false;
|
||||
await el.updateComplete;
|
||||
expect(el.invalid).to.be.true;
|
||||
expect(el.checkValidity()).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive the correct validation attributes ("states") when valid', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input required value="a"></sl-input> `);
|
||||
|
||||
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;
|
||||
|
||||
el.focus();
|
||||
await el.updateComplete;
|
||||
await sendKeys({ press: 'b' });
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.true;
|
||||
});
|
||||
|
||||
it('should receive the correct validation attributes ("states") when invalid', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input required></sl-input> `);
|
||||
|
||||
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;
|
||||
|
||||
el.focus();
|
||||
await el.updateComplete;
|
||||
await sendKeys({ press: 'a' });
|
||||
await sendKeys({ press: 'Backspace' });
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-input required></sl-input></form> `);
|
||||
const input = el.querySelector<SlInput>('sl-input')!;
|
||||
|
||||
expect(input.hasAttribute('data-required')).to.be.true;
|
||||
expect(input.hasAttribute('data-optional')).to.be.false;
|
||||
expect(input.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(input.hasAttribute('data-valid')).to.be.false;
|
||||
expect(input.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(input.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when serializing', () => {
|
||||
describe('when submitting a form', () => {
|
||||
it('should serialize its name and value with FormData', async () => {
|
||||
const form = await fixture<HTMLFormElement>(html` <form><sl-input name="a" value="1"></sl-input></form> `);
|
||||
const formData = new FormData(form);
|
||||
@@ -132,9 +186,7 @@ describe('<sl-input>', () => {
|
||||
const json = serialize(form);
|
||||
expect(json.a).to.equal('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
it('should submit the form when pressing enter in a form without a submit button', async () => {
|
||||
const form = await fixture<HTMLFormElement>(html` <form><sl-input></sl-input></form> `);
|
||||
const input = form.querySelector('sl-input')!;
|
||||
@@ -167,6 +219,43 @@ describe('<sl-input>', () => {
|
||||
expect(keydownHandler).to.have.been.calledOnce;
|
||||
expect(submitHandler).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
|
||||
const input = await fixture<HTMLFormElement>(html` <sl-input></sl-input> `);
|
||||
|
||||
input.setCustomValidity('Invalid selection');
|
||||
await input.updateComplete;
|
||||
|
||||
expect(input.checkValidity()).to.be.false;
|
||||
expect(input.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(input.hasAttribute('data-valid')).to.be.false;
|
||||
expect(input.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(input.hasAttribute('data-user-valid')).to.be.false;
|
||||
|
||||
input.focus();
|
||||
await sendKeys({ type: 'test' });
|
||||
await input.updateComplete;
|
||||
input.blur();
|
||||
await input.updateComplete;
|
||||
|
||||
expect(input.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(input.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
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-input form="f" name="a" value="1"></sl-input>
|
||||
</div>
|
||||
`);
|
||||
const form = el.querySelector('form')!;
|
||||
const formData = new FormData(form);
|
||||
|
||||
expect(formData.get('a')).to.equal('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when resetting a form', () => {
|
||||
@@ -277,21 +366,21 @@ describe('<sl-input>', () => {
|
||||
describe('when type="number"', () => {
|
||||
it('should be valid when the value is within the boundary of a step', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input type="number" step=".5" value="1.5"></sl-input> `);
|
||||
expect(el.invalid).to.be.false;
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
});
|
||||
|
||||
it('should be invalid when the value is not within the boundary of a step', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input type="number" step=".5" value="1.25"></sl-input> `);
|
||||
expect(el.invalid).to.be.true;
|
||||
expect(el.checkValidity()).to.be.false;
|
||||
});
|
||||
|
||||
it('should update validity when step changes', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input type="number" step=".5" value="1.5"></sl-input> `);
|
||||
expect(el.invalid).to.be.false;
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
|
||||
el.step = 1;
|
||||
await el.updateComplete;
|
||||
expect(el.invalid).to.be.true;
|
||||
expect(el.checkValidity()).to.be.false;
|
||||
});
|
||||
|
||||
it('should increment by step when stepUp() is called', async () => {
|
||||
@@ -353,4 +442,61 @@ describe('<sl-input>', () => {
|
||||
expect(input.spellcheck).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using FormControlController', () => {
|
||||
it('should submit with the correct form when the form attribute changes', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html`
|
||||
<div>
|
||||
<form id="f1">
|
||||
<input type="hidden" name="b" value="2" />
|
||||
<sl-button type="submit">Submit</sl-button>
|
||||
</form>
|
||||
<form id="f2">
|
||||
<input type="hidden" name="c" value="3" />
|
||||
<sl-button type="submit">Submit</sl-button>
|
||||
</form>
|
||||
<sl-input form="f1" name="a" value="1"></sl-input>
|
||||
</div>
|
||||
`);
|
||||
const form = el.querySelector<HTMLFormElement>('#f2')!;
|
||||
const input = document.querySelector('sl-input')!;
|
||||
|
||||
input.form = 'f2';
|
||||
await input.updateComplete;
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
expect(formData.get('a')).to.equal('1');
|
||||
expect(formData.get('b')).to.be.null;
|
||||
expect(formData.get('c')).to.equal('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using the getFormControls() function', () => {
|
||||
it('should return both native and Shoelace form controls in the correct DOM order', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html`
|
||||
<div>
|
||||
<input type="text" name="a" value="1" form="f1" />
|
||||
<sl-input type="text" name="b" value="2" form="f1"></sl-input>
|
||||
<form id="f1">
|
||||
<input type="hidden" name="c" value="3" />
|
||||
<input type="text" name="d" value="4" />
|
||||
<sl-input name="e" value="5"></sl-input>
|
||||
<textarea name="f">6</textarea>
|
||||
<sl-textarea name="g" value="7"></sl-textarea>
|
||||
<sl-checkbox name="h" value="8"></sl-checkbox>
|
||||
</form>
|
||||
<input type="text" name="i" value="9" form="f1" />
|
||||
<sl-input type="text" name="j" value="10" form="f1"></sl-input>
|
||||
</div>
|
||||
`);
|
||||
const form = el.querySelector<HTMLFormElement>('form')!;
|
||||
|
||||
const formControls = getFormControls(form); // eslint-disable-line
|
||||
expect(formControls.length).to.equal(10); // eslint-disable-line
|
||||
expect(formControls.map((fc: HTMLInputElement) => fc.value).join('')).to.equal('12345678910'); // eslint-disable-line
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-input');
|
||||
});
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
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 { HasSlotController } from '../../internal/slot';
|
||||
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 { HasSlotController } from '../../internal/slot';
|
||||
import { watch } from '../../internal/watch';
|
||||
import { LocalizeController } from '../../utilities/localize';
|
||||
import '../icon/icon';
|
||||
import { watch } from '../../internal/watch';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './input.styles';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
//
|
||||
// It's currently impossible to hide Firefox's built-in clear icon when using <input type="date|time">, so we need this
|
||||
// check to apply a clip-path to hide it. I know, I know…user agent sniffing is nasty but, if it fails, we only see a
|
||||
// redundant clear icon so nothing important is breaking. The benefits outweigh the costs for this one. See the
|
||||
// discussion at: https://github.com/shoelace-style/shoelace/pull/794
|
||||
//
|
||||
// Also note that we do the Chromium check first to prevent Chrome from logging a console notice as described here:
|
||||
// https://github.com/shoelace-style/shoelace/issues/855
|
||||
//
|
||||
const isChromium = navigator.userAgentData?.brands.some(b => b.brand.includes('Chromium'));
|
||||
const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
|
||||
/**
|
||||
* @summary Inputs collect data from the user.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/input
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
@@ -47,6 +35,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
|
||||
* @event sl-clear - Emitted when the clear button is activated.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
@@ -63,14 +52,15 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
|
||||
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formSubmitController = new FormSubmitController(this);
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.input__control') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() invalid = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/**
|
||||
@@ -134,6 +124,13 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
|
||||
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = 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 input a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
@@ -147,10 +144,10 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
@property({ type: Number }) maxlength: number;
|
||||
|
||||
/** The input's minimum value. Only applies to date and number input types. */
|
||||
@property({ type: Number }) min: number;
|
||||
@property() min: number | string;
|
||||
|
||||
/** The input's maximum value. Only applies to date and number input types. */
|
||||
@property({ type: Number }) max: number;
|
||||
@property() max: number | string;
|
||||
|
||||
/**
|
||||
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
|
||||
@@ -219,8 +216,18 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
this.value = input.value;
|
||||
}
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.invalid = !this.checkValidity();
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
@@ -250,12 +257,13 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
|
||||
private handleInput() {
|
||||
this.value = this.input.value;
|
||||
this.invalid = !this.checkValidity();
|
||||
this.formControlController.updateValidity();
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid() {
|
||||
this.invalid = true;
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -272,7 +280,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
// See https://github.com/shoelace-style/shoelace/pull/988
|
||||
//
|
||||
if (!event.defaultPrevented && !event.isComposing) {
|
||||
this.formSubmitController.submit();
|
||||
this.formControlController.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -284,9 +292,8 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
|
||||
@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.checkValidity();
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('step', { waitUntilFirstUpdate: true })
|
||||
@@ -294,13 +301,13 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
|
||||
// imperatively so we don't have to wait for the next render to report the updated validity.
|
||||
this.input.step = String(this.step);
|
||||
this.invalid = !this.checkValidity();
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
async handleValueChange() {
|
||||
await this.updateComplete;
|
||||
this.invalid = !this.checkValidity();
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Sets focus on the input. */
|
||||
@@ -365,20 +372,25 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.invalid = !this.checkValidity();
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -428,9 +440,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
'input--disabled': this.disabled,
|
||||
'input--focused': this.hasFocus,
|
||||
'input--empty': !this.value,
|
||||
'input--invalid': this.invalid,
|
||||
'input--no-spin-buttons': this.noSpinButtons,
|
||||
'input--is-firefox': isFirefox
|
||||
'input--no-spin-buttons': this.noSpinButtons
|
||||
})}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="input__prefix"></slot>
|
||||
@@ -451,16 +461,15 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
max=${ifDefined(this.max)}
|
||||
step=${ifDefined(this.step as number)}
|
||||
.value=${live(this.value)}
|
||||
autocapitalize=${ifDefined(this.type === 'password' ? 'off' : this.autocapitalize)}
|
||||
autocomplete=${ifDefined(this.type === 'password' ? 'off' : this.autocomplete)}
|
||||
autocorrect=${ifDefined(this.type === 'password' ? 'off' : this.autocorrect)}
|
||||
autocapitalize=${ifDefined(this.autocapitalize)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
autocorrect=${ifDefined(this.autocorrect)}
|
||||
?autofocus=${this.autofocus}
|
||||
spellcheck=${this.spellcheck}
|
||||
pattern=${ifDefined(this.pattern)}
|
||||
enterkeyhint=${ifDefined(this.enterkeyhint)}
|
||||
inputmode=${ifDefined(this.inputmode)}
|
||||
aria-describedby="help-text"
|
||||
aria-invalid=${this.invalid ? 'true' : 'false'}
|
||||
@change=${this.handleChange}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
|
||||
@@ -8,6 +8,10 @@ export default css`
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host([inert]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -56,7 +60,7 @@ export default css`
|
||||
margin-inline-start: var(--sl-spacing-x-small);
|
||||
}
|
||||
|
||||
:host(:focus) {
|
||||
:host(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { expect, fixture, html, waitUntil, aTimeout } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type SlMenuItem from './menu-item';
|
||||
|
||||
describe('<sl-menu-item>', () => {
|
||||
it('passes accessibility test', async () => {
|
||||
it('should pass accessibility tests', async () => {
|
||||
const el = await fixture<SlMenuItem>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item>Item 1</sl-menu-item>
|
||||
@@ -17,7 +18,7 @@ describe('<sl-menu-item>', () => {
|
||||
await expect(el).to.be.accessible();
|
||||
});
|
||||
|
||||
it('default properties', async () => {
|
||||
it('should have the correct default properties', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
|
||||
|
||||
expect(el.value).to.equal('');
|
||||
@@ -25,20 +26,27 @@ describe('<sl-menu-item>', () => {
|
||||
expect(el.getAttribute('aria-disabled')).to.equal('false');
|
||||
});
|
||||
|
||||
it('changes aria attributes', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
|
||||
|
||||
el.disabled = true;
|
||||
await aTimeout(100);
|
||||
it('should render the correct aria attributes when disabled', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item disabled>Test</sl-menu-item> `);
|
||||
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
||||
});
|
||||
|
||||
it('get text label', async () => {
|
||||
it('should not emit the click event when disabled', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item disabled>Test</sl-menu-item> `);
|
||||
const clickHandler = sinon.spy();
|
||||
el.addEventListener('click', clickHandler);
|
||||
await clickOnElement(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(clickHandler).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('should return a text label when calling getTextLabel()', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
|
||||
expect(el.getTextLabel()).to.equal('Test');
|
||||
});
|
||||
|
||||
it('emits the slotchange event when the label changes', async () => {
|
||||
it('should emit the slotchange event when the label changes', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Text</sl-menu-item> `);
|
||||
const slotChangeHandler = sinon.spy();
|
||||
|
||||
@@ -48,4 +56,17 @@ describe('<sl-menu-item>', () => {
|
||||
|
||||
expect(slotChangeHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('should render a hidden menu item when the inert attribute is used', async () => {
|
||||
const menu = await fixture<SlMenuItem>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item inert>Item 1</sl-menu-item>
|
||||
<sl-menu-item>Item 2</sl-menu-item>
|
||||
<sl-menu-item>Item 3</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
const item1 = menu.querySelector('sl-menu-item')!;
|
||||
|
||||
expect(getComputedStyle(item1).display).to.equal('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import { getTextContent } from '../../internal/slot';
|
||||
import { watch } from '../../internal/watch';
|
||||
import '../icon/icon';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getTextContent } from '../../internal/slot';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './menu-item.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu items provide options for the user to pick from in a menu.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/menu-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
@@ -47,6 +47,17 @@ export default class SlMenuItem extends ShoelaceElement {
|
||||
/** Draws the menu item in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleHostClick = this.handleHostClick.bind(this);
|
||||
this.addEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
@@ -63,6 +74,14 @@ export default class SlMenuItem extends ShoelaceElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleHostClick(event: MouseEvent) {
|
||||
// Prevent the click event from being emitted when the button is disabled or loading
|
||||
if (this.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './menu-label.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu labels are used to describe a group of menu items.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/menu-label
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu label's content.
|
||||
*
|
||||
|
||||
@@ -1,78 +1,102 @@
|
||||
import { expect, fixture, waitUntil } from '@open-wc/testing';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { expect, fixture } from '@open-wc/testing';
|
||||
import { html } from 'lit';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlMenuItem from '../menu-item/menu-item';
|
||||
import type SlMenu from './menu';
|
||||
|
||||
interface Payload {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
||||
const createTestMenu = (): Promise<SlMenu> => {
|
||||
return fixture<SlMenu>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item value="test1">test1</sl-menu-item>
|
||||
<sl-menu-item value="test2">test2</sl-menu-item>
|
||||
<sl-menu-item value="test3">test3</sl-menu-item>
|
||||
<sl-menu-item value="testDisabled" disabled>testDisabled</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
};
|
||||
|
||||
const clickOnItemWithValue = (menu: SlMenu, value: string) => {
|
||||
const clickedItem = menu.querySelector(`[value=${value}]`);
|
||||
if (clickedItem) {
|
||||
(clickedItem as SlMenuItem).click();
|
||||
}
|
||||
};
|
||||
|
||||
const spyOnSelectHandler = (menu: SlMenu): sinon.SinonSpy => {
|
||||
const selectHandler = sinon.spy();
|
||||
menu.addEventListener('sl-select', selectHandler);
|
||||
return selectHandler;
|
||||
};
|
||||
|
||||
const expectSelectHandlerToHaveBeenCalledOn = async (
|
||||
selectHandler: sinon.SinonSpy,
|
||||
expectedValue: string
|
||||
): Promise<void> => {
|
||||
await waitUntil(() => selectHandler.called);
|
||||
expect(selectHandler).to.have.been.calledOnce;
|
||||
const event = selectHandler.args[0][0] as CustomEvent;
|
||||
const detail = event.detail as Payload;
|
||||
expect(detail.item.value).to.equal(expectedValue);
|
||||
};
|
||||
import type SlSelectEvent from '../../events/sl-select';
|
||||
|
||||
describe('<sl-menu>', () => {
|
||||
it('emits sl-select on click of an item returning the selected item as payload', async () => {
|
||||
const menu = await createTestMenu();
|
||||
const selectHandler = spyOnSelectHandler(menu);
|
||||
it('emits sl-select with the correct event detail when clicking an item', async () => {
|
||||
const menu = await fixture<SlMenu>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item value="item-1">Item 1</sl-menu-item>
|
||||
<sl-menu-item value="item-2">Item 2</sl-menu-item>
|
||||
<sl-menu-item value="item-3">Item 3</sl-menu-item>
|
||||
<sl-menu-item value="item-4">Item 4</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
const item2 = menu.querySelectorAll('sl-menu-item')[1];
|
||||
const selectHandler = sinon.spy((event: SlSelectEvent) => {
|
||||
const item = event.detail.item;
|
||||
if (item !== item2) {
|
||||
expect.fail('Incorrect event detail emitted with sl-select');
|
||||
}
|
||||
});
|
||||
|
||||
clickOnItemWithValue(menu, 'test1');
|
||||
menu.addEventListener('sl-select', selectHandler);
|
||||
await clickOnElement(item2);
|
||||
|
||||
await expectSelectHandlerToHaveBeenCalledOn(selectHandler, 'test1');
|
||||
expect(selectHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('can be selected via keyboard', async () => {
|
||||
const menu = await createTestMenu();
|
||||
const selectHandler = spyOnSelectHandler(menu);
|
||||
const menu = await fixture<SlMenu>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item value="item-1">Item 1</sl-menu-item>
|
||||
<sl-menu-item value="item-2">Item 2</sl-menu-item>
|
||||
<sl-menu-item value="item-3">Item 3</sl-menu-item>
|
||||
<sl-menu-item value="item-4">Item 4</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
const [item1, item2] = menu.querySelectorAll('sl-menu-item');
|
||||
const selectHandler = sinon.spy((event: SlSelectEvent) => {
|
||||
const item = event.detail.item;
|
||||
if (item !== item2) {
|
||||
expect.fail('Incorrect item selected');
|
||||
}
|
||||
});
|
||||
|
||||
await sendKeys({ press: 'Tab' });
|
||||
menu.addEventListener('sl-select', selectHandler);
|
||||
|
||||
item1.focus();
|
||||
await item1.updateComplete;
|
||||
await sendKeys({ press: 'ArrowDown' });
|
||||
await sendKeys({ press: 'Enter' });
|
||||
|
||||
await expectSelectHandlerToHaveBeenCalledOn(selectHandler, 'test2');
|
||||
expect(selectHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('does not select disabled items', async () => {
|
||||
const menu = await createTestMenu();
|
||||
const selectHandler = spyOnSelectHandler(menu);
|
||||
it('does not select disabled items when clicking', async () => {
|
||||
const menu = await fixture<SlMenu>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item value="item-1">Item 1</sl-menu-item>
|
||||
<sl-menu-item value="item-2" disabled>Item 2</sl-menu-item>
|
||||
<sl-menu-item value="item-3">Item 3</sl-menu-item>
|
||||
<sl-menu-item value="item-4">Item 4</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
const item2 = menu.querySelectorAll('sl-menu-item')[1];
|
||||
const selectHandler = sinon.spy();
|
||||
|
||||
await sendKeys({ press: 'Tab' });
|
||||
await sendKeys({ type: 'testDisabled' });
|
||||
menu.addEventListener('sl-select', selectHandler);
|
||||
|
||||
await clickOnElement(item2);
|
||||
|
||||
expect(selectHandler).to.not.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('does not select disabled items when pressing enter', async () => {
|
||||
const menu = await fixture<SlMenu>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item value="item-1">Item 1</sl-menu-item>
|
||||
<sl-menu-item value="item-2" disabled>Item 2</sl-menu-item>
|
||||
<sl-menu-item value="item-3">Item 3</sl-menu-item>
|
||||
<sl-menu-item value="item-4">Item 4</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
const [item1, item2] = menu.querySelectorAll('sl-menu-item');
|
||||
const selectHandler = sinon.spy();
|
||||
|
||||
menu.addEventListener('sl-select', selectHandler);
|
||||
|
||||
item1.focus();
|
||||
await item1.updateComplete;
|
||||
await sendKeys({ press: 'ArrowDown' });
|
||||
expect(document.activeElement).to.equal(item2);
|
||||
await sendKeys({ press: 'Enter' });
|
||||
await item2.updateComplete;
|
||||
|
||||
await expectSelectHandlerToHaveBeenCalledOn(selectHandler, 'test1');
|
||||
expect(selectHandler).to.not.have.been.called;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './menu.styles';
|
||||
import type SlMenuItem from '../menu-item/menu-item';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlMenuItem from '../menu-item/menu-item';
|
||||
export interface MenuSelectEventDetail {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Menus provide a list of options for the user to choose from.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/menu
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu's content, including menu items, menu labels, and dividers.
|
||||
*
|
||||
@@ -29,27 +29,19 @@ export default class SlMenu extends ShoelaceElement {
|
||||
this.setAttribute('role', 'menu');
|
||||
}
|
||||
|
||||
private getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (!this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const item = target.closest('sl-menu-item');
|
||||
|
||||
if (item?.disabled === false) {
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
||||
this.emit('sl-select', { detail: { item } });
|
||||
if (!item || item.disabled || item.inert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
||||
this.emit('sl-select', { detail: { item } });
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -123,6 +115,16 @@ export default class SlMenu extends ShoelaceElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
|
||||
getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (el.inert || !this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
|
||||
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './mutation-observer.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary The Mutation Observer component offers a thin, declarative interface to the [`MutationObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/mutation-observer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event {{ mutationList: MutationRecord[] }} sl-mutation - Emitted when a mutation occurs.
|
||||
*
|
||||
|
||||
@@ -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 SlOption from './option';
|
||||
|
||||
@@ -41,4 +41,14 @@ describe('<sl-option>', () => {
|
||||
|
||||
expect(slotChangeHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('should convert non-string values to string', async () => {
|
||||
const el = await fixture<SlOption>(html` <sl-option>Text</sl-option> `);
|
||||
|
||||
// @ts-expect-error - intentional
|
||||
el.value = 10;
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.value).to.equal('10');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query, 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 { LocalizeController } from '../../utilities/localize';
|
||||
import '../icon/icon';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize';
|
||||
import { watch } from '../../internal/watch';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './option.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Options define the selectable items within various form controls such as [select](/components/select).
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/option
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
@@ -31,7 +31,7 @@ export default class SlOption extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private cachedTextLabel: string;
|
||||
// @ts-expect-error -- Controller is currently unused
|
||||
// @ts-expect-error - Controller is currently unused
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.option__label') defaultSlot: HTMLSlotElement;
|
||||
@@ -92,6 +92,12 @@ export default class SlOption extends ShoelaceElement {
|
||||
|
||||
@watch('value')
|
||||
handleValueChange() {
|
||||
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
|
||||
// instead of requiring them to cast the value to a string.
|
||||
if (typeof this.value !== 'string') {
|
||||
this.value = String(this.value);
|
||||
}
|
||||
|
||||
if (this.value.includes(' ')) {
|
||||
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
|
||||
this.value = this.value.replace(/ /g, '_');
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { arrow, autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { offsetParent } from 'composed-offset-position';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './popup.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Popup is a utility that lets you declaratively anchor "popup" containers to another element.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/popup
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-reposition - Emitted when the popup is repositioned. This event can fire a lot, so avoid putting expensive
|
||||
* operations in your listener or consider debouncing it.
|
||||
@@ -38,7 +39,7 @@ import type { CSSResultGroup } from 'lit';
|
||||
export default class SlPopup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private anchorEl: HTMLElement | null;
|
||||
private anchorEl: Element | null;
|
||||
private cleanup: ReturnType<typeof autoUpdate> | undefined;
|
||||
|
||||
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
|
||||
@@ -76,8 +77,8 @@ export default class SlPopup extends ShoelaceElement {
|
||||
| 'left-end' = 'top';
|
||||
|
||||
/**
|
||||
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if
|
||||
* overflow is clipped, using a `fixed` position strategy can often workaround it.
|
||||
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if overflow is
|
||||
* clipped, using a `fixed` position strategy can often workaround it.
|
||||
*/
|
||||
@property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
|
||||
|
||||
@@ -223,7 +224,7 @@ export default class SlPopup extends ShoelaceElement {
|
||||
// Locate the anchor by id
|
||||
const root = this.getRootNode() as Document | ShadowRoot;
|
||||
this.anchorEl = root.getElementById(this.anchor);
|
||||
} else if (this.anchor instanceof HTMLElement) {
|
||||
} else if (this.anchor instanceof Element) {
|
||||
// Use the anchor's reference
|
||||
this.anchorEl = this.anchor;
|
||||
} else {
|
||||
@@ -365,10 +366,24 @@ export default class SlPopup extends ShoelaceElement {
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Use custom positioning logic if the strategy is absolute. Otherwise, fall back to the default logic.
|
||||
//
|
||||
// More info: https://github.com/shoelace-style/shoelace/issues/1135
|
||||
//
|
||||
const getOffsetParent =
|
||||
this.strategy === 'absolute'
|
||||
? (element: Element) => platform.getOffsetParent(element, offsetParent)
|
||||
: platform.getOffsetParent;
|
||||
|
||||
computePosition(this.anchorEl, this.popup, {
|
||||
placement: this.placement,
|
||||
middleware,
|
||||
strategy: this.strategy
|
||||
strategy: this.strategy,
|
||||
platform: {
|
||||
...platform,
|
||||
getOffsetParent
|
||||
}
|
||||
}).then(({ x, y, middlewareData, placement }) => {
|
||||
//
|
||||
// Even though we have our own localization utility, it uses different heuristics to determine RTL. Because of
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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 { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import { LocalizeController } from '../../utilities/localize';
|
||||
import styles from './progress-bar.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Progress bars are used to show the status of an ongoing operation.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/progress-bar
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - A label to show inside the progress indicator.
|
||||
*
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query, state } 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';
|
||||
import styles from './progress-ring.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Progress rings are used to show the progress of a determinate operation in a circular fashion.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/progress-ring
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - A label to show inside the ring.
|
||||
*
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch';
|
||||
import QrCreator from 'qr-creator';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import { watch } from '../../internal/watch';
|
||||
import styles from './qr-code.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @since 2.0
|
||||
* @summary 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).
|
||||
* @documentation https://shoelace.style/components/qr-code
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@@ -44,12 +46,7 @@ export default class SlQrCode extends ShoelaceElement {
|
||||
this.generate();
|
||||
}
|
||||
|
||||
@watch('background')
|
||||
@watch('errorCorrection')
|
||||
@watch('fill')
|
||||
@watch('radius')
|
||||
@watch('size')
|
||||
@watch('value')
|
||||
@watch(['background', 'errorCorrection', 'fill', 'radius', 'size', 'value'])
|
||||
generate() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
@@ -75,7 +72,7 @@ export default class SlQrCode extends ShoelaceElement {
|
||||
part="base"
|
||||
class="qr-code"
|
||||
role="img"
|
||||
aria-label=${this.label.length > 0 ? this.label : this.value}
|
||||
aria-label=${this.label?.length > 0 ? this.label : this.value}
|
||||
style=${styleMap({
|
||||
width: `${this.size}px`,
|
||||
height: `${this.size}px`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import type SlRadioGroup from '../radio-group/radio-group';
|
||||
import type SlRadioButton from './radio-button';
|
||||
import type SlRadioGroup from '../radio-group/radio-group';
|
||||
|
||||
describe('<sl-radio-button>', () => {
|
||||
it('should not get checked when disabled', async () => {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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 } from 'lit/static-html.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { HasSlotController } from '../../internal/slot';
|
||||
import { html } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { watch } from '../../internal/watch';
|
||||
import ShoelaceElement from '../../internal/shoelace-element';
|
||||
import styles from './radio-button.styles';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Radios buttons allow the user to select a single option from a group using a button-like control.
|
||||
*
|
||||
* @since 2.0
|
||||
* @documentation https://shoelace.style/components/radio-button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The radio button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
@@ -38,7 +38,12 @@ export default class SlRadioButton extends ShoelaceElement {
|
||||
@query('.hidden-input') hiddenInput: HTMLInputElement;
|
||||
|
||||
@state() protected hasFocus = false;
|
||||
@state() checked = false;
|
||||
|
||||
/**
|
||||
* @internal The radio button's checked state. This is exposed as an "internal" attribute so we can reflect it, making
|
||||
* it easier to style in button groups.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** The radio's value. When selected, the radio group will receive this value. */
|
||||
@property() value: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user