kopia lustrzana https://github.com/shoelace-style/shoelace
Porównaj commity
178 Commity
Autor | SHA1 | Data |
---|---|---|
cptKNJO | 06a27dd899 | |
Konnor Rogers | 9767e84d26 | |
Cory LaViska | 8726910160 | |
Cory LaViska | d478ccb2da | |
Cory LaViska | d94acc6e06 | |
Fiqri Syah Redha | eb42671ef3 | |
Christian Schilling | 3ad6364678 | |
Konnor Rogers | 64996b2d35 | |
Susanne Kirchner | 0daa5d8dee | |
Konnor Rogers | 16d5575307 | |
Konnor Rogers | a427433701 | |
Danny Andrews | c6da4f5b14 | |
Cory LaViska | d0b71adb81 | |
Cory LaViska | ae66483671 | |
Cory LaViska | 537fd87497 | |
Cory LaViska | 1534f47d34 | |
Cory LaViska | eb08be0fce | |
Cory LaViska | dfc4cb6248 | |
Cory LaViska | c1eda83e5b | |
Cory LaViska | 0e5048989d | |
Konnor Rogers | ff2e0486b4 | |
Sebi | 4aa5e9c1f2 | |
Cory LaViska | 0b7e70bccf | |
Alessandro | 31f2600816 | |
Cory LaViska | f6d5344c44 | |
Nic Newdigate | 5a89439e14 | |
Cory LaViska | 2878957ef5 | |
Konnor Rogers | cd5a6486da | |
Cory LaViska | 77d6f27248 | |
Konnor Rogers | 7a62a87b9b | |
Cory LaViska | 0ac61a6a22 | |
Matt Walkland | acf76cf359 | |
Cory LaViska | 3451ec753c | |
cyantree | 2a4b3ee2e9 | |
Konnor Rogers | 3bc8495874 | |
Cory LaViska | 7f87887477 | |
Amadej Glasenčnik | c88b38f194 | |
Cory LaViska | e2bce65c02 | |
Susanne Kirchner | 9cbb0b8a95 | |
Cory LaViska | 2128e62109 | |
Cory LaViska | 12ce0217e5 | |
RoyDust | 1a2969a74b | |
Cory LaViska | 033fec9471 | |
Cory LaViska | 8272619663 | |
cyantree | 298892b10a | |
Cory LaViska | e1102ba9cf | |
Cory LaViska | b589938443 | |
Cory LaViska | 07b13d489a | |
Cory LaViska | e9405d33a8 | |
Cory LaViska | f2a42565e2 | |
Cory LaViska | 23f09dfa79 | |
stefanholzapfel | 6e288a80a3 | |
Cory LaViska | 9b19c4c782 | |
cyantree | 6440387432 | |
Cory LaViska | f3be76840f | |
Cory LaViska | 1056a10f8e | |
Cory LaViska | f9a73567f7 | |
Burton Smith | 6bc06d5d95 | |
Cory LaViska | 7571f8c534 | |
Cory LaViska | 1bf3e5a2b7 | |
Cory LaViska | 02ce4dbf4e | |
Cory LaViska | 775f30107f | |
Cory LaViska | 9ee1617696 | |
Alessandro | 7e38e93ab2 | |
Cory LaViska | dafb35c6e2 | |
Cory LaViska | a36bbe2fc4 | |
Ahmad Alfy | 4185430989 | |
Cory LaViska | e6d3d8317a | |
clintcs | 9451c3b8de | |
Konnor Rogers | a5e9b942e3 | |
Cory LaViska | 380d56fa40 | |
Cory LaViska | 83fe1ff28e | |
Cory LaViska | a4c49e95a9 | |
Cory LaViska | beea96b373 | |
Cory LaViska | 6751b21283 | |
Cory LaViska | e37139b7cf | |
Cory LaViska | 1f87f429ed | |
Cory LaViska | afc6dc1923 | |
Cory LaViska | e2a64486d0 | |
Cory LaViska | 8473d06822 | |
Cory LaViska | cb15749500 | |
Luke Warlow | 0a319c3646 | |
Matin | 1d626c1357 | |
YassSSH | caf47069c0 | |
Konnor Rogers | 773255881b | |
Cory LaViska | 478c8bdf69 | |
Alessandro | 9f640aa0a2 | |
Burton Smith | b1908d73dc | |
clintcs | 1a77e603f8 | |
Cory LaViska | ac5e2d2d43 | |
Alessandro | 95881b8cf8 | |
Cory LaViska | eb39610a46 | |
Cory LaViska | e231f8a4a1 | |
Cory LaViska | 6b9e78f05d | |
Cory LaViska | b79c72725b | |
Cory LaViska | 92bde9c66b | |
Cory LaViska | dd483c0a04 | |
Cory LaViska | f5f4f9ae43 | |
Cory LaViska | a21ab1d044 | |
Cory LaViska | 75c45a2aa7 | |
Michael Warren | d909f4f73d | |
Konnor Rogers | 7891dbef93 | |
Konnor Rogers | b4ed398240 | |
Cory LaViska | 1710cfb8bc | |
Cory LaViska | 0080ff9c60 | |
Cory LaViska | caae94119c | |
Cory LaViska | 59ef323f38 | |
Cory LaViska | e1417b8e1a | |
Cory LaViska | bb20126b17 | |
Ryan | 3de99eee0a | |
Cory LaViska | 0d043767ec | |
Cory LaViska | b7eccb1bff | |
Konnor Rogers | dd27db5196 | |
Cory LaViska | 3e38da210e | |
Cory LaViska | 4864ab808d | |
Cory LaViska | e2b7327d98 | |
Mitch Ray | 1a8403b9b2 | |
Konnor Rogers | bfa7c4cda9 | |
Cory LaViska | 7fae62b806 | |
Cory LaViska | 15c6733949 | |
Cory LaViska | 1e57a632d9 | |
Cory LaViska | ffe492c503 | |
Cory LaViska | 21e2c7a473 | |
Cory LaViska | b6c9b64ec0 | |
Cory LaViska | 00435ac682 | |
Matt Obee | 4699f99107 | |
Rikard Kling | 025da5e59f | |
Cory LaViska | d99b90dee1 | |
Cory LaViska | 66c5e4cba2 | |
Cory LaViska | d7d9242d58 | |
Cory LaViska | 02ad181775 | |
Cory LaViska | 024c6e2e48 | |
Cory LaViska | 3fdbefa2d4 | |
Cory LaViska | 2b45c546e8 | |
Cory LaViska | a36ae4e482 | |
Cory LaViska | 3b2eb9bb5c | |
Cory LaViska | 1bf490aed0 | |
Cory LaViska | 1564df829b | |
Cory LaViska | facb5504a4 | |
Cory LaViska | ee18f3a449 | |
folini96 | c3c770b0e0 | |
Nick Lemmon | a1888c628f | |
Cory LaViska | 13c3e88384 | |
Cory LaViska | e0701fe3fc | |
Konnor Rogers | 35c2ad886d | |
Coridyn | e786aa86b5 | |
Konnor Rogers | 5221419816 | |
Konnor Rogers | f015dc9169 | |
Mitch Ray | a2b7816010 | |
Cory LaViska | 2a1f48c332 | |
Henry Wilkinson | 8ddef1a0bd | |
Mitch Ray | 468b0b9e66 | |
Cory LaViska | 6590dd4004 | |
Konnor Rogers | 12a45eb65d | |
Konnor Rogers | 5e620a8bb3 | |
Cory LaViska | b9fa2a60fe | |
Cory LaViska | b7a4a228d6 | |
Alessandro | 597a06c97c | |
Cory LaViska | 1087fe23f7 | |
Cory LaViska | 207a660738 | |
Cory LaViska | 296a24c74a | |
Cory LaViska | 265ef71e6d | |
Cory LaViska | 224bba2532 | |
Cory LaViska | d07f8e01ad | |
Alessandro | 58bf05451d | |
floflausch | f53309b04a | |
Cory LaViska | 49b42c3b90 | |
Cory LaViska | 762d0b0098 | |
Cory LaViska | e297633bd7 | |
Cory LaViska | f28ea9b834 | |
Cory LaViska | 0272e3dcff | |
floflausch | 8d42e9fd7e | |
Cory LaViska | e5da26fe6d | |
Cory LaViska | eb96e3db4b | |
Cory LaViska | b1b54a5a34 | |
Cory LaViska | a5404ecab0 | |
Cory LaViska | afe7778f89 | |
fountainpen | 88f3009cf4 |
|
@ -25,6 +25,6 @@ jobs:
|
|||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- run: npx playwright install-deps
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
_site
|
||||
.cache
|
||||
.DS_Store
|
||||
package.json
|
||||
package-lock.json
|
||||
cdn
|
||||
dist
|
||||
docs/assets/images/sprite.svg
|
||||
|
|
|
@ -2,6 +2,6 @@
|
|||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -100,6 +100,7 @@
|
|||
"monospace",
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
"mouseout",
|
||||
"mouseup",
|
||||
"multiselectable",
|
||||
"nextjs",
|
||||
|
@ -109,6 +110,7 @@
|
|||
"novalidate",
|
||||
"npmdir",
|
||||
"Numberish",
|
||||
"onscrollend",
|
||||
"outdir",
|
||||
"ParamagicDev",
|
||||
"peta",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'path';
|
||||
import { customElementJetBrainsPlugin } from 'custom-element-jet-brains-integration';
|
||||
import { customElementVsCodePlugin } from 'custom-element-vs-code-integration';
|
||||
import { customElementVuejsPlugin } from 'custom-element-vuejs-integration';
|
||||
import { parse } from 'comment-parser';
|
||||
import { pascalCase } from 'pascal-case';
|
||||
import commandLineArgs from 'command-line-args';
|
||||
|
@ -38,6 +39,7 @@ export default {
|
|||
customElementsManifest.package = { name, description, version, author, homepage, license };
|
||||
}
|
||||
},
|
||||
|
||||
// Infer tag names because we no longer use @customElement decorators.
|
||||
{
|
||||
name: 'shoelace-infer-tag-names',
|
||||
|
@ -66,6 +68,7 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Parse custom jsDoc tags
|
||||
{
|
||||
name: 'shoelace-custom-tags',
|
||||
|
@ -137,6 +140,7 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'shoelace-react-event-names',
|
||||
analyzePhase({ ts, node, moduleDoc }) {
|
||||
|
@ -155,6 +159,7 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'shoelace-translate-module-paths',
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
|
@ -191,6 +196,7 @@ export default {
|
|||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Generate custom VS Code data
|
||||
customElementVsCodePlugin({
|
||||
outdir,
|
||||
|
@ -202,15 +208,23 @@ export default {
|
|||
}
|
||||
]
|
||||
}),
|
||||
|
||||
customElementJetBrainsPlugin({
|
||||
outdir: './dist',
|
||||
excludeCss: true,
|
||||
packageJson: false,
|
||||
referencesTemplate: (_, tag) => {
|
||||
return {
|
||||
name: 'Documentation',
|
||||
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
customElementVuejsPlugin({
|
||||
outdir: './dist/types/vue',
|
||||
fileName: 'index.d.ts',
|
||||
componentTypePath: (_, tag) => `../../components/${tag.replace('sl-', '')}/${tag.replace('sl-', '')}.component.js`
|
||||
})
|
||||
]
|
||||
};
|
||||
|
|
|
@ -160,7 +160,7 @@
|
|||
</td>
|
||||
<td>
|
||||
{% if prop.type.text %}
|
||||
<code>{{ prop.type.text | markdownInline | safe }}</code>
|
||||
<code>{{ prop.type.text | trimPipes | markdownInline | safe }}</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
|
@ -211,7 +211,7 @@
|
|||
<td>{{ event.description | markdownInline | safe }}</td>
|
||||
<td>
|
||||
{% if event.type.text %}
|
||||
<code>{{ event.type.text }}</code>
|
||||
<code>{{ event.type.text | trimPipes }}</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
|
@ -245,7 +245,7 @@
|
|||
{% if method.parameters.length %}
|
||||
<code>
|
||||
{% for param in method.parameters %}
|
||||
{{ param.name }}: {{ param.type.text }}{% if not loop.last %},{% endif %}
|
||||
{{ param.name }}: {{ param.type.text | trimPipes }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
</code>
|
||||
{% else %}
|
||||
|
|
|
@ -95,6 +95,23 @@
|
|||
</sl-dropdown>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="ks-banner{% if toc %} with-toc{% endif %}"
|
||||
href="https://www.kickstarter.com/projects/fontawesome/web-awesome?ref=71ihfk"
|
||||
target="_blank"
|
||||
>
|
||||
<span>
|
||||
<svg viewBox="0 0 20 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#f36944" d="M11.63 1.625C11.63 2.27911 11.2435 2.84296 10.6865 3.10064L14 6L17.2622 5.34755C17.0968 5.10642 17 4.81452 17 4.5C17 3.67157 17.6716 3 18.5 3C19.3284 3 20 3.67157 20 4.5C20 5.31157 19.3555 5.9726 18.5504 5.99917L15.0307 13.8207C14.7077 14.5384 13.9939 15 13.2068 15H6.79317C6.00615 15 5.29229 14.5384 4.96933 13.8207L1.44963 5.99917C0.64452 5.9726 0 5.31157 0 4.5C0 3.67157 0.671573 3 1.5 3C2.32843 3 3 3.67157 3 4.5C3 4.81452 2.9032 5.10642 2.73777 5.34755L6 6L9.31702 3.09761C8.76346 2.83855 8.38 2.27656 8.38 1.625C8.38 0.727537 9.10754 0 10.005 0C10.9025 0 11.63 0.727537 11.63 1.625Z"/>
|
||||
</svg>
|
||||
<span>
|
||||
<strong style="white-space: nowrap;">Get ready for more awesome!</strong>
|
||||
Web Awesome, the next iteration of Shoelace, is on Kickstarter.
|
||||
</span>
|
||||
</span>
|
||||
<span class="faux-button">Read Our Story</span>
|
||||
</a>
|
||||
|
||||
<aside id="sidebar" data-preserve-scroll>
|
||||
<header>
|
||||
<a href="/">
|
||||
|
|
|
@ -1059,7 +1059,6 @@ html.sidebar-open #menu-toggle {
|
|||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: 250ms scale ease;
|
||||
}
|
||||
|
||||
#theme-selector:not(:defined) {
|
||||
|
@ -1102,12 +1101,6 @@ html.sidebar-open #menu-toggle {
|
|||
color: var(--sl-color-neutral-1000);
|
||||
}
|
||||
|
||||
#icon-toolbar button:hover,
|
||||
#icon-toolbar a:hover,
|
||||
#theme-selector sl-button:hover {
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
#icon-toolbar a:not(:last-child),
|
||||
#icon-toolbar button:not(:last-child) {
|
||||
margin-right: 0.25rem;
|
||||
|
@ -1420,3 +1413,95 @@ body[data-page^='/tokens/'] .table-wrapper td:first-child code {
|
|||
grid-column-start: span 6;
|
||||
}
|
||||
}
|
||||
|
||||
.ks-banner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
width: 950px;
|
||||
left: calc(50% - 475px);
|
||||
font-size: 0.9375rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #1a3256;
|
||||
border-radius: var(--sl-border-radius-large);
|
||||
padding: 1rem 1.25rem;
|
||||
color: #fdfdfd;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
z-index: 2;
|
||||
margin-left: 160px;
|
||||
}
|
||||
|
||||
.ks-banner:hover {
|
||||
color: #fdfdfd;
|
||||
}
|
||||
|
||||
.ks-banner > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ks-banner svg {
|
||||
flex: 0 0 1.5rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.ks-banner .faux-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
background: white;
|
||||
border: solid 1px #d4d4d4;
|
||||
border-radius: var(--sl-border-radius-medium);
|
||||
font-size: 0.8375rem;
|
||||
color: #353439;
|
||||
padding: 0.5rem 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ks-banner.with-toc {
|
||||
width: 1100px;
|
||||
left: calc(50% - 550px);
|
||||
margin-left: 140px;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 70px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1650px) {
|
||||
.ks-banner,
|
||||
.ks-banner.with-toc {
|
||||
width: 540px !important;
|
||||
top: 50px;
|
||||
left: calc(50% - 270px);
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.ks-banner,
|
||||
.ks-banner.with-toc {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 680px) {
|
||||
.ks-banner,
|
||||
.ks-banner.with-toc {
|
||||
width: calc(100% - 2rem) !important;
|
||||
left: 1rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 150px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -96,6 +96,12 @@ module.exports = function (eleventyConfig) {
|
|||
return shoelaceFlavoredMarkdown.renderInline(content);
|
||||
});
|
||||
|
||||
// Trims whitespace and pipes from the start and end of a string. Useful for CEM types, which can be pipe-delimited.
|
||||
// With Prettier 3, this means a leading pipe will exist if the line wraps.
|
||||
eleventyConfig.addFilter('trimPipes', content => {
|
||||
return typeof content === 'string' ? content.replace(/^(\s|\|)/g, '').replace(/(\s|\|)$/g, '') : content;
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter('classNameToComponentName', className => {
|
||||
let name = capitalCase(className.replace(/^Sl/, ''));
|
||||
if (name === 'Qr Code') name = 'QR Code'; // manual override
|
||||
|
|
|
@ -236,7 +236,7 @@ When a `target` is set, the link will receive `rel="noreferrer noopener"` for [s
|
|||
|
||||
### Setting a Custom Width
|
||||
|
||||
As expected, buttons can be given a custom width by setting the `width` attribute. This is useful for making buttons span the full width of their container on smaller screens.
|
||||
As expected, buttons can be given a custom width by passing inline styles to the component (or using a class). This is useful for making buttons span the full width of their container on smaller screens.
|
||||
|
||||
```html:preview
|
||||
<sl-button variant="default" size="small" style="width: 100%; margin-bottom: 1rem;">Small</sl-button>
|
||||
|
@ -417,7 +417,7 @@ const App = () => (
|
|||
|
||||
### Loading
|
||||
|
||||
Use the `loading` attribute to make a button busy. The width will remain the same as before, preventing adjacent elements from moving around. Clicks will be suppressed until the loading state is removed.
|
||||
Use the `loading` attribute to make a button busy. The width will remain the same as before, preventing adjacent elements from moving around.
|
||||
|
||||
```html:preview
|
||||
<sl-button variant="default" loading>Default</sl-button>
|
||||
|
|
|
@ -1246,7 +1246,7 @@ const App = () => {
|
|||
<img
|
||||
alt={`Thumbnail by ${i + 1}`}
|
||||
className={`thumbnails__image ${i === currentSlide ? 'active' : ''}`}
|
||||
onCLick={() => handleThumbnailClick(i)}
|
||||
onClick={() => handleThumbnailClick(i)}
|
||||
src={src}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -89,6 +89,20 @@ const App = () => (
|
|||
);
|
||||
```
|
||||
|
||||
### Help Text
|
||||
|
||||
Add descriptive help text to a switch with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead.
|
||||
|
||||
```html:preview
|
||||
<sl-checkbox help-text="What should the user know about the checkbox?">Label</sl-checkbox>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import SlCheckbox from '@shoelace-style/shoelace/dist/react/checkbox';
|
||||
|
||||
const App = () => <SlCheckbox help-text="What should the user know about the switch?">Label</SlCheckbox>;
|
||||
```
|
||||
|
||||
### Custom Validity
|
||||
|
||||
Use the `setCustomValidity()` method to set a custom validation message. This will prevent the form from submitting and make the browser display the error message you provide. To clear the error, call this function with an empty string.
|
||||
|
|
|
@ -123,7 +123,9 @@ Details are designed to function independently, but you can simulate a group or
|
|||
|
||||
// Close all other details when one is shown
|
||||
container.addEventListener('sl-show', event => {
|
||||
[...container.querySelectorAll('sl-details')].map(details => (details.open = event.target === details));
|
||||
if (event.target.localName === 'sl-details') {
|
||||
[...container.querySelectorAll('sl-details')].map(details => (details.open = event.target === details));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -60,35 +60,6 @@ const App = () => (
|
|||
|
||||
## Examples
|
||||
|
||||
### Disabled
|
||||
|
||||
Add the `disabled` attribute to disable the menu item so it cannot be selected.
|
||||
|
||||
```html:preview
|
||||
<sl-menu style="max-width: 200px;">
|
||||
<sl-menu-item>Option 1</sl-menu-item>
|
||||
<sl-menu-item disabled>Option 2</sl-menu-item>
|
||||
<sl-menu-item>Option 3</sl-menu-item>
|
||||
</sl-menu>
|
||||
```
|
||||
|
||||
{% raw %}
|
||||
|
||||
```jsx:react
|
||||
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
|
||||
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
|
||||
|
||||
const App = () => (
|
||||
<SlMenu style={{ maxWidth: '200px' }}>
|
||||
<SlMenuItem>Option 1</SlMenuItem>
|
||||
<SlMenuItem disabled>Option 2</SlMenuItem>
|
||||
<SlMenuItem>Option 3</SlMenuItem>
|
||||
</SlMenu>
|
||||
);
|
||||
```
|
||||
|
||||
{% endraw %}
|
||||
|
||||
### Prefix & Suffix
|
||||
|
||||
Add content to the start and end of menu items using the `prefix` and `suffix` slots.
|
||||
|
@ -151,6 +122,64 @@ const App = () => (
|
|||
|
||||
{% endraw %}
|
||||
|
||||
### Disabled
|
||||
|
||||
Add the `disabled` attribute to disable the menu item so it cannot be selected.
|
||||
|
||||
```html:preview
|
||||
<sl-menu style="max-width: 200px;">
|
||||
<sl-menu-item>Option 1</sl-menu-item>
|
||||
<sl-menu-item disabled>Option 2</sl-menu-item>
|
||||
<sl-menu-item>Option 3</sl-menu-item>
|
||||
</sl-menu>
|
||||
```
|
||||
|
||||
{% raw %}
|
||||
|
||||
```jsx:react
|
||||
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
|
||||
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
|
||||
|
||||
const App = () => (
|
||||
<SlMenu style={{ maxWidth: '200px' }}>
|
||||
<SlMenuItem>Option 1</SlMenuItem>
|
||||
<SlMenuItem disabled>Option 2</SlMenuItem>
|
||||
<SlMenuItem>Option 3</SlMenuItem>
|
||||
</SlMenu>
|
||||
);
|
||||
```
|
||||
|
||||
{% endraw %}
|
||||
|
||||
### Loading
|
||||
|
||||
Use the `loading` attribute to indicate that a menu item is busy. Like a disabled menu item, clicks will be suppressed until the loading state is removed.
|
||||
|
||||
```html:preview
|
||||
<sl-menu style="max-width: 200px;">
|
||||
<sl-menu-item>Option 1</sl-menu-item>
|
||||
<sl-menu-item loading>Option 2</sl-menu-item>
|
||||
<sl-menu-item>Option 3</sl-menu-item>
|
||||
</sl-menu>
|
||||
```
|
||||
|
||||
{% raw %}
|
||||
|
||||
```jsx:react
|
||||
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
|
||||
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
|
||||
|
||||
const App = () => (
|
||||
<SlMenu style={{ maxWidth: '200px' }}>
|
||||
<SlMenuItem>Option 1</SlMenuItem>
|
||||
<SlMenuItem loading>Option 2</SlMenuItem>
|
||||
<SlMenuItem>Option 3</SlMenuItem>
|
||||
</SlMenu>
|
||||
);
|
||||
```
|
||||
|
||||
{% endraw %}
|
||||
|
||||
### Checkbox Menu Items
|
||||
|
||||
Set the `type` attribute to `checkbox` to create a menu item that will toggle on and off when selected. You can use the `checked` attribute to set the initial state.
|
||||
|
|
|
@ -1530,6 +1530,140 @@ const App = () => {
|
|||
};
|
||||
```
|
||||
|
||||
### Hover Bridge
|
||||
|
||||
When a gap exists between the anchor and the popup element, this option will add a "hover bridge" that fills the gap using an invisible element. This makes listening for events such as `mouseover` and `mouseout` more sane because the pointer never technically leaves the element. The hover bridge will only be drawn when the popover is active. For demonstration purposes, the bridge in this example is shown in orange.
|
||||
|
||||
```html:preview
|
||||
<div class="popup-hover-bridge">
|
||||
<sl-popup placement="top" hover-bridge distance="10" skidding="0" active>
|
||||
<span slot="anchor"></span>
|
||||
<div class="box"></div>
|
||||
</sl-popup>
|
||||
|
||||
<br>
|
||||
<sl-switch checked>Hover Bridge</sl-switch><br>
|
||||
<sl-range min="0" max="50" step="1" value="10" label="Distance"></sl-range>
|
||||
<sl-range min="-50" max="50" step="1" value="0" label="Skidding"></sl-range>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.popup-hover-bridge span[slot='anchor'] {
|
||||
display: inline-block;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: dashed 2px var(--sl-color-neutral-600);
|
||||
margin: 50px;
|
||||
}
|
||||
|
||||
.popup-hover-bridge .box {
|
||||
width: 100px;
|
||||
height: 50px;
|
||||
background: var(--sl-color-primary-600);
|
||||
border-radius: var(--sl-border-radius-medium);
|
||||
}
|
||||
|
||||
.popup-hover-bridge sl-range {
|
||||
max-width: 260px;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.popup-hover-bridge sl-popup::part(hover-bridge) {
|
||||
background: tomato;
|
||||
opacity: .5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const container = document.querySelector('.popup-hover-bridge');
|
||||
const popup = container.querySelector('sl-popup');
|
||||
const hoverBridge = container.querySelector('sl-switch');
|
||||
const distance = container.querySelector('sl-range[label="Distance"]');
|
||||
const skidding = container.querySelector('sl-range[label="Skidding"]');
|
||||
|
||||
distance.addEventListener('sl-input', () => (popup.distance = distance.value));
|
||||
skidding.addEventListener('sl-input', () => (popup.skidding = skidding.value));
|
||||
hoverBridge.addEventListener('sl-change', () => (popup.hoverBridge = hoverBridge.checked));
|
||||
</script>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { useState } from 'react';
|
||||
import SlPopup from '@shoelace-style/shoelace/dist/react/popup';
|
||||
import SlRange from '@shoelace-style/shoelace/dist/react/range';
|
||||
import SlSwitch from '@shoelace-style/shoelace/dist/react/switch';
|
||||
|
||||
const css = `
|
||||
.popup-hover-bridge span[slot='anchor'] {
|
||||
display: inline-block;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: dashed 2px var(--sl-color-neutral-600);
|
||||
margin: 50px;
|
||||
}
|
||||
|
||||
.popup-hover-bridge .box {
|
||||
width: 100px;
|
||||
height: 50px;
|
||||
background: var(--sl-color-primary-600);
|
||||
border-radius: var(--sl-border-radius-medium);
|
||||
}
|
||||
|
||||
.popup-hover-bridge sl-range {
|
||||
max-width: 260px;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.popup-hover-bridge sl-popup::part(hover-bridge) {
|
||||
background: tomato;
|
||||
opacity: .5;
|
||||
}
|
||||
`;
|
||||
|
||||
const App = () => {
|
||||
const [hoverBridge, setHoverBridge] = useState(true);
|
||||
const [distance, setDistance] = useState(10);
|
||||
const [skidding, setSkidding] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="popup-hover-bridge">
|
||||
<SlPopup placement="top" hover-bridge={hoverBridge} distance={distance} skidding={skidding} active>
|
||||
<span slot="anchor" />
|
||||
<div class="box" />
|
||||
</SlPopup>
|
||||
|
||||
<br />
|
||||
<SlSwitch
|
||||
checked={hoverBridge}
|
||||
onSlChange={event => setHoverBridge(event.target.checked)}
|
||||
>
|
||||
Hover Bridge
|
||||
</SlSwitch><br />
|
||||
<SlRange
|
||||
min="0"
|
||||
max="50"
|
||||
step="1"
|
||||
value={distance}
|
||||
label="Distance"
|
||||
onSlInput={event => setDistance(event.target.value)}
|
||||
/>
|
||||
<SlRange
|
||||
min="-50"
|
||||
max="50"
|
||||
step="1"
|
||||
value={skidding}
|
||||
label="Skidding"
|
||||
onSlInput={event => setSkidding(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>{css}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Virtual Elements
|
||||
|
||||
In most cases, popups are anchored to an actual element. Sometimes, it can be useful to anchor them to a non-element. To do this, you can pass a `VirtualElement` to the anchor property. A virtual element must contain a function called `getBoundingClientRect()` that returns a [`DOMRect`](https://developer.mozilla.org/en-US/docs/Web/API/DOMRect) object as shown below.
|
||||
|
@ -1705,3 +1839,15 @@ const App = () => {
|
|||
);
|
||||
};
|
||||
```
|
||||
|
||||
Sometimes the `getBoundingClientRects` might be derived from a real element. In this case provide the anchor element as context to ensure clipping and position updates for the popup work well.
|
||||
|
||||
```ts
|
||||
const virtualElement = {
|
||||
getBoundingClientRect() {
|
||||
// ...
|
||||
return { width, height, x, y, top, left, right, bottom };
|
||||
},
|
||||
contextElement: anchorElement
|
||||
};
|
||||
```
|
||||
|
|
|
@ -233,7 +233,7 @@ import SlOption from '@shoelace-style/shoelace/dist/react/option';
|
|||
import SlSelect from '@shoelace-style/shoelace/dist/react/select';
|
||||
|
||||
const App = () => (
|
||||
<SlSelect label="Select a Few" value="option-1 option-2 option-3" multiple clearable>
|
||||
<SlSelect label="Select a Few" value={["option-1", "option-2", "option-3"]} multiple clearable>
|
||||
<SlOption value="option-1">Option 1</SlOption>
|
||||
<SlOption value="option-2">Option 2</SlOption>
|
||||
<SlOption value="option-3">Option 3</SlOption>
|
||||
|
@ -269,7 +269,7 @@ import SlOption from '@shoelace-style/shoelace/dist/react/option';
|
|||
import SlSelect from '@shoelace-style/shoelace/dist/react/select';
|
||||
|
||||
const App = () => (
|
||||
<SlSelect value="option-1 option-2" multiple clearable>
|
||||
<SlSelect value={["option-1", "option-2"]} multiple clearable>
|
||||
<SlOption value="option-1">Option 1</SlOption>
|
||||
<SlOption value="option-2">Option 2</SlOption>
|
||||
<SlOption value="option-3">Option 3</SlOption>
|
||||
|
|
|
@ -75,6 +75,20 @@ const App = () => (
|
|||
);
|
||||
```
|
||||
|
||||
### Help Text
|
||||
|
||||
Add descriptive help text to a switch with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead.
|
||||
|
||||
```html:preview
|
||||
<sl-switch help-text="What should the user know about the switch?">Label</sl-switch>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import SlSwitch from '@shoelace-style/shoelace/dist/react/checkbox';
|
||||
|
||||
const App = () => <SlSwitch help-text="What should the user know about the switch?">Label</SlSwitch>;
|
||||
```
|
||||
|
||||
### Custom Styles
|
||||
|
||||
Use the available custom properties to change how the switch is styled.
|
||||
|
|
|
@ -249,7 +249,7 @@ const App = () => (
|
|||
|
||||
### Manual Trigger
|
||||
|
||||
Tooltips can be controller programmatically by setting the `trigger` attribute to `manual`. Use the `open` attribute to control when the tooltip is shown.
|
||||
Tooltips can be controlled programmatically by setting the `trigger` attribute to `manual`. Use the `open` attribute to control when the tooltip is shown.
|
||||
|
||||
```html:preview
|
||||
<sl-button style="margin-right: 4rem;">Toggle Manually</sl-button>
|
||||
|
|
|
@ -10,16 +10,41 @@ Angular [plays nice](https://custom-elements-everywhere.com/#angular) with custo
|
|||
|
||||
## Installation
|
||||
|
||||
### Download the npm package
|
||||
|
||||
To add Shoelace to your Angular app, install the package from npm.
|
||||
|
||||
```bash
|
||||
npm install @shoelace-style/shoelace
|
||||
```
|
||||
|
||||
Next, [include a theme](/getting-started/themes) and set the [base path](/getting-started/installation#setting-the-base-path) for icons and other assets. In this example, we'll import the light theme and use the CDN as a base path.
|
||||
### Update the Angular Configuration
|
||||
|
||||
Next, [include a theme](/getting-started/themes). In this example, we'll import the light theme.
|
||||
|
||||
Its also important to load the components by using a `<script>` tag into the index.html file. However, the Angular way to do it is by adding a script configurations into your angular.json file as follows:
|
||||
|
||||
```json
|
||||
"architect": {
|
||||
"build": {
|
||||
...
|
||||
"options": {
|
||||
...
|
||||
"styles": [
|
||||
"src/styles.scss",
|
||||
"@shoelace-style/shoelace/dist/themes/light.css"
|
||||
],
|
||||
"scripts": [
|
||||
"@shoelace-style/shoelace/dist/shoelace.js"
|
||||
]
|
||||
...
|
||||
```
|
||||
|
||||
### Setting up the base path
|
||||
|
||||
Next, set the [base path](/getting-started/installation#setting-the-base-path) for icons and other assets in the `main.ts`. In this example, we'll use the CDN as a base path.
|
||||
|
||||
```jsx
|
||||
import '@shoelace-style/shoelace/%NPMDIR%/themes/light.css';
|
||||
import { setBasePath } from '@shoelace-style/shoelace/%NPMDIR%/utilities/base-path';
|
||||
|
||||
setBasePath('https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/%CDNDIR%/');
|
||||
|
|
|
@ -35,35 +35,22 @@ If you'd rather not use the CDN for assets, you can create a build task that cop
|
|||
|
||||
## Configuration
|
||||
|
||||
You'll need to tell Vue to ignore Shoelace components. This is pretty easy because they all start with `sl-`.
|
||||
|
||||
```js
|
||||
import { fileURLToPath, URL } from 'url';
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag.startsWith('sl-')
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
If you haven't configured your Vue.js project to work with custom elements/web components, follow [the instructions here](https://vuejs.org/guide/extras/web-components.html#using-custom-elements-in-vue) based on your project type to ensure your project will not throw an error when it encounters a custom element.
|
||||
|
||||
Now you can start using Shoelace components in your app!
|
||||
|
||||
## Types
|
||||
|
||||
Once you have configured your application for custom elements, you should be able to use Shoelace in your application without it causing any errors. Unfortunately, this doesn't register the custom elements to behave like components built using Vue. To provide autocomplete information and type safety for your components, you can import the Shoelace Vue types into your `tsconfig.json` to get better integration in your standard Vue and JSX templates.
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["@shoelace-style/shoelace/dist/types/vue"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### QR code generator example
|
||||
|
@ -107,13 +94,26 @@ When binding complex data such as objects and arrays, use the `.prop` modifier t
|
|||
<sl-color-picker :swatches.prop="mySwatches" />
|
||||
```
|
||||
|
||||
### Two-way Binding
|
||||
|
||||
One caveat is there's currently [no support for v-model on custom elements](https://github.com/vuejs/vue/issues/7830), but you can still achieve two-way binding manually.
|
||||
|
||||
```html
|
||||
<!-- This doesn't work -->
|
||||
<sl-input v-model="name"></sl-input>
|
||||
<!-- This works, but it's a bit longer -->
|
||||
<sl-input :value="name" @input="name = $event.target.value"></sl-input>
|
||||
```
|
||||
|
||||
If that's too verbose for your liking, you can use a custom directive instead. [This utility](https://www.npmjs.com/package/@shoelace-style/vue-sl-model) adds a custom directive that will work just like `v-model` but for Shoelace components.
|
||||
|
||||
:::tip
|
||||
Are you using Shoelace with Vue? [Help us improve this page!](https://github.com/shoelace-style/shoelace/blob/next/docs/frameworks/vue.md)
|
||||
:::
|
||||
|
||||
### Slots
|
||||
|
||||
To use Shoelace components with slots, follow the Vue documentation on using [slots with custom elements](https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue).
|
||||
Slots in Shoelace/web components are functionally the same as basic slots in Vue. Slots can be assigned to elements using the `slot` attribute followed by the name of the slot it is being assigned to.
|
||||
|
||||
Here is an example:
|
||||
|
||||
|
|
|
@ -12,6 +12,101 @@ 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).
|
||||
|
||||
## Next
|
||||
|
||||
- Fixed a bug in `<sl-radio-group>` where if a click did not contain a `<sl-radio>` it would show a console error. [#2009]
|
||||
- Fixed a bug in `<sl-split-panel>` that caused it not to recalculate it's position when going from being `display: none;` to its original display value. [#1942]
|
||||
- Fixed a bug in `<dialog>` where when it showed it would cause a layout shift. [#1967]
|
||||
- Fixed a bug in `<sl-tooltip>` that allowed unwanted text properties to leak in [#1947]
|
||||
- Fixed a bug in `<sl-button-group>` classes [#1974]
|
||||
- Fixed a bug in `<sl-textarea>` that may throw errors on `disconnectedCallback` in test environments [#1985]
|
||||
- Fixed a bug in `<sl-color-picker>` that would log a non-passive event listener warning [#2005]
|
||||
- Fixed a bug in the submenu controller that allowed submenus to go offscreen and not be scrollable [#2001]
|
||||
- Fixed a bug in `<sl-range>` that caused the tooltip position to be incorrect in some cases [#1979]
|
||||
|
||||
## 2.15.0
|
||||
|
||||
- Added the Slovenian translation [#1893]
|
||||
- Added support for `contextElement` to `VirtualElements` in `<sl-popup>` [#1874]
|
||||
- Added the `spinner` and `spinner__base` parts to `<sl-tree-item>` [#1937]
|
||||
- Added the `sync` property to `<sl-dropdown>` so the menu can easily sync sizes with the trigger element [#1935]
|
||||
- Fixed a bug in `<sl-icon>` that did not properly apply mutators to spritesheets [#1927]
|
||||
- Fixed a bug in `.sl-scroll-lock` causing layout shifts [#1895]
|
||||
- Fixed a bug in `<sl-rating>` that caused the rating to not reset in some circumstances [#1877]
|
||||
- Fixed a bug in `<sl-select>` that caused the menu to not close when rendered in a shadow root [#1878]
|
||||
- Fixed a bug in `<sl-tree>` that caused a new stacking context resulting in tooltips being clipped [#1709]
|
||||
- Fixed a bug in `<sl-tab-group>` that caused the scroll controls to toggle indefinitely when zoomed in Safari [#1839]
|
||||
- Fixed a bug in the submenu controller that allowed two submenus to be open at the same time [#1880]
|
||||
- Fixed a bug in `<sl-select>` where the tag size wouldn't update with the control's size [#1886]
|
||||
- Fixed a bug in `<sl-checkbox>` and `<sl-switch>` where the color of the required content wasn't applying correctly
|
||||
- Fixed a bug in `<sl-checkbox>` where help text was incorrectly styled [#1897]
|
||||
- Fixed a bug in `<sl-input>` that prevented the control from receiving focus when clicking over the clear button
|
||||
- Fixed a bug in `<sl-carousel>` that caused the carousel to be out of sync when used with reduced motion settings [#1887]
|
||||
- Fixed a bug in `<sl-button-group>` that caused styles to stop working when using `className` on buttons in React [#1926]
|
||||
|
||||
## 2.14.0
|
||||
|
||||
- Added the Arabic translation [#1852]
|
||||
- Added help text to `<sl-checkbox>` [#1860]
|
||||
- Added help text to `<sl-switch>` [#1800]
|
||||
- Fixed a bug in `<sl-option>` that caused HTML tags to be included in `getTextLabel()`
|
||||
- Fixed a bug in `<sl-carousel>` that caused slides to not switch correctly [#1862]
|
||||
- Refactored component styles to be consumed more efficiently [#1692]
|
||||
|
||||
## 2.13.1
|
||||
|
||||
- Fixed a bug where the safe triangle was always visible when selecting nested `<sl-menu>` elements [#1835]
|
||||
|
||||
## 2.13.0
|
||||
|
||||
- Added the `hover-bridge` feature to `<sl-popup>` to support better tooltip accessibility [#1734]
|
||||
- Added the `loading` attribute and the `spinner` and `spinner__base` part to `<sl-menu-item>` [#1700]
|
||||
- Fixed files that did not have `.js` extensions. [#1770]
|
||||
- Fixed a bug in `<sl-tree>` when providing custom expand / collapse icons [#1922]
|
||||
- Fixed `<sl-dialog>` not accounting for elements with hidden dialog controls like `<video>` [#1755]
|
||||
- Fixed focus trapping not scrolling elements into view. [#1750]
|
||||
- Fixed more performance issues with focus trapping performance. [#1750]
|
||||
- Fixed a bug in `<sl-input>` and `<sl-textarea>` that made it work differently from `<input>` and `<textarea>` when using defaults [#1746]
|
||||
- Fixed a bug in `<sl-select>` that prevented it from closing when tabbing to another select inside a shadow root [#1763]
|
||||
- Fixed a bug in `<sl-spinner>` that caused the animation to appear strange in certain circumstances [#1787]
|
||||
- Fixed a bug in `<sl-dialog>` with focus trapping [#1813]
|
||||
- Fixed a bug that caused form controls to submit even after they were removed from the DOM [#1823]
|
||||
- Fixed a bug that caused empty `<sl-radio-group>` elements to log an error in the console [#1795]
|
||||
- Fixed a bug that caused modal scroll locking to conflict with the `scrollbar-gutter` property [#1805]
|
||||
- Fixed a bug in `<sl-option>` that caused slotted content to show up when calling `getTextLabel()` [#1730]
|
||||
- Fixed a bug in `<sl-color-picker>` that caused picker values to not match the preview color [#1831]
|
||||
- Fixed a bug in `<sl-carousel>` where pagination dots don't update when swiping slide in iOS Safari [#1748]
|
||||
- Fixed a bug in`<sl-carousel>` where trying to swipe doesn't change the slide in Firefox for Android [#1748]
|
||||
- Improved the accessibility of `<sl-tooltip>` so they persist when hovering over the tooltip and dismiss when pressing [[Esc]] [#1734]
|
||||
- Improved "close" behavior of multiple components in supportive browsers using the `CloseWatcher` API [#1788]
|
||||
- Removed the scroll controller from the experimental `<sl-carousel>` and moved all mouse related logic into the component [#1748]
|
||||
|
||||
## 2.12.0
|
||||
|
||||
- Added the Italian translation [#1727]
|
||||
- Added the ability to call `form.checkValidity()` and it will use Shoelace's custom `checkValidity()` handler. [#1708]
|
||||
- Fixed a bug where nested dialogs were not properly trapping focus. [#1711]
|
||||
- Fixed a bug with form controls removing the custom validity handlers from the form. [#1708]
|
||||
- Fixed a bug in form control components that used a `form` property, but not an attribute. [#1707]
|
||||
- Fixed a bug with bundled components using CDN builds not having translations on initial connect [#1696]
|
||||
- Fixed a bug where the `"sl-change"` event would always fire simultaneously with `"sl-input"` event in `<sl-color-picker>`. The `<sl-change>` event now only fires when a user stops dragging a slider or stops dragging on the color canvas. [#1689]
|
||||
- Updated the copy icon in the system library [#1702]
|
||||
|
||||
## 2.11.2
|
||||
|
||||
- Fixed a bug in `<sl-carousel>` component that caused an error to be thrown when rendered with Lit [#1684]
|
||||
|
||||
## 2.11.1
|
||||
|
||||
- Improved the experimental `<sl-carousel>` component [#1605]
|
||||
|
||||
## 2.11.0
|
||||
|
||||
- Added the Croatian translation [#1656]
|
||||
- Fixed a bug that caused the [[Escape]] key to stop propagating when tooltips are disabled [#1607]
|
||||
- Fixed a bug that made it impossible to style placeholders in `<sl-select>` [#1667]
|
||||
- Fixed a bug that caused `dist/react/index.js` to be blank [#1659]
|
||||
|
||||
## 2.10.0
|
||||
|
||||
- Added the Simplified Chinese translation [#1604]
|
||||
|
|
Plik diff jest za duży
Load Diff
13
package.json
13
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@shoelace-style/shoelace",
|
||||
"description": "A forward-thinking library of web components.",
|
||||
"version": "2.10.0",
|
||||
"version": "2.15.0",
|
||||
"homepage": "https://github.com/shoelace-style/shoelace",
|
||||
"author": "Cory LaViska",
|
||||
"license": "MIT",
|
||||
|
@ -49,12 +49,12 @@
|
|||
"start": "node scripts/build.js --serve",
|
||||
"build": "node scripts/build.js",
|
||||
"verify": "npm run prettier:check && npm run lint && npm run build && npm run test",
|
||||
"prepare": "npx playwright install",
|
||||
"prepublishOnly": "npm run verify",
|
||||
"prettier": "prettier --write --log-level=warn .",
|
||||
"prettier:check": "prettier --check --log-level=warn .",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"lint:fix": "eslint src --max-warnings 0 --fix",
|
||||
"ts-check": "tsc --noEmit --project ./tsconfig.json",
|
||||
"create": "plop --plopfile scripts/plop/plopfile.js",
|
||||
"test": "web-test-runner --group default",
|
||||
"test:component": "web-test-runner -- --watch --group",
|
||||
|
@ -85,9 +85,9 @@
|
|||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||
"@typescript-eslint/parser": "^6.7.5",
|
||||
"@web/dev-server-esbuild": "^0.3.6",
|
||||
"@web/test-runner": "^0.15.3",
|
||||
"@web/test-runner-commands": "^0.6.6",
|
||||
"@web/test-runner-playwright": "^0.9.0",
|
||||
"@web/test-runner": "^0.18.0",
|
||||
"@web/test-runner-commands": "^0.9.0",
|
||||
"@web/test-runner-playwright": "^0.11.0",
|
||||
"bootstrap-icons": "^1.11.1",
|
||||
"browser-sync": "^2.29.3",
|
||||
"chalk": "^5.3.0",
|
||||
|
@ -95,8 +95,9 @@
|
|||
"command-line-args": "^5.2.1",
|
||||
"comment-parser": "^1.4.0",
|
||||
"cspell": "^6.18.1",
|
||||
"custom-element-jet-brains-integration": "^1.2.1",
|
||||
"custom-element-jet-brains-integration": "^1.4.0",
|
||||
"custom-element-vs-code-integration": "^1.2.1",
|
||||
"custom-element-vuejs-integration": "^1.0.0",
|
||||
"del": "^7.1.0",
|
||||
"download": "^8.0.0",
|
||||
"esbuild": "^0.19.4",
|
||||
|
|
|
@ -19,16 +19,16 @@ const metadata = JSON.parse(fs.readFileSync(path.join(outdir, 'custom-elements.j
|
|||
const components = getAllComponents(metadata);
|
||||
const index = [];
|
||||
|
||||
components.forEach(async component => {
|
||||
for await (const component of components) {
|
||||
const tagWithoutPrefix = component.tagName.replace(/^sl-/, '');
|
||||
const componentDir = path.join(reactDir, tagWithoutPrefix);
|
||||
const componentFile = path.join(componentDir, 'index.ts');
|
||||
const importPath = component.path.replace(/\.js$/, '.component.js');
|
||||
const eventImports = (component.events || [])
|
||||
.map(event => `import type { ${event.eventName} } from '../../../src/events/events';`)
|
||||
.map(event => `import type { ${event.eventName} } from '../../events/events.js';`)
|
||||
.join('\n');
|
||||
const eventExports = (component.events || [])
|
||||
.map(event => `export type { ${event.eventName} } from '../../../src/events/events';`)
|
||||
.map(event => `export type { ${event.eventName} } from '../../events/events.js';`)
|
||||
.join('\n');
|
||||
const eventNameImport = (component.events || []).length > 0 ? `import { type EventName } from '@lit/react';` : ``;
|
||||
const events = (component.events || [])
|
||||
|
@ -73,7 +73,7 @@ components.forEach(async component => {
|
|||
index.push(`export { default as ${component.name} } from './${tagWithoutPrefix}/index.js';`);
|
||||
|
||||
fs.writeFileSync(componentFile, source, 'utf8');
|
||||
});
|
||||
}
|
||||
|
||||
// Generate the index file
|
||||
fs.writeFileSync(path.join(reactDir, 'index.ts'), index.join('\n'), 'utf8');
|
||||
|
|
|
@ -47,9 +47,19 @@ files.forEach(async file => {
|
|||
{ parser: 'babel-ts' }
|
||||
);
|
||||
|
||||
let dTs = await prettier.format(
|
||||
`
|
||||
declare const _default: import("lit").CSSResult;
|
||||
export default _default;
|
||||
`,
|
||||
{ parser: 'babel-ts' }
|
||||
);
|
||||
|
||||
const cssFile = path.join(themesDir, path.basename(file));
|
||||
const jsFile = path.join(themesDir, path.basename(file).replace('.css', '.styles.js'));
|
||||
const dTsFile = path.join(themesDir, path.basename(file).replace('.css', '.styles.d.ts'));
|
||||
|
||||
fs.writeFileSync(cssFile, css, 'utf8');
|
||||
fs.writeFileSync(jsFile, js, 'utf8');
|
||||
fs.writeFileSync(dTsFile, dTs, 'utf8');
|
||||
});
|
||||
|
|
|
@ -2,6 +2,7 @@ import { property } from 'lit/decorators.js';
|
|||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './{{ tagWithoutPrefix tag }}.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -24,7 +25,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @cssproperty --example - An example CSS custom property.
|
||||
*/
|
||||
export default class {{ properCase tag }} extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { LocalizeController } from '../../utilities/localize.js';
|
|||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
import styles from './alert.styles.js';
|
||||
|
@ -40,7 +41,7 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
|
|||
* @animation alert.hide - The animation to use when hiding the alert.
|
||||
*/
|
||||
export default class SlAlert extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = { 'sl-icon-button': SlIconButton };
|
||||
|
||||
private autoHideTimeout: number;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: contents;
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './animated-image.styles.js';
|
||||
|
@ -20,13 +21,13 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @slot play-icon - Optional play icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot pause-icon - Optional pause icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @part - control-box - The container that surrounds the pause/play icons and provides their background.
|
||||
* @part control-box - The container that surrounds the pause/play icons and provides their background.
|
||||
*
|
||||
* @cssproperty --control-box-size - The size of the icon box.
|
||||
* @cssproperty --icon-size - The size of the play/pause icons.
|
||||
*/
|
||||
export default class SlAnimatedImage extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
@query('.animated-image__animated') animatedImage: HTMLImageElement;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--control-box-size: 3rem;
|
||||
--icon-size: calc(var(--control-box-size) * 0.625);
|
||||
|
|
|
@ -2,6 +2,7 @@ import { animations } from './animations.js';
|
|||
import { html } from 'lit';
|
||||
import { property, queryAsync } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './animation.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -20,7 +21,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* animate multiple elements, either wrap them in a single container or use multiple `<sl-animation>` elements.
|
||||
*/
|
||||
export default class SlAnimation extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
private animation?: Animation;
|
||||
private hasStarted = false;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { aTimeout, expect, fixture, oneEvent } from '@open-wc/testing';
|
||||
import { html } from 'lit';
|
||||
import type SlAnimation from './animation.js';
|
||||
|
||||
describe('<sl-animation>', () => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||
import { html } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './avatar.styles.js';
|
||||
|
@ -25,7 +26,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @cssproperty --size - The size of the avatar.
|
||||
*/
|
||||
export default class SlAvatar extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon
|
||||
};
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './badge.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -16,7 +17,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlBadge extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
/** The badge's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { HasSlotController } from '../../internal/slot.js';
|
|||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './breadcrumb-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -26,7 +27,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @csspart separator - The container that wraps the separator.
|
||||
*/
|
||||
export default class SlBreadcrumbItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './breadcrumb.styles.js';
|
||||
|
@ -21,7 +22,7 @@ import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item.js';
|
|||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlBreadcrumb extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './button-group.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -15,7 +16,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlButtonGroup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
|
@ -29,22 +30,22 @@ export default class SlButtonGroup extends ShoelaceElement {
|
|||
|
||||
private handleFocus(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--focus');
|
||||
button?.toggleAttribute('data-sl-button-group__button--focus', true);
|
||||
}
|
||||
|
||||
private handleBlur(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--focus');
|
||||
button?.toggleAttribute('data-sl-button-group__button--focus', false);
|
||||
}
|
||||
|
||||
private handleMouseOver(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--hover');
|
||||
button?.toggleAttribute('data-sl-button-group__button--hover', true);
|
||||
}
|
||||
|
||||
private handleMouseOut(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--hover');
|
||||
button?.toggleAttribute('data-sl-button-group__button--hover', false);
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
|
@ -55,11 +56,14 @@ export default class SlButtonGroup extends ShoelaceElement {
|
|||
const button = findButton(el);
|
||||
|
||||
if (button) {
|
||||
button.classList.add('sl-button-group__button');
|
||||
button.classList.toggle('sl-button-group__button--first', index === 0);
|
||||
button.classList.toggle('sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--last', index === slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--radio', button.tagName.toLowerCase() === 'sl-radio-button');
|
||||
button.toggleAttribute('data-sl-button-group__button', true);
|
||||
button.toggleAttribute('data-sl-button-group__button--first', index === 0);
|
||||
button.toggleAttribute('data-sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);
|
||||
button.toggleAttribute('data-sl-button-group__button--last', index === slottedElements.length - 1);
|
||||
button.toggleAttribute(
|
||||
'data-sl-button-group__button--radio',
|
||||
button.tagName.toLowerCase() === 'sl-radio-button'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
|
@ -27,8 +27,8 @@ describe('<sl-button-group>', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('slotted button classes', () => {
|
||||
it('slotted buttons have the right classes applied based on their order', async () => {
|
||||
describe('slotted button data attributes', () => {
|
||||
it('slotted buttons have the right data attributes applied based on their order', async () => {
|
||||
const group = await fixture<SlButtonGroup>(html`
|
||||
<sl-button-group>
|
||||
<sl-button>Button 1 Label</sl-button>
|
||||
|
@ -38,19 +38,19 @@ describe('<sl-button-group>', () => {
|
|||
`);
|
||||
|
||||
const allButtons = group.querySelectorAll('sl-button');
|
||||
const hasGroupClass = Array.from(allButtons).every(button =>
|
||||
button.classList.contains('sl-button-group__button')
|
||||
const hasGroupAttrib = Array.from(allButtons).every(button =>
|
||||
button.hasAttribute('data-sl-button-group__button')
|
||||
);
|
||||
expect(hasGroupClass).to.be.true;
|
||||
expect(hasGroupAttrib).to.be.true;
|
||||
|
||||
expect(allButtons[0]).to.have.class('sl-button-group__button--first');
|
||||
expect(allButtons[1]).to.have.class('sl-button-group__button--inner');
|
||||
expect(allButtons[2]).to.have.class('sl-button-group__button--last');
|
||||
expect(allButtons[0]).to.have.attribute('data-sl-button-group__button--first');
|
||||
expect(allButtons[1]).to.have.attribute('data-sl-button-group__button--inner');
|
||||
expect(allButtons[2]).to.have.attribute('data-sl-button-group__button--last');
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus and blur events', () => {
|
||||
it('toggles focus class to slotted buttons on focus/blur', async () => {
|
||||
it('toggles focus data attribute to slotted buttons on focus/blur', async () => {
|
||||
const group = await fixture<SlButtonGroup>(html`
|
||||
<sl-button-group>
|
||||
<sl-button>Button 1 Label</sl-button>
|
||||
|
@ -63,16 +63,16 @@ describe('<sl-button-group>', () => {
|
|||
allButtons[0].dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
|
||||
await elementUpdated(allButtons[0]);
|
||||
expect(allButtons[0].classList.contains('sl-button-group__button--focus')).to.be.true;
|
||||
expect(allButtons[0]).to.have.attribute('data-sl-button-group__button--focus');
|
||||
|
||||
allButtons[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
await elementUpdated(allButtons[0]);
|
||||
expect(allButtons[0].classList.contains('sl-button-group__button--focus')).not.to.be.true;
|
||||
expect(allButtons[0]).to.not.have.attribute('data-sl-button-group__button--focus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mouseover and mouseout events', () => {
|
||||
it('toggles hover class to slotted buttons on mouseover/mouseout', async () => {
|
||||
it('toggles hover data attribute to slotted buttons on mouseover/mouseout', async () => {
|
||||
const group = await fixture<SlButtonGroup>(html`
|
||||
<sl-button-group>
|
||||
<sl-button>Button 1 Label</sl-button>
|
||||
|
@ -85,11 +85,12 @@ describe('<sl-button-group>', () => {
|
|||
|
||||
allButtons[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
await elementUpdated(allButtons[0]);
|
||||
expect(allButtons[0].classList.contains('sl-button-group__button--hover')).to.be.true;
|
||||
expect(allButtons[0]).to.have.attribute('data-sl-button-group__button--hover');
|
||||
|
||||
allButtons[0].dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
|
||||
await elementUpdated(allButtons[0]);
|
||||
expect(allButtons[0].classList.contains('sl-button-group__button--hover')).not.to.be.true;
|
||||
console.log(allButtons[0]);
|
||||
expect(allButtons[0]).to.not.have.attribute('data-sl-button-group__button--hover');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
|||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import SlSpinner from '../spinner/spinner.component.js';
|
||||
|
@ -38,27 +39,16 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
|||
* @csspart spinner - The spinner that shows when the button is in the loading state.
|
||||
*/
|
||||
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon,
|
||||
'sl-spinner': SlSpinner
|
||||
};
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
form: input => {
|
||||
// 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;
|
||||
const formId = input.getAttribute('form')!;
|
||||
return doc.getElementById(formId) as HTMLFormElement;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
@ -549,30 +546,30 @@ export default css`
|
|||
* buttons and we style them here instead.
|
||||
*/
|
||||
|
||||
:host(.sl-button-group__button--first:not(.sl-button-group__button--last)) .button {
|
||||
:host([data-sl-button-group__button--first]:not([data-sl-button-group__button--last])) .button {
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
:host(.sl-button-group__button--inner) .button {
|
||||
:host([data-sl-button-group__button--inner]) .button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:host(.sl-button-group__button--last:not(.sl-button-group__button--first)) .button {
|
||||
:host([data-sl-button-group__button--last]:not([data-sl-button-group__button--first])) .button {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
/* All except the first */
|
||||
:host(.sl-button-group__button:not(.sl-button-group__button--first)) {
|
||||
:host([data-sl-button-group__button]:not([data-sl-button-group__button--first])) {
|
||||
margin-inline-start: calc(-1 * var(--sl-input-border-width));
|
||||
}
|
||||
|
||||
/* Add a visual separator between solid buttons */
|
||||
:host(
|
||||
.sl-button-group__button:not(
|
||||
.sl-button-group__button--first,
|
||||
.sl-button-group__button--radio,
|
||||
[data-sl-button-group__button]:not(
|
||||
[data-sl-button-group__button--first],
|
||||
[data-sl-button-group__button--radio],
|
||||
[variant='default']
|
||||
):not(:hover)
|
||||
)
|
||||
|
@ -587,13 +584,13 @@ export default css`
|
|||
}
|
||||
|
||||
/* Bump hovered, focused, and checked buttons up so their focus ring isn't clipped */
|
||||
:host(.sl-button-group__button--hover) {
|
||||
:host([data-sl-button-group__button--hover]) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Focus and checked are always on top */
|
||||
:host(.sl-button-group__button--focus),
|
||||
:host(.sl-button-group__button[checked]) {
|
||||
:host([data-sl-button-group__button--focus]),
|
||||
:host([data-sl-button-group__button][checked]) {
|
||||
z-index: 2;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './card.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -28,7 +29,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @cssproperty --padding - The padding to use for the card's sections.
|
||||
*/
|
||||
export default class SlCard extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--border-color: var(--sl-color-neutral-200);
|
||||
--border-radius: var(--sl-border-radius-medium);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { html } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './carousel-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -15,11 +16,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
*
|
||||
*/
|
||||
export default class SlCarouselItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static isCarouselItem(node: Node) {
|
||||
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
||||
}
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--aspect-ratio: inherit;
|
||||
|
||||
|
@ -19,8 +16,8 @@ export default css`
|
|||
}
|
||||
|
||||
::slotted(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -10,7 +10,7 @@ describe('<sl-carousel-item>', () => {
|
|||
|
||||
it('should pass accessibility tests', async () => {
|
||||
// Arrange
|
||||
const el = await fixture(html` <div role="list"><sl-carousel-item></sl-carousel-item></div> `);
|
||||
const el = await fixture(html` <sl-carousel-item></sl-carousel-item> `);
|
||||
|
||||
// Assert
|
||||
await expect(el).to.be.accessible();
|
||||
|
|
|
@ -1,19 +1,22 @@
|
|||
import '../../internal/scrollend-polyfill.js';
|
||||
|
||||
import { AutoplayController } from './autoplay-controller.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { eventOptions, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { map } from 'lit/directives/map.js';
|
||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { range } from 'lit/directives/range.js';
|
||||
import { ScrollController } from './scroll-controller.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlCarouselItem from '../carousel-item/carousel-item.component.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './carousel.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { CSSResultGroup, PropertyValueMap } from 'lit';
|
||||
import type SlCarouselItem from '../carousel-item/carousel-item.component.js';
|
||||
|
||||
/**
|
||||
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
||||
|
@ -45,7 +48,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* partially visible as a scroll hint.
|
||||
*/
|
||||
export default class SlCarousel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
|
||||
|
@ -68,7 +71,7 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
|
||||
/**
|
||||
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
|
||||
* greater than one.
|
||||
* greater than one. It can't be higher than `slides-per-page`.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||
|
||||
|
@ -78,19 +81,17 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
/** 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;
|
||||
|
||||
@state() scrolling = false;
|
||||
|
||||
@state() dragging = false;
|
||||
|
||||
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;
|
||||
|
||||
|
@ -98,54 +99,55 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
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);
|
||||
this.mutationObserver.observe(this, { childList: true, subtree: false });
|
||||
this.mutationObserver.observe(this, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValueMap<SlCarousel> | Map<PropertyKey, unknown>): void {
|
||||
// Ensure the slidesPerMove is never higher than the slidesPerPage
|
||||
if (changedProperties.has('slidesPerMove') || changedProperties.has('slidesPerPage')) {
|
||||
this.slidesPerMove = Math.min(this.slidesPerMove, this.slidesPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
private getPageCount() {
|
||||
return Math.ceil(this.getSlides().length / this.slidesPerPage);
|
||||
const slidesCount = this.getSlides().length;
|
||||
const { slidesPerPage, slidesPerMove, loop } = this;
|
||||
|
||||
const pages = loop ? slidesCount / slidesPerMove : (slidesCount - slidesPerPage) / slidesPerMove + 1;
|
||||
|
||||
return Math.ceil(pages);
|
||||
}
|
||||
|
||||
private getCurrentPage() {
|
||||
return Math.ceil(this.activeSlide / this.slidesPerPage);
|
||||
return Math.ceil(this.activeSlide / this.slidesPerMove);
|
||||
}
|
||||
|
||||
private canScrollNext(): boolean {
|
||||
return this.loop || this.getCurrentPage() < this.getPageCount() - 1;
|
||||
}
|
||||
|
||||
private canScrollPrev(): boolean {
|
||||
return this.loop || this.getCurrentPage() > 0;
|
||||
}
|
||||
|
||||
/** @internal Gets all carousel items. */
|
||||
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||
return [...this.children].filter(
|
||||
(el: HTMLElement) => this.isCarouselItem(el) && (!excludeClones || !el.hasAttribute('data-clone'))
|
||||
) as SlCarouselItem[];
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
|
@ -190,31 +192,135 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
}
|
||||
}
|
||||
|
||||
private handleMouseDragStart(event: PointerEvent) {
|
||||
const canDrag = this.mouseDragging && event.button === 0;
|
||||
if (canDrag) {
|
||||
event.preventDefault();
|
||||
|
||||
document.addEventListener('pointermove', this.handleMouseDrag, { capture: true, passive: true });
|
||||
document.addEventListener('pointerup', this.handleMouseDragEnd, { capture: true, once: true });
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseDrag = (event: PointerEvent) => {
|
||||
if (!this.dragging) {
|
||||
// Start dragging if it hasn't yet
|
||||
this.scrollContainer.style.setProperty('scroll-snap-type', 'none');
|
||||
this.dragging = true;
|
||||
}
|
||||
|
||||
this.scrollContainer.scrollBy({
|
||||
left: -event.movementX,
|
||||
top: -event.movementY,
|
||||
behavior: 'instant'
|
||||
});
|
||||
};
|
||||
|
||||
private handleMouseDragEnd = () => {
|
||||
const scrollContainer = this.scrollContainer;
|
||||
|
||||
document.removeEventListener('pointermove', this.handleMouseDrag, { capture: true });
|
||||
|
||||
// get the current scroll position
|
||||
const startLeft = scrollContainer.scrollLeft;
|
||||
const startTop = scrollContainer.scrollTop;
|
||||
|
||||
// remove the scroll-snap-type property so that the browser will snap the slide to the correct position
|
||||
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||
|
||||
// fix(safari): forcing a style recalculation doesn't seem to immediately update the scroll
|
||||
// position in Safari. Setting "overflow" to "hidden" should force this behavior.
|
||||
scrollContainer.style.setProperty('overflow', 'hidden');
|
||||
|
||||
// get the final scroll position to the slide snapped by the browser
|
||||
const finalLeft = scrollContainer.scrollLeft;
|
||||
const finalTop = scrollContainer.scrollTop;
|
||||
|
||||
// restore the scroll position to the original one, so that it can be smoothly animated if needed
|
||||
scrollContainer.style.removeProperty('overflow');
|
||||
scrollContainer.style.setProperty('scroll-snap-type', 'none');
|
||||
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'instant' });
|
||||
|
||||
requestAnimationFrame(async () => {
|
||||
if (startLeft !== finalLeft || startTop !== finalTop) {
|
||||
scrollContainer.scrollTo({
|
||||
left: finalLeft,
|
||||
top: finalTop,
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth'
|
||||
});
|
||||
await waitForEvent(scrollContainer, 'scrollend');
|
||||
}
|
||||
|
||||
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||
|
||||
this.dragging = false;
|
||||
this.handleScrollEnd();
|
||||
});
|
||||
};
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private handleScroll() {
|
||||
this.scrolling = true;
|
||||
}
|
||||
|
||||
/** @internal Synchronizes the slides with the IntersectionObserver API. */
|
||||
private synchronizeSlides() {
|
||||
const io = new IntersectionObserver(
|
||||
entries => {
|
||||
io.disconnect();
|
||||
|
||||
for (const entry of entries) {
|
||||
const slide = entry.target;
|
||||
slide.toggleAttribute('inert', !entry.isIntersecting);
|
||||
slide.classList.toggle('--in-view', entry.isIntersecting);
|
||||
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
|
||||
}
|
||||
|
||||
const firstIntersecting = entries.find(entry => entry.isIntersecting);
|
||||
|
||||
if (firstIntersecting) {
|
||||
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, 'instant');
|
||||
} else {
|
||||
const slides = this.getSlides();
|
||||
|
||||
// Update the current index based on the first visible slide
|
||||
const slideIndex = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||
// Set the index to the first "snappable" slide
|
||||
this.activeSlide = Math.ceil(slideIndex / this.slidesPerMove) * this.slidesPerMove;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: this.scrollContainer,
|
||||
threshold: 0.6
|
||||
}
|
||||
);
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
io.observe(slide);
|
||||
});
|
||||
}
|
||||
|
||||
private handleScrollEnd() {
|
||||
const slides = this.getSlides();
|
||||
const entries = [...this.intersectionObserverEntries.values()];
|
||||
if (!this.scrolling || this.dragging) return;
|
||||
|
||||
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
|
||||
this.synchronizeSlides();
|
||||
|
||||
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
|
||||
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
|
||||
this.scrolling = false;
|
||||
}
|
||||
|
||||
// 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 isCarouselItem(node: Node): node is SlCarouselItem {
|
||||
return node instanceof Element && node.tagName.toLowerCase() === 'sl-carousel-item';
|
||||
}
|
||||
|
||||
private handleSlotChange = (mutations: MutationRecord[]) => {
|
||||
const needsInitialization = mutations.some(mutation =>
|
||||
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
||||
(el: HTMLElement) => this.isCarouselItem(el) && !el.hasAttribute('data-clone')
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -222,21 +328,15 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
if (needsInitialization) {
|
||||
this.initializeSlides();
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@watch('loop', { waitUntilFirstUpdate: true })
|
||||
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||
initializeSlides() {
|
||||
const slides = this.getSlides();
|
||||
const intersectionObserver = this.intersectionObserver;
|
||||
|
||||
this.intersectionObserverEntries.clear();
|
||||
|
||||
// Removes all the cloned elements from the carousel
|
||||
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
|
||||
intersectionObserver.unobserve(slide);
|
||||
|
||||
slide.classList.remove('--in-view');
|
||||
slide.classList.remove('--is-active');
|
||||
slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1));
|
||||
|
@ -246,33 +346,39 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
}
|
||||
});
|
||||
|
||||
this.updateSlidesSnap();
|
||||
|
||||
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.createClones();
|
||||
}
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
intersectionObserver.observe(slide);
|
||||
});
|
||||
this.synchronizeSlides();
|
||||
|
||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||
this.goToSlide(this.activeSlide, 'auto');
|
||||
}
|
||||
|
||||
private createClones() {
|
||||
const slides = this.getSlides();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@watch('activeSlide')
|
||||
handelSlideChange() {
|
||||
const slides = this.getSlides();
|
||||
|
@ -292,12 +398,12 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
}
|
||||
|
||||
@watch('slidesPerMove')
|
||||
handleSlidesPerMoveChange() {
|
||||
const slides = this.getSlides({ excludeClones: false });
|
||||
updateSlidesSnap() {
|
||||
const slides = this.getSlides();
|
||||
|
||||
const slidesPerMove = this.slidesPerMove;
|
||||
slides.forEach((slide, i) => {
|
||||
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
||||
const shouldSnap = (i + slidesPerMove) % slidesPerMove === 0;
|
||||
if (shouldSnap) {
|
||||
slide.style.removeProperty('scroll-snap-align');
|
||||
} else {
|
||||
|
@ -314,26 +420,13 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
}
|
||||
}
|
||||
|
||||
@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') {
|
||||
let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove;
|
||||
let canSnap = false;
|
||||
|
||||
while (!canSnap && previousIndex > 0) {
|
||||
previousIndex -= 1;
|
||||
canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0;
|
||||
}
|
||||
|
||||
this.goToSlide(previousIndex, behavior);
|
||||
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -352,13 +445,18 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
|
||||
const { slidesPerPage, loop, scrollContainer } = this;
|
||||
const { slidesPerPage, loop } = this;
|
||||
|
||||
const slides = this.getSlides();
|
||||
const slidesWithClones = this.getSlides({ excludeClones: false });
|
||||
|
||||
// No need to do anything in case there are no items in the carousel
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sets the next index without taking into account clones, if any.
|
||||
const newActiveSlide = (index + slides.length) % slides.length;
|
||||
const newActiveSlide = loop ? (index + slides.length) % slides.length : clamp(index, 0, slides.length - 1);
|
||||
this.activeSlide = newActiveSlide;
|
||||
|
||||
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
||||
|
@ -366,22 +464,30 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
|
||||
const nextSlide = slidesWithClones[nextSlideIndex];
|
||||
|
||||
this.scrollToSlide(nextSlide, prefersReducedMotion() ? 'auto' : behavior);
|
||||
}
|
||||
|
||||
private scrollToSlide(slide: HTMLElement, behavior: ScrollBehavior = 'smooth') {
|
||||
const scrollContainer = this.scrollContainer;
|
||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const nextSlideRect = nextSlide.getBoundingClientRect();
|
||||
const nextSlideRect = slide.getBoundingClientRect();
|
||||
|
||||
const nextLeft = nextSlideRect.left - scrollContainerRect.left;
|
||||
const nextTop = nextSlideRect.top - scrollContainerRect.top;
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
|
||||
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
|
||||
behavior: prefersReducedMotion() ? 'auto' : behavior
|
||||
left: nextLeft + scrollContainer.scrollLeft,
|
||||
top: nextTop + scrollContainer.scrollTop,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { scrollController, slidesPerPage } = this;
|
||||
const { slidesPerMove, scrolling } = this;
|
||||
const pagesCount = this.getPageCount();
|
||||
const currentPage = this.getCurrentPage();
|
||||
const prevEnabled = this.loop || currentPage > 0;
|
||||
const nextEnabled = this.loop || currentPage < pagesCount - 1;
|
||||
const prevEnabled = this.canScrollPrev();
|
||||
const nextEnabled = this.canScrollNext();
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
return html`
|
||||
|
@ -392,13 +498,16 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
class="${classMap({
|
||||
carousel__slides: true,
|
||||
'carousel__slides--horizontal': this.orientation === 'horizontal',
|
||||
'carousel__slides--vertical': this.orientation === 'vertical'
|
||||
'carousel__slides--vertical': this.orientation === 'vertical',
|
||||
'carousel__slides--dragging': this.dragging
|
||||
})}"
|
||||
style="--slides-per-page: ${this.slidesPerPage};"
|
||||
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
|
||||
aria-busy="${scrolling ? 'true' : 'false'}"
|
||||
aria-atomic="true"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeyDown}
|
||||
@mousedown="${this.handleMouseDragStart}"
|
||||
@scroll="${this.handleScroll}"
|
||||
@scrollend=${this.handleScrollEnd}
|
||||
>
|
||||
<slot></slot>
|
||||
|
@ -459,7 +568,7 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||
tabindex=${isActive ? '0' : '-1'}
|
||||
@click=${() => this.goToSlide(index * slidesPerPage)}
|
||||
@click=${() => this.goToSlide(index * slidesPerMove)}
|
||||
@keydown=${this.handleKeyDown}
|
||||
></button>
|
||||
`;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--slide-gap: var(--sl-spacing-medium, 1rem);
|
||||
--aspect-ratio: 16 / 9;
|
||||
|
@ -79,9 +76,7 @@ export default css`
|
|||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.carousel__slides--dragging,
|
||||
.carousel__slides--dropping {
|
||||
scroll-snap-type: unset;
|
||||
.carousel__slides--dragging {
|
||||
}
|
||||
|
||||
:host([vertical]) ::slotted(sl-carousel-item) {
|
||||
|
|
|
@ -1,10 +1,41 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { clickOnElement } from '../../internal/test.js';
|
||||
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { aTimeout, expect, fixture, html, nextFrame, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { clickOnElement, dragElement, moveMouseOnElement } from '../../internal/test.js';
|
||||
import { map } from 'lit/directives/map.js';
|
||||
import { range } from 'lit/directives/range.js';
|
||||
import { resetMouse } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type { SinonStub } from 'sinon';
|
||||
import type SlCarousel from './carousel.js';
|
||||
|
||||
describe('<sl-carousel>', () => {
|
||||
const sandbox = sinon.createSandbox();
|
||||
const ioCallbacks = new Map<IntersectionObserver, SinonStub>();
|
||||
const intersectionObserverCallbacks = () => {
|
||||
const callbacks = [...ioCallbacks.values()];
|
||||
return waitUntil(() => callbacks.every(callback => callback.called));
|
||||
};
|
||||
const OriginalIntersectionObserver = globalThis.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IntersectionObserver = class IntersectionObserverMock extends OriginalIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
const stubCallback = sandbox.stub().callsFake(callback);
|
||||
|
||||
super(stubCallback, options);
|
||||
|
||||
ioCallbacks.set(this, stubCallback);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetMouse();
|
||||
sandbox.restore();
|
||||
globalThis.IntersectionObserver = OriginalIntersectionObserver;
|
||||
ioCallbacks.clear();
|
||||
});
|
||||
|
||||
it('should render a carousel with default configuration', async () => {
|
||||
// Arrange
|
||||
const el = await fixture(html`
|
||||
|
@ -27,15 +58,11 @@ describe('<sl-carousel>', () => {
|
|||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
beforeEach(() => {
|
||||
clock = sinon.useFakeTimers({
|
||||
clock = sandbox.useFakeTimers({
|
||||
now: new Date()
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('should scroll forwards every `autoplay-interval` milliseconds', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
|
@ -45,7 +72,7 @@ describe('<sl-carousel>', () => {
|
|||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
sinon.stub(el, 'next');
|
||||
sandbox.stub(el, 'next');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
|
@ -66,7 +93,7 @@ describe('<sl-carousel>', () => {
|
|||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
sinon.stub(el, 'next');
|
||||
sandbox.stub(el, 'next');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
|
@ -89,7 +116,7 @@ describe('<sl-carousel>', () => {
|
|||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
sinon.stub(el, 'next');
|
||||
sandbox.stub(el, 'next');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
|
@ -176,7 +203,7 @@ describe('<sl-carousel>', () => {
|
|||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
sinon.stub(el, 'goToSlide');
|
||||
sandbox.stub(el, 'goToSlide');
|
||||
await el.updateComplete;
|
||||
|
||||
// Act
|
||||
|
@ -223,6 +250,36 @@ describe('<sl-carousel>', () => {
|
|||
// Assert
|
||||
expect(el.scrollContainer.style.getPropertyValue('--slides-per-page').trim()).to.be.equal('2');
|
||||
});
|
||||
|
||||
[
|
||||
[7, 2, 1, false, 6],
|
||||
[5, 3, 3, false, 2],
|
||||
[10, 2, 2, false, 5],
|
||||
[7, 2, 1, true, 7],
|
||||
[5, 3, 3, true, 2],
|
||||
[10, 2, 2, true, 5]
|
||||
].forEach(([slides, slidesPerPage, slidesPerMove, loop, expected]: [number, number, number, boolean, number]) => {
|
||||
it(`should display ${expected} pages for ${slides} slides grouped by ${slidesPerPage} and scrolled by ${slidesPerMove}${
|
||||
loop ? ' (loop)' : ''
|
||||
}`, async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel
|
||||
pagination
|
||||
navigation
|
||||
slides-per-page="${slidesPerPage}"
|
||||
slides-per-move="${slidesPerMove}"
|
||||
?loop=${loop}
|
||||
>
|
||||
${map(range(slides), i => html`<sl-carousel-item>${i}</sl-carousel-item>`)}
|
||||
</sl-carousel>
|
||||
`);
|
||||
|
||||
// Assert
|
||||
const paginationItems = el.shadowRoot!.querySelectorAll('.carousel__pagination-item');
|
||||
expect(paginationItems.length).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `slides-per-move` attribute is provided', () => {
|
||||
|
@ -230,7 +287,7 @@ describe('<sl-carousel>', () => {
|
|||
// Arrange
|
||||
const expectedSnapGranularity = 2;
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel slides-per-move="${expectedSnapGranularity}">
|
||||
<sl-carousel slides-per-page="${expectedSnapGranularity}" 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>
|
||||
|
@ -252,6 +309,96 @@ describe('<sl-carousel>', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should be possible to move by the given number of slides at a time', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel navigation slides-per-move="2" slides-per-page="2">
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 3</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 4</sl-carousel-item>
|
||||
<sl-carousel-item>Node 5</sl-carousel-item>
|
||||
<sl-carousel-item>Node 6</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
const expectedSlides = el.querySelectorAll('.expected')!;
|
||||
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||
|
||||
// Act
|
||||
await clickOnElement(nextButton);
|
||||
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await intersectionObserverCallbacks();
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
for (const expectedSlide of expectedSlides) {
|
||||
expect(expectedSlide).to.have.class('--in-view');
|
||||
expect(expectedSlide).to.be.visible;
|
||||
}
|
||||
});
|
||||
|
||||
it('should be possible to move by a number that is less than the displayed number', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel navigation slides-per-move="1" 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-item>Node 4</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 5</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 6</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
const expectedSlides = el.querySelectorAll('.expected')!;
|
||||
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||
|
||||
// Act
|
||||
await clickOnElement(nextButton);
|
||||
await aTimeout(50);
|
||||
await clickOnElement(nextButton);
|
||||
await aTimeout(50);
|
||||
await clickOnElement(nextButton);
|
||||
await aTimeout(50);
|
||||
await clickOnElement(nextButton);
|
||||
await aTimeout(50);
|
||||
await clickOnElement(nextButton);
|
||||
await aTimeout(50);
|
||||
await clickOnElement(nextButton);
|
||||
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await intersectionObserverCallbacks();
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
for (const expectedSlide of expectedSlides) {
|
||||
expect(expectedSlide).to.have.class('--in-view');
|
||||
expect(expectedSlide).to.be.visible;
|
||||
}
|
||||
});
|
||||
|
||||
it('should not be possible to move by a number that is greater than the displayed number', async () => {
|
||||
// Arrange
|
||||
const expectedSlidesPerMove = 2;
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel slides-per-page="${expectedSlidesPerMove}">
|
||||
<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-item>Node 5</sl-carousel-item>
|
||||
<sl-carousel-item>Node 6</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
|
||||
// Act
|
||||
el.slidesPerMove = 3;
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
expect(el.slidesPerMove).to.be.equal(expectedSlidesPerMove);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `orientation` attribute is provided', () => {
|
||||
|
@ -294,6 +441,53 @@ describe('<sl-carousel>', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when `mouse-dragging` attribute is provided', () => {
|
||||
// TODO(alenaksu): skipping because failing in webkit, PointerEvent.movementX and PointerEvent.movementY seem to return incorrect values
|
||||
it.skip('should be possible to drag the carousel using the mouse', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel mouse-dragging>
|
||||
<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 dragElement(el, -Math.round(el.offsetWidth * 0.75));
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await dragElement(el, -Math.round(el.offsetWidth * 0.75));
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
expect(el.activeSlide).to.be.equal(2);
|
||||
});
|
||||
|
||||
it('should be possible to interact with clickable elements', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel mouse-dragging>
|
||||
<sl-carousel-item><button>click me</button></sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
const button = el.querySelector('button')!;
|
||||
|
||||
const clickSpy = sinon.spy();
|
||||
button.addEventListener('click', clickSpy);
|
||||
|
||||
// Act
|
||||
await moveMouseOnElement(button);
|
||||
await clickOnElement(button);
|
||||
|
||||
// Assert
|
||||
expect(clickSpy).to.have.been.called;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation controls', () => {
|
||||
describe('when the user clicks the next button', () => {
|
||||
it('should scroll to the next slide', async () => {
|
||||
|
@ -306,7 +500,7 @@ describe('<sl-carousel>', () => {
|
|||
</sl-carousel>
|
||||
`);
|
||||
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||
sinon.stub(el, 'next');
|
||||
sandbox.stub(el, 'next');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
|
@ -329,10 +523,11 @@ describe('<sl-carousel>', () => {
|
|||
</sl-carousel>
|
||||
`);
|
||||
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||
sinon.stub(el, 'next');
|
||||
sandbox.stub(el, 'next');
|
||||
|
||||
el.goToSlide(2, 'auto');
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await intersectionObserverCallbacks();
|
||||
await el.updateComplete;
|
||||
|
||||
// Act
|
||||
|
@ -368,6 +563,9 @@ describe('<sl-carousel>', () => {
|
|||
// wait scroll to actual item
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
|
||||
await intersectionObserverCallbacks();
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
expect(nextButton).to.have.attribute('aria-disabled', 'false');
|
||||
expect(el.activeSlide).to.be.equal(0);
|
||||
|
@ -393,7 +591,7 @@ describe('<sl-carousel>', () => {
|
|||
await el.updateComplete;
|
||||
|
||||
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
|
||||
sinon.stub(el, 'previous');
|
||||
sandbox.stub(el, 'previous');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
|
@ -417,7 +615,7 @@ describe('<sl-carousel>', () => {
|
|||
`);
|
||||
|
||||
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
|
||||
sinon.stub(el, 'previous');
|
||||
sandbox.stub(el, 'previous');
|
||||
await el.updateComplete;
|
||||
|
||||
// Act
|
||||
|
@ -451,6 +649,8 @@ describe('<sl-carousel>', () => {
|
|||
// wait scroll to actual item
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
|
||||
await intersectionObserverCallbacks();
|
||||
|
||||
// Assert
|
||||
expect(previousButton).to.have.attribute('aria-disabled', 'false');
|
||||
expect(el.activeSlide).to.be.equal(2);
|
||||
|
@ -465,19 +665,27 @@ describe('<sl-carousel>', () => {
|
|||
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>
|
||||
<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;
|
||||
sandbox.spy(el, 'goToSlide');
|
||||
const expectedCarouselItem: HTMLElement = el.querySelector('sl-carousel-item:nth-child(2)')!;
|
||||
|
||||
// Act
|
||||
el.next();
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.goToSlide).to.have.been.calledWith(2);
|
||||
const containerRect = el.scrollContainer.getBoundingClientRect();
|
||||
const itemRect = expectedCarouselItem.getBoundingClientRect();
|
||||
|
||||
// Assert
|
||||
expect(el.goToSlide).to.have.been.calledWith(1);
|
||||
expect(itemRect.top).to.be.equal(containerRect.top);
|
||||
expect(itemRect.left).to.be.equal(containerRect.left);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -485,19 +693,34 @@ describe('<sl-carousel>', () => {
|
|||
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>
|
||||
<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;
|
||||
const expectedCarouselItem: HTMLElement = el.querySelector('sl-carousel-item:nth-child(1)')!;
|
||||
|
||||
el.goToSlide(1);
|
||||
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await intersectionObserverCallbacks();
|
||||
await nextFrame();
|
||||
|
||||
sandbox.spy(el, 'goToSlide');
|
||||
|
||||
// Act
|
||||
el.previous();
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await intersectionObserverCallbacks();
|
||||
|
||||
expect(el.goToSlide).to.have.been.calledWith(-2);
|
||||
const containerRect = el.scrollContainer.getBoundingClientRect();
|
||||
const itemRect = expectedCarouselItem.getBoundingClientRect();
|
||||
|
||||
// Assert
|
||||
expect(el.goToSlide).to.have.been.calledWith(0);
|
||||
expect(itemRect.top).to.be.equal(containerRect.top);
|
||||
expect(itemRect.left).to.be.equal(containerRect.left);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -516,6 +739,7 @@ describe('<sl-carousel>', () => {
|
|||
// Act
|
||||
el.goToSlide(2);
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await intersectionObserverCallbacks();
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
|
|
|
@ -1,169 +0,0 @@
|
|||
import { debounce } from '../../internal/debounce.js';
|
||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
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);
|
||||
}
|
||||
|
||||
async hostConnected() {
|
||||
const host = this.host;
|
||||
await host.updateComplete;
|
||||
|
||||
const scrollContainer = host.scrollContainer;
|
||||
|
||||
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
|
||||
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
|
||||
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
|
||||
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
|
||||
scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true });
|
||||
scrollContainer.addEventListener('touchend', this.handleTouchEnd);
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
const host = this.host;
|
||||
const scrollContainer = host.scrollContainer;
|
||||
|
||||
scrollContainer.removeEventListener('scroll', this.handleScroll);
|
||||
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
|
||||
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
|
||||
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
|
||||
scrollContainer.removeEventListener('touchstart', this.handleTouchStart);
|
||||
scrollContainer.removeEventListener('touchend', this.handleTouchEnd);
|
||||
}
|
||||
|
||||
handleScroll = () => {
|
||||
if (!this.scrolling) {
|
||||
this.scrolling = true;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
this.handleScrollEnd();
|
||||
};
|
||||
|
||||
@debounce(100)
|
||||
handleScrollEnd() {
|
||||
if (!this.pointers.size) {
|
||||
// If no pointer is active in the scroll area then the scroll has ended
|
||||
this.scrolling = false;
|
||||
this.host.scrollContainer.dispatchEvent(
|
||||
new CustomEvent('scrollend', {
|
||||
bubbles: false,
|
||||
cancelable: false
|
||||
})
|
||||
);
|
||||
this.host.requestUpdate();
|
||||
} else {
|
||||
// otherwise let's wait a bit more
|
||||
this.handleScrollEnd();
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pointers.add(event.pointerId);
|
||||
|
||||
const canDrag = this.mouseDragging && !this.dragging && event.button === 0;
|
||||
if (canDrag) {
|
||||
event.preventDefault();
|
||||
|
||||
this.host.scrollContainer.addEventListener('pointermove', this.handlePointerMove);
|
||||
}
|
||||
};
|
||||
|
||||
handlePointerMove = (event: PointerEvent) => {
|
||||
const scrollContainer = this.host.scrollContainer;
|
||||
|
||||
const hasMoved = !!event.movementX || !!event.movementY;
|
||||
if (!this.dragging && hasMoved) {
|
||||
// Start dragging if it hasn't yet
|
||||
scrollContainer.setPointerCapture(event.pointerId);
|
||||
this.handleDragStart();
|
||||
} else if (scrollContainer.hasPointerCapture(event.pointerId)) {
|
||||
// Ignore pointers that we are not tracking
|
||||
this.handleDrag(event);
|
||||
}
|
||||
};
|
||||
|
||||
handlePointerUp = (event: PointerEvent) => {
|
||||
this.pointers.delete(event.pointerId);
|
||||
this.host.scrollContainer.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (this.pointers.size === 0) {
|
||||
this.handleDragEnd();
|
||||
}
|
||||
};
|
||||
|
||||
handleTouchEnd = (event: TouchEvent) => {
|
||||
for (const touch of event.changedTouches) {
|
||||
this.pointers.delete(touch.identifier);
|
||||
}
|
||||
};
|
||||
|
||||
handleTouchStart = (event: TouchEvent) => {
|
||||
for (const touch of event.touches) {
|
||||
this.pointers.add(touch.identifier);
|
||||
}
|
||||
};
|
||||
|
||||
handleDragStart() {
|
||||
const host = this.host;
|
||||
|
||||
this.dragging = true;
|
||||
host.scrollContainer.style.setProperty('scroll-snap-type', 'unset');
|
||||
host.requestUpdate();
|
||||
}
|
||||
|
||||
handleDrag(event: PointerEvent) {
|
||||
this.host.scrollContainer.scrollBy({
|
||||
left: -event.movementX,
|
||||
top: -event.movementY
|
||||
});
|
||||
}
|
||||
|
||||
async handleDragEnd() {
|
||||
const host = this.host;
|
||||
const scrollContainer = host.scrollContainer;
|
||||
|
||||
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
|
||||
this.dragging = false;
|
||||
|
||||
const startLeft = scrollContainer.scrollLeft;
|
||||
const startTop = scrollContainer.scrollTop;
|
||||
|
||||
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||
const finalLeft = scrollContainer.scrollLeft;
|
||||
const finalTop = scrollContainer.scrollTop;
|
||||
|
||||
scrollContainer.style.setProperty('scroll-snap-type', 'unset');
|
||||
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
|
||||
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||
|
||||
if (this.scrolling) {
|
||||
await waitForEvent(scrollContainer, 'scrollend');
|
||||
}
|
||||
|
||||
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||
|
||||
host.requestUpdate();
|
||||
}
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import formControlStyles from '../../styles/form-control.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './checkbox.styles.js';
|
||||
|
@ -21,6 +24,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
|||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The checkbox's label.
|
||||
* @slot help-text - Text that describes how to use the checkbox. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-blur - Emitted when the checkbox loses focus.
|
||||
* @event sl-change - Emitted when the checked state changes.
|
||||
|
@ -35,9 +39,10 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
|||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart indeterminate-icon - The indeterminate icon, an `<sl-icon>` element.
|
||||
* @csspart label - The container that wraps the checkbox's label.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
*/
|
||||
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, formControlStyles, styles];
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
|
@ -45,6 +50,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
|||
defaultValue: (control: SlCheckbox) => control.defaultChecked,
|
||||
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text');
|
||||
|
||||
@query('input[type="checkbox"]') input: HTMLInputElement;
|
||||
|
||||
|
@ -86,6 +92,9 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
|||
/** Makes the checkbox a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** The checkbox's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
|
@ -178,68 +187,93 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
|||
}
|
||||
|
||||
render() {
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
|
||||
//
|
||||
// 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"
|
||||
<div
|
||||
class=${classMap({
|
||||
checkbox: true,
|
||||
'checkbox--checked': this.checked,
|
||||
'checkbox--disabled': this.disabled,
|
||||
'checkbox--focused': this.hasFocus,
|
||||
'checkbox--indeterminate': this.indeterminate,
|
||||
'checkbox--small': this.size === 'small',
|
||||
'checkbox--medium': this.size === 'medium',
|
||||
'checkbox--large': this.size === 'large'
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<input
|
||||
class="checkbox__input"
|
||||
type="checkbox"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${this.name}
|
||||
value=${ifDefined(this.value)}
|
||||
.indeterminate=${live(this.indeterminate)}
|
||||
.checked=${live(this.checked)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
|
||||
<span
|
||||
part="control${this.checked ? ' control--checked' : ''}${this.indeterminate ? ' control--indeterminate' : ''}"
|
||||
class="checkbox__control"
|
||||
<label
|
||||
part="base"
|
||||
class=${classMap({
|
||||
checkbox: true,
|
||||
'checkbox--checked': this.checked,
|
||||
'checkbox--disabled': this.disabled,
|
||||
'checkbox--focused': this.hasFocus,
|
||||
'checkbox--indeterminate': this.indeterminate,
|
||||
'checkbox--small': this.size === 'small',
|
||||
'checkbox--medium': this.size === 'medium',
|
||||
'checkbox--large': this.size === 'large'
|
||||
})}
|
||||
>
|
||||
${this.checked
|
||||
? html`
|
||||
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
${!this.checked && this.indeterminate
|
||||
? html`
|
||||
<sl-icon
|
||||
part="indeterminate-icon"
|
||||
class="checkbox__indeterminate-icon"
|
||||
library="system"
|
||||
name="indeterminate"
|
||||
></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
</span>
|
||||
<input
|
||||
class="checkbox__input"
|
||||
type="checkbox"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${this.name}
|
||||
value=${ifDefined(this.value)}
|
||||
.indeterminate=${live(this.indeterminate)}
|
||||
.checked=${live(this.checked)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
aria-describedby="help-text"
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
|
||||
<div part="label" class="checkbox__label">
|
||||
<slot></slot>
|
||||
<span
|
||||
part="control${this.checked ? ' control--checked' : ''}${this.indeterminate
|
||||
? ' control--indeterminate'
|
||||
: ''}"
|
||||
class="checkbox__control"
|
||||
>
|
||||
${this.checked
|
||||
? html`
|
||||
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
${!this.checked && this.indeterminate
|
||||
? html`
|
||||
<sl-icon
|
||||
part="indeterminate-icon"
|
||||
class="checkbox__indeterminate-icon"
|
||||
library="system"
|
||||
name="indeterminate"
|
||||
></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<div part="label" class="checkbox__label">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
class="form-control__help-text"
|
||||
id="help-text"
|
||||
part="form-control-help-text"
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
@ -118,6 +115,7 @@ export default css`
|
|||
|
||||
:host([required]) .checkbox__label::after {
|
||||
content: var(--sl-input-required-content);
|
||||
color: var(--sl-input-required-content-color);
|
||||
margin-inline-start: var(--sl-input-required-content-offset);
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -23,6 +23,7 @@ describe('<sl-checkbox>', () => {
|
|||
expect(el.checked).to.be.false;
|
||||
expect(el.indeterminate).to.be.false;
|
||||
expect(el.defaultChecked).to.be.false;
|
||||
expect(el.helpText).to.equal('');
|
||||
});
|
||||
|
||||
it('should have title if title attribute is set', async () => {
|
||||
|
|
|
@ -2,14 +2,15 @@ import { clamp } from '../../internal/math.js';
|
|||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { drag } from '../../internal/drag.js';
|
||||
import { eventOptions, property, query, state } from 'lit/decorators.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { TinyColor } from '@ctrl/tinycolor';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlButton from '../button/button.component.js';
|
||||
import SlButtonGroup from '../button-group/button-group.component.js';
|
||||
|
@ -90,7 +91,7 @@ declare const EyeDropper: EyeDropperConstructor;
|
|||
* @cssproperty --swatch-size - The size of each predefined color swatch.
|
||||
*/
|
||||
export default class SlColorPicker extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
static dependencies = {
|
||||
'sl-button-group': SlButtonGroup,
|
||||
|
@ -243,7 +244,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__alpha')!;
|
||||
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
|
||||
const { width } = container.getBoundingClientRect();
|
||||
let oldValue = this.value;
|
||||
let initialValue = this.value;
|
||||
let currentValue = this.value;
|
||||
|
||||
handle.focus();
|
||||
event.preventDefault();
|
||||
|
@ -253,12 +255,17 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
this.alpha = clamp((x / width) * 100, 0, 100);
|
||||
this.syncValues();
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
oldValue = this.value;
|
||||
this.emit('sl-change');
|
||||
if (this.value !== currentValue) {
|
||||
currentValue = this.value;
|
||||
this.emit('sl-input');
|
||||
}
|
||||
},
|
||||
onStop: () => {
|
||||
if (this.value !== initialValue) {
|
||||
initialValue = this.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
@ -267,7 +274,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__hue')!;
|
||||
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
|
||||
const { width } = container.getBoundingClientRect();
|
||||
let oldValue = this.value;
|
||||
let initialValue = this.value;
|
||||
let currentValue = this.value;
|
||||
|
||||
handle.focus();
|
||||
event.preventDefault();
|
||||
|
@ -277,12 +285,17 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
this.hue = clamp((x / width) * 360, 0, 360);
|
||||
this.syncValues();
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
oldValue = this.value;
|
||||
this.emit('sl-change');
|
||||
if (this.value !== currentValue) {
|
||||
currentValue = this.value;
|
||||
this.emit('sl-input');
|
||||
}
|
||||
},
|
||||
onStop: () => {
|
||||
if (this.value !== initialValue) {
|
||||
initialValue = this.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
@ -291,7 +304,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
const grid = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__grid')!;
|
||||
const handle = grid.querySelector<HTMLElement>('.color-picker__grid-handle')!;
|
||||
const { width, height } = grid.getBoundingClientRect();
|
||||
let oldValue = this.value;
|
||||
let initialValue = this.value;
|
||||
let currentValue = this.value;
|
||||
|
||||
handle.focus();
|
||||
event.preventDefault();
|
||||
|
@ -304,13 +318,18 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
this.brightness = clamp(100 - (y / height) * 100, 0, 100);
|
||||
this.syncValues();
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
oldValue = this.value;
|
||||
this.emit('sl-change');
|
||||
if (this.value !== currentValue) {
|
||||
currentValue = this.value;
|
||||
this.emit('sl-input');
|
||||
}
|
||||
},
|
||||
onStop: () => (this.isDraggingGridHandle = false),
|
||||
onStop: () => {
|
||||
this.isDraggingGridHandle = false;
|
||||
if (this.value !== initialValue) {
|
||||
initialValue = this.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
@ -469,6 +488,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
@eventOptions({ passive: false })
|
||||
private handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
@ -649,7 +669,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
|
||||
/** Generates a hex string from HSV values. Hue must be 0-360. All other arguments must be 0-100. */
|
||||
private getHexString(hue: number, saturation: number, brightness: number, alpha = 100) {
|
||||
const color = new TinyColor(`hsva(${hue}, ${saturation}, ${brightness}, ${alpha / 100})`);
|
||||
const color = new TinyColor(`hsva(${hue}, ${saturation}%, ${brightness}%, ${alpha / 100})`);
|
||||
if (!color.isValid) {
|
||||
return '';
|
||||
}
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--grid-width: 280px;
|
||||
--grid-height: 200px;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test.js';
|
||||
import { clickOnElement, dragElement } from '../../internal/test.js';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { serialize } from '../../utilities/form.js';
|
||||
|
@ -31,11 +31,22 @@ describe('<sl-color-picker>', () => {
|
|||
|
||||
await clickOnElement(trigger); // open the dropdown
|
||||
await aTimeout(200); // wait for the dropdown to open
|
||||
await clickOnElement(grid); // click on the grid
|
||||
|
||||
// Simulate a drag event. "sl-change" should not fire until we stop dragging.
|
||||
await dragElement(grid, 2, 0, {
|
||||
afterMouseDown: () => {
|
||||
expect(changeHandler).to.have.not.been.called;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
},
|
||||
afterMouseMove: () => {
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
}
|
||||
});
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
expect(changeHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
});
|
||||
|
||||
it('should emit sl-change and sl-input when the hue slider is moved', async () => {
|
||||
|
@ -50,10 +61,22 @@ describe('<sl-color-picker>', () => {
|
|||
|
||||
await clickOnElement(trigger); // open the dropdown
|
||||
await aTimeout(200); // wait for the dropdown to open
|
||||
await clickOnElement(slider); // click on the hue slider
|
||||
// Simulate a drag event. "sl-change" should not fire until we stop dragging.
|
||||
await dragElement(slider, 20, 0, {
|
||||
afterMouseDown: () => {
|
||||
expect(changeHandler).to.have.not.been.called;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
},
|
||||
afterMouseMove: () => {
|
||||
// It's not twice because you can't change the hue of white!
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
}
|
||||
});
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
expect(changeHandler).to.have.been.calledOnce;
|
||||
// It's not twice because you can't change the hue of white!
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
|
@ -69,11 +92,22 @@ describe('<sl-color-picker>', () => {
|
|||
|
||||
await clickOnElement(trigger); // open the dropdown
|
||||
await aTimeout(200); // wait for the dropdown to open
|
||||
await clickOnElement(slider); // click on the opacity slider
|
||||
|
||||
// Simulate a drag event. "sl-change" should not fire until we stop dragging.
|
||||
await dragElement(slider, 2, 0, {
|
||||
afterMouseDown: () => {
|
||||
expect(changeHandler).to.have.not.been.called;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
},
|
||||
afterMouseMove: () => {
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
}
|
||||
});
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
expect(changeHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
});
|
||||
|
||||
it('should emit sl-change and sl-input when toggling the format', async () => {
|
||||
|
@ -326,7 +360,7 @@ describe('<sl-color-picker>', () => {
|
|||
expect(previewColor).to.equal('#ff000050');
|
||||
});
|
||||
|
||||
it('should emit sl-focus when rendered as a dropdown and focused', async () => {
|
||||
it.skip('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>
|
||||
|
|
|
@ -3,6 +3,7 @@ import { getAnimation, setDefaultAnimation } from '../../utilities/animation-reg
|
|||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import SlTooltip from '../tooltip/tooltip.component.js';
|
||||
|
@ -41,7 +42,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @animation copy.out - The animation to use when feedback icons animate out.
|
||||
*/
|
||||
export default class SlCopyButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon,
|
||||
'sl-tooltip': SlTooltip
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--error-color: var(--sl-color-danger-600);
|
||||
--success-color: var(--sl-color-success-600);
|
||||
|
|
|
@ -6,6 +6,7 @@ import { LocalizeController } from '../../utilities/localize.js';
|
|||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './details.styles.js';
|
||||
|
@ -39,7 +40,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @animation details.hide - The animation to use when hiding details. You can use `height: auto` with this animation.
|
||||
*/
|
||||
export default class SlDetails extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@ import '../../../dist/shoelace.js';
|
|||
// cspell:dictionaries lorem-ipsum
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type { SlHideEvent } from '../../events/sl-hide';
|
||||
import type { SlShowEvent } from '../../events/sl-show';
|
||||
import type SlDetails from './details';
|
||||
import type { SlHideEvent } from '../../events/sl-hide.js';
|
||||
import type { SlShowEvent } from '../../events/sl-show.js';
|
||||
import type SlDetails from './details.js';
|
||||
|
||||
describe('<sl-details>', () => {
|
||||
describe('accessibility', () => {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js
|
|||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
|
@ -66,7 +67,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* the third-party modal opens. Upon closing, call `modal.deactivateExternal()` to restore Shoelace's focus trapping.
|
||||
*/
|
||||
export default class SlDialog extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = {
|
||||
'sl-icon-button': SlIconButton
|
||||
};
|
||||
|
@ -75,6 +76,7 @@ export default class SlDialog extends ShoelaceElement {
|
|||
private readonly localize = new LocalizeController(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
public modal = new Modal(this);
|
||||
private closeWatcher: CloseWatcher | null;
|
||||
|
||||
@query('.dialog') dialog: HTMLElement;
|
||||
@query('.dialog__panel') panel: HTMLElement;
|
||||
|
@ -112,6 +114,7 @@ export default class SlDialog extends ShoelaceElement {
|
|||
super.disconnectedCallback();
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
this.closeWatcher?.destroy();
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
|
@ -130,10 +133,17 @@ export default class SlDialog extends ShoelaceElement {
|
|||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
if ('CloseWatcher' in window) {
|
||||
this.closeWatcher?.destroy();
|
||||
this.closeWatcher = new CloseWatcher();
|
||||
this.closeWatcher.onclose = () => this.requestClose('keyboard');
|
||||
} else {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
this.closeWatcher?.destroy();
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
|
@ -300,9 +310,9 @@ export default class SlDialog extends ShoelaceElement {
|
|||
`
|
||||
: ''}
|
||||
${
|
||||
'' /* The tabindex="-1" is here because the body is technically scrollable if overflowing. However, if there's no focusable elements inside, you won't actually be able to scroll it via keyboard. */
|
||||
'' /* The tabindex="-1" is here because the body is technically scrollable if overflowing. However, if there's no focusable elements inside, you won't actually be able to scroll it via keyboard. Previously this was just a <slot>, but tabindex="-1" on the slot causes children to not be focusable. https://github.com/shoelace-style/shoelace/issues/1753#issuecomment-1836803277 */
|
||||
}
|
||||
<slot part="body" class="dialog__body" tabindex="-1"></slot>
|
||||
<div part="body" class="dialog__body" tabindex="-1"><slot></slot></div>
|
||||
|
||||
<footer part="footer" class="dialog__footer">
|
||||
<slot name="footer"></slot>
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--width: 31rem;
|
||||
--header-spacing: var(--sl-spacing-large);
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
// cspell:dictionaries lorem-ipsum
|
||||
import { aTimeout, elementUpdated, expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { LitElement } from 'lit';
|
||||
import { aTimeout, elementUpdated, expect, fixture, waitUntil } from '@open-wc/testing';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlDialog from './dialog';
|
||||
import type SlDialog from './dialog.js';
|
||||
|
||||
describe('<sl-dialog>', () => {
|
||||
it('should be visible with the open attribute', async () => {
|
||||
|
@ -211,7 +211,7 @@ describe('<sl-dialog>', () => {
|
|||
// Opens modal.
|
||||
const openModalButton = container.shadowRoot?.querySelector('sl-button');
|
||||
|
||||
if (openModalButton) openModalButton.click();
|
||||
openModalButton!.click();
|
||||
|
||||
// Test tab cycling
|
||||
await pressTab();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { property } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './divider.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -15,7 +16,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @cssproperty --spacing - The spacing of the divider.
|
||||
*/
|
||||
export default class SlDivider extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
/** Draws the divider in a vertical orientation. */
|
||||
@property({ type: Boolean, reflect: true }) vertical = false;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--color: var(--sl-panel-border-color);
|
||||
--width: var(--sl-panel-border-width);
|
||||
|
|
|
@ -10,6 +10,7 @@ import { property, query } from 'lit/decorators.js';
|
|||
import { uppercaseFirstLetter } from '../../internal/string.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
|
@ -74,13 +75,14 @@ import type { CSSResultGroup } from 'lit';
|
|||
* the third-party modal opens. Upon closing, call `modal.deactivateExternal()` to restore Shoelace's focus trapping.
|
||||
*/
|
||||
export default class SlDrawer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = { 'sl-icon-button': SlIconButton };
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
public modal = new Modal(this);
|
||||
private closeWatcher: CloseWatcher | null;
|
||||
|
||||
@query('.drawer') drawer: HTMLElement;
|
||||
@query('.drawer__panel') panel: HTMLElement;
|
||||
|
@ -129,6 +131,7 @@ export default class SlDrawer extends ShoelaceElement {
|
|||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unlockBodyScrolling(this);
|
||||
this.closeWatcher?.destroy();
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
|
@ -147,11 +150,20 @@ export default class SlDrawer extends ShoelaceElement {
|
|||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
if ('CloseWatcher' in window) {
|
||||
this.closeWatcher?.destroy();
|
||||
if (!this.contained) {
|
||||
this.closeWatcher = new CloseWatcher();
|
||||
this.closeWatcher.onclose = () => this.requestClose('keyboard');
|
||||
}
|
||||
} else {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
this.closeWatcher?.destroy();
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--size: 25rem;
|
||||
--header-spacing: var(--sl-spacing-large);
|
||||
|
|
|
@ -3,7 +3,7 @@ import '../../../dist/shoelace.js';
|
|||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlDrawer from './drawer';
|
||||
import type SlDrawer from './drawer.js';
|
||||
|
||||
describe('<sl-drawer>', () => {
|
||||
it('should be visible with the open attribute', async () => {
|
||||
|
|
|
@ -3,10 +3,12 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { getTabbableBoundary } from '../../internal/tabbable.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlPopup from '../popup/popup.component.js';
|
||||
import styles from './dropdown.styles.js';
|
||||
|
@ -40,7 +42,7 @@ import type SlMenu from '../menu/menu.js';
|
|||
* @animation dropdown.hide - The animation to use when hiding the dropdown.
|
||||
*/
|
||||
export default class SlDropdown extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = { 'sl-popup': SlPopup };
|
||||
|
||||
@query('.dropdown') popup: SlPopup;
|
||||
|
@ -48,6 +50,7 @@ export default class SlDropdown extends ShoelaceElement {
|
|||
@query('.dropdown__panel') panel: HTMLSlotElement;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private closeWatcher: CloseWatcher | null;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
|
||||
|
@ -100,6 +103,11 @@ export default class SlDropdown extends ShoelaceElement {
|
|||
*/
|
||||
@property({ type: Boolean }) hoist = false;
|
||||
|
||||
/**
|
||||
* Syncs the popup width or height to that of the trigger element.
|
||||
*/
|
||||
@property({ reflect: true }) sync: 'width' | 'height' | 'both' | undefined = undefined;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
|
@ -149,7 +157,7 @@ export default class SlDropdown extends ShoelaceElement {
|
|||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
if (event.key === 'Escape' && this.open && !this.closeWatcher) {
|
||||
event.stopPropagation();
|
||||
this.focusOnTrigger();
|
||||
this.hide();
|
||||
|
@ -334,7 +342,16 @@ export default class SlDropdown extends ShoelaceElement {
|
|||
|
||||
addOpenListeners() {
|
||||
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.addEventListener('keydown', this.handleKeyDown);
|
||||
if ('CloseWatcher' in window) {
|
||||
this.closeWatcher?.destroy();
|
||||
this.closeWatcher = new CloseWatcher();
|
||||
this.closeWatcher.onclose = () => {
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
};
|
||||
} else {
|
||||
this.panel.addEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
@ -346,6 +363,7 @@ export default class SlDropdown extends ShoelaceElement {
|
|||
}
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
this.closeWatcher?.destroy();
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
|
@ -397,6 +415,7 @@ export default class SlDropdown extends ShoelaceElement {
|
|||
shift
|
||||
auto-size="vertical"
|
||||
auto-size-padding="10"
|
||||
sync=${ifDefined(this.sync ? this.sync : undefined)}
|
||||
class=${classMap({
|
||||
dropdown: true,
|
||||
'dropdown--open': this.open
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
|
@ -354,27 +354,4 @@ describe('<sl-dropdown>', () => {
|
|||
|
||||
expect(el.open).to.be.false;
|
||||
});
|
||||
|
||||
it('should close and stop propagating the keydown event when Escape is pressed and the dropdown is open ', async () => {
|
||||
const el = await fixture<SlDropdown>(html`
|
||||
<sl-dropdown open>
|
||||
<sl-button slot="trigger" caret>Toggle</sl-button>
|
||||
<sl-menu>
|
||||
<sl-menu-item>Dropdown Item 1</sl-menu-item>
|
||||
<sl-menu-item>Dropdown Item 2</sl-menu-item>
|
||||
<sl-menu-item>Dropdown Item 3</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-dropdown>
|
||||
`);
|
||||
const firstMenuItem = el.querySelector('sl-menu-item')!;
|
||||
const hideHandler = sinon.spy();
|
||||
|
||||
document.body.addEventListener('keydown', hideHandler);
|
||||
firstMenuItem.focus();
|
||||
await sendKeys({ press: 'Escape' });
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.open).to.be.false;
|
||||
expect(hideHandler).to.not.have.been.called;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
|
||||
import type SlFormatBytes from './format-bytes';
|
||||
import type SlFormatBytes from './format-bytes.js';
|
||||
|
||||
describe('<sl-format-bytes>', () => {
|
||||
describe('defaults ', () => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type SlFormatDate from './format-date';
|
||||
import type SlFormatDate from './format-date.js';
|
||||
|
||||
describe('<sl-format-date>', () => {
|
||||
describe('defaults ', () => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import type SlFormatNumber from './format-number';
|
||||
import type SlFormatNumber from './format-number.js';
|
||||
|
||||
describe('<sl-format-number>', () => {
|
||||
describe('defaults ', () => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './icon-button.styles.js';
|
||||
|
@ -21,7 +22,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlIconButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
color: var(--sl-color-neutral-600);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type SlIconButton from './icon-button';
|
||||
import type SlIconButton from './icon-button.js';
|
||||
|
||||
type LinkTarget = '_self' | '_blank' | '_parent' | '_top';
|
||||
|
||||
|
|
|
@ -3,9 +3,9 @@ import { html } from 'lit';
|
|||
import { isTemplateResult } from 'lit/directive-helpers.js';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './icon.styles.js';
|
||||
|
||||
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
|
||||
|
||||
const CACHEABLE_ERROR = Symbol();
|
||||
|
@ -33,7 +33,7 @@ interface IconSource {
|
|||
* @csspart use - The <use> element generated when using `spriteSheet: true`
|
||||
*/
|
||||
export default class SlIcon extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
private initialRender = false;
|
||||
|
||||
|
@ -42,9 +42,21 @@ export default class SlIcon extends ShoelaceElement {
|
|||
let fileData: Response;
|
||||
|
||||
if (library?.spriteSheet) {
|
||||
return html`<svg part="svg">
|
||||
this.svg = html`<svg part="svg">
|
||||
<use part="use" href="${url}"></use>
|
||||
</svg>`;
|
||||
|
||||
// Using a templateResult requires the SVG to be written to the DOM first before we can grab the SVGElement
|
||||
// to be passed to the library's mutator function.
|
||||
await this.updateComplete;
|
||||
|
||||
const svg = this.shadowRoot!.querySelector("[part='svg']")!;
|
||||
|
||||
if (typeof library.mutator === 'function') {
|
||||
library.mutator(svg as SVGElement);
|
||||
}
|
||||
|
||||
return this.svg;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { aTimeout, elementUpdated, expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { registerIconLibrary } from '../../../dist/shoelace.js';
|
||||
import type { SlErrorEvent } from '../../events/sl-error';
|
||||
import type { SlLoadEvent } from '../../events/sl-load';
|
||||
import type SlIcon from './icon';
|
||||
import type { SlErrorEvent } from '../../events/sl-error.js';
|
||||
import type { SlLoadEvent } from '../../events/sl-load.js';
|
||||
import type SlIcon from './icon.js';
|
||||
|
||||
const testLibraryIcons = {
|
||||
'test-icon1': `
|
||||
|
@ -204,6 +204,10 @@ describe('<sl-icon>', () => {
|
|||
const rect = use?.getBoundingClientRect();
|
||||
expect(rect?.width).to.equal(0);
|
||||
expect(rect?.width).to.equal(0);
|
||||
|
||||
// Make sure the mutator is applied.
|
||||
// https://github.com/shoelace-style/shoelace/issues/1925
|
||||
expect(svg?.getAttribute('fill')).to.equal('currentColor');
|
||||
});
|
||||
|
||||
// TODO: <use> svg icons don't emit a "load" or "error" event...if we can figure out how to get the event to emit errors.
|
||||
|
|
|
@ -41,8 +41,8 @@ const icons = {
|
|||
</svg>
|
||||
`,
|
||||
copy: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-files" viewBox="0 0 16 16" part="svg">
|
||||
<path d="M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"></path>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-copy" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2Zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6ZM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2Z"/>
|
||||
</svg>
|
||||
`,
|
||||
eye: `
|
||||
|
|
|
@ -6,6 +6,7 @@ import { LocalizeController } from '../../utilities/localize.js';
|
|||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './image-comparer.styles.js';
|
||||
|
@ -35,7 +36,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @cssproperty --handle-size - The size of the compare handle.
|
||||
*/
|
||||
export default class SlImageComparer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static scopedElement = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--divider-width: 2px;
|
||||
--handle-size: 2.5rem;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type SlImageComparer from './image-comparer';
|
||||
import type SlImageComparer from './image-comparer.js';
|
||||
|
||||
describe('<sl-image-comparer>', () => {
|
||||
it('should render a basic before/after', async () => {
|
||||
|
@ -231,8 +231,8 @@ describe('<sl-image-comparer>', () => {
|
|||
const handle = el.shadowRoot!.querySelector<HTMLElement>('[part~="handle"]')!;
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
const rect = base.getBoundingClientRect();
|
||||
const offsetX = rect.left + window.pageXOffset;
|
||||
const offsetY = rect.top + window.pageYOffset;
|
||||
const offsetX = rect.left + window.scrollX;
|
||||
const offsetY = rect.top + window.scrollY;
|
||||
|
||||
handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { html } from 'lit';
|
|||
import { property } from 'lit/decorators.js';
|
||||
import { requestInclude } from './request.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './include.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
@ -16,7 +17,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
|
||||
*/
|
||||
export default class SlInclude extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
|
||||
/**
|
||||
* The location of the HTML file to include. Be sure you trust the content you are including as it will be executed as
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type SlInclude from './include';
|
||||
import type SlInclude from './include.js';
|
||||
|
||||
const stubbedFetchResponse: Response = {
|
||||
headers: new Headers(),
|
||||
|
|
|
@ -8,6 +8,8 @@ import { live } from 'lit/directives/live.js';
|
|||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import formControlStyles from '../../styles/form-control.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './input.styles.js';
|
||||
|
@ -49,7 +51,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
|||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, formControlStyles, styles];
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
|
@ -249,13 +251,16 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
|||
}
|
||||
|
||||
private handleClearClick(event: MouseEvent) {
|
||||
this.value = '';
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
this.input.focus();
|
||||
event.preventDefault();
|
||||
|
||||
event.stopPropagation();
|
||||
if (this.value !== '') {
|
||||
this.value = '';
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
|
@ -347,10 +352,12 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
|||
replacement: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
selectMode?: 'select' | 'start' | 'end' | 'preserve'
|
||||
selectMode: 'select' | 'start' | 'end' | 'preserve' = 'preserve'
|
||||
) {
|
||||
// @ts-expect-error - start, end, and selectMode are optional
|
||||
this.input.setRangeText(replacement, start, end, selectMode);
|
||||
const selectionStart = start ?? this.input.selectionStart!;
|
||||
const selectionEnd = end ?? this.input.selectionEnd!;
|
||||
|
||||
this.input.setRangeText(replacement, selectionStart, selectionEnd, selectMode);
|
||||
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
|
@ -489,14 +496,11 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
|||
@blur=${this.handleBlur}
|
||||
/>
|
||||
|
||||
${hasClearIcon
|
||||
${isClearIconVisible
|
||||
? html`
|
||||
<button
|
||||
part="clear-button"
|
||||
class=${classMap({
|
||||
input__clear: true,
|
||||
'input__clear--visible': isClearIconVisible
|
||||
})}
|
||||
class="input__clear"
|
||||
type="button"
|
||||
aria-label=${this.localize.term('clearEntry')}
|
||||
@click=${this.handleClearClick}
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import formControlStyles from '../../styles/form-control.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
${formControlStyles}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
@ -252,10 +247,6 @@ export default css`
|
|||
* Clearable + Password Toggle
|
||||
*/
|
||||
|
||||
.input__clear:not(.input__clear--visible) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.input__clear,
|
||||
.input__password-toggle {
|
||||
display: inline-flex;
|
||||
|
@ -280,10 +271,6 @@ export default css`
|
|||
outline: none;
|
||||
}
|
||||
|
||||
.input--empty .input__clear {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Don't show the browser's password toggle in Edge */
|
||||
::-ms-reveal {
|
||||
display: none;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getFormControls, serialize } from '../../../dist/shoelace.js';
|
|||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
|
||||
import { sendKeys } from '@web/test-runner-commands'; // must come from the same module
|
||||
import sinon from 'sinon';
|
||||
import type SlInput from './input';
|
||||
import type SlInput from './input.js';
|
||||
|
||||
describe('<sl-input>', () => {
|
||||
it('should pass accessibility tests', async () => {
|
||||
|
@ -545,5 +545,17 @@ describe('<sl-input>', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when using the setRangeText() function', () => {
|
||||
it('should set replacement text in the correct location', async () => {
|
||||
const el = await fixture<SlInput>(html` <sl-input value="test"></sl-input> `);
|
||||
|
||||
el.focus();
|
||||
el.setSelectionRange(1, 3);
|
||||
el.setRangeText('boom');
|
||||
await el.updateComplete;
|
||||
expect(el.value).to.equal('tboomt'); // cspell:disable-line
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-input');
|
||||
});
|
||||
|
|
|
@ -5,9 +5,11 @@ import { LocalizeController } from '../../utilities/localize.js';
|
|||
import { property, query } from 'lit/decorators.js';
|
||||
import { SubmenuController } from './submenu-controller.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import SlPopup from '../popup/popup.component.js';
|
||||
import SlSpinner from '../spinner/spinner.component.js';
|
||||
import styles from './menu-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
|
@ -19,6 +21,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
*
|
||||
* @dependency sl-icon
|
||||
* @dependency sl-popup
|
||||
* @dependency sl-spinner
|
||||
*
|
||||
* @slot - The menu item's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
|
@ -30,15 +33,18 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @csspart prefix - The prefix container.
|
||||
* @csspart label - The menu item label.
|
||||
* @csspart suffix - The suffix container.
|
||||
* @csspart spinner - The spinner that shows when the menu item is in the loading state.
|
||||
* @csspart spinner__base - The spinner's base part.
|
||||
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
|
||||
*
|
||||
* @cssproperty [--submenu-offset=-2px] - The distance submenus shift to overlap the parent menu.
|
||||
*/
|
||||
export default class SlMenuItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static styles: CSSResultGroup = [componentStyles, styles];
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon,
|
||||
'sl-popup': SlPopup
|
||||
'sl-popup': SlPopup,
|
||||
'sl-spinner': SlSpinner
|
||||
};
|
||||
|
||||
private cachedTextLabel: string;
|
||||
|
@ -55,6 +61,9 @@ export default class SlMenuItem extends ShoelaceElement {
|
|||
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
|
||||
@property() value = '';
|
||||
|
||||
/** Draws the menu item in a loading state. */
|
||||
@property({ type: Boolean, reflect: true }) loading = false;
|
||||
|
||||
/** Draws the menu item in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
|
@ -158,6 +167,7 @@ export default class SlMenuItem extends ShoelaceElement {
|
|||
'menu-item--rtl': isRtl,
|
||||
'menu-item--checked': this.checked,
|
||||
'menu-item--disabled': this.disabled,
|
||||
'menu-item--loading': this.loading,
|
||||
'menu-item--has-submenu': this.isSubmenu(),
|
||||
'menu-item--submenu-expanded': isSubmenuExpanded
|
||||
})}
|
||||
|
@ -179,6 +189,7 @@ export default class SlMenuItem extends ShoelaceElement {
|
|||
</span>
|
||||
|
||||
${this.submenuController.renderSubmenu()}
|
||||
${this.loading ? html` <sl-spinner part="spinner" exportparts="base:spinner__base"></sl-spinner> ` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
@ -1,20 +1,9 @@
|
|||
import { css } from 'lit';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
|
||||
export default css`
|
||||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--submenu-offset: -2px;
|
||||
|
||||
/* Private */
|
||||
--safe-triangle-cursor-x: 0;
|
||||
--safe-triangle-cursor-y: 0;
|
||||
--safe-triangle-submenu-start-x: 0;
|
||||
--safe-triangle-submenu-start-y: 0;
|
||||
--safe-triangle-submenu-end-x: 0;
|
||||
--safe-triangle-submenu-end-y: 0;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
@ -46,6 +35,25 @@ export default css`
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-item.menu-item--loading {
|
||||
outline: none;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.menu-item.menu-item--loading *:not(sl-spinner) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menu-item--loading sl-spinner {
|
||||
--indicator-color: currentColor;
|
||||
--track-width: 1px;
|
||||
position: absolute;
|
||||
font-size: 0.75em;
|
||||
top: calc(50% - 0.5em);
|
||||
left: 0.65rem;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-item .menu-item__label {
|
||||
flex: 1 1 auto;
|
||||
display: inline-block;
|
||||
|
@ -83,9 +91,9 @@ export default css`
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
clip-path: polygon(
|
||||
var(--safe-triangle-cursor-x) var(--safe-triangle-cursor-y),
|
||||
var(--safe-triangle-submenu-start-x) var(--safe-triangle-submenu-start-y),
|
||||
var(--safe-triangle-submenu-end-x) var(--safe-triangle-submenu-end-y)
|
||||
var(--safe-triangle-cursor-x, 0) var(--safe-triangle-cursor-y, 0),
|
||||
var(--safe-triangle-submenu-start-x, 0) var(--safe-triangle-submenu-start-y, 0),
|
||||
var(--safe-triangle-submenu-end-x, 0) var(--safe-triangle-submenu-end-y, 0)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -139,4 +147,9 @@ export default css`
|
|||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
::slotted(sl-menu) {
|
||||
max-width: var(--auto-size-available-width) !important;
|
||||
max-height: var(--auto-size-available-height) !important;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -2,8 +2,8 @@ import '../../../dist/shoelace.js';
|
|||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type { SlSelectEvent } from '../../events/sl-select';
|
||||
import type SlMenuItem from './menu-item';
|
||||
import type { SlSelectEvent } from '../../events/sl-select.js';
|
||||
import type SlMenuItem from './menu-item.js';
|
||||
|
||||
describe('<sl-menu-item>', () => {
|
||||
it('should pass accessibility tests', async () => {
|
||||
|
@ -40,6 +40,7 @@ describe('<sl-menu-item>', () => {
|
|||
|
||||
expect(el.value).to.equal('');
|
||||
expect(el.disabled).to.be.false;
|
||||
expect(el.loading).to.equal(false);
|
||||
expect(el.getAttribute('aria-disabled')).to.equal('false');
|
||||
});
|
||||
|
||||
|
@ -48,6 +49,13 @@ describe('<sl-menu-item>', () => {
|
|||
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
||||
});
|
||||
|
||||
describe('when loading', () => {
|
||||
it('should have a spinner present', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item loading>Menu Item Label</sl-menu-item> `);
|
||||
expect(el.shadowRoot!.querySelector('sl-spinner')).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue