Compare commits

...

72 Commits

Author SHA1 Message Date
Cory LaViska
2c883d4e59 fix up/down focus in dropdown; closes #1472 2023-08-01 13:37:43 -04:00
Cory LaViska
75b2da9eab 2.6.0 2023-07-31 15:17:43 -04:00
Cory LaViska
9736f053d9 update version 2023-07-31 15:15:54 -04:00
Cory LaViska
d0b710c26d clear search index and other cache with cmd+shift+r 2023-07-31 15:14:25 -04:00
Cory LaViska
5b83d4d1b0 update changelog 2023-07-31 14:00:57 -04:00
Thomas Allmer
89f0f4a02c feat(details): use details and summary html tag to enable in browser searching (#1470) 2023-07-31 13:58:42 -04:00
Cory LaViska
a067ccb9e0 fix docs 2023-07-27 12:39:44 -04:00
Cory LaViska
1ccea42cca fix card borders 2023-07-26 15:20:51 -04:00
Cory LaViska
0f90dd0f54 update changelog 2023-07-25 22:20:13 -04:00
Ben Anderson
262cbc9a22 Add entry to changelog for types for react-wrapped elements (#1464) 2023-07-25 22:18:54 -04:00
Konnor Rogers
3a61d20d93 Create non-auto-registering routes (#1450)
* initial attempt at not auto defining

* add files with -

* continued work on removing auto-define

* fix component definitions

* update with new tag stuff

* fix lots of things

* fix improper scoped elements

* working through side effects

* continued react wrapper work

* update changelog

* formatting

* fixes

* update changelog

* lint / formatting

* fix version injection

* fix version injection, work on test

* fix version injection, work on test

* fix merge conflicts

* fix jsdoc null issue

* fix templates

* use exports

* working on tests

* working on registration mocking

* fix customElements test

* linting

* fix some test stuff

* clean up test

* clean up comment

* rename scopedElements to dependencies

* linting / formatting

* linting / formatting

* mark all packages external and still bundle

* set bundle false

* set bundle true

* dont minify

* fix merge conflicts

* use built shoelace-element

* fix lint errors

* prettier

* appease eslint

* appease eslint gods

* appease eslint gods

* appease eslint gods

* appease eslint gods

* add shoelace-autoloader

* move it all into 1 function

* add exportmaps note

* prettier

* add jsdelivr entrypoint

* read as utf8

* update docs with .component.js importS

* prettier
2023-07-24 13:00:07 -04:00
Cory LaViska
95f4f87eb8 update changelog 2023-07-19 15:06:25 -04:00
Cory LaViska
5b3cc0d492 add part to docs; #1460 2023-07-19 15:05:52 -04:00
Yehuda Ringler
0de39a8163 Add part to button spinner (#1460) 2023-07-19 15:04:49 -04:00
Cory LaViska
879fd7a224 wait for registration before attaching form handlers; closes #1452 2023-07-18 13:38:20 -04:00
Cory LaViska
50af138424 fix typos 2023-07-18 13:15:21 -04:00
Cory LaViska
9d592f4e08 wait longer to prevent flakiness 2023-07-18 13:13:59 -04:00
Cory LaViska
5016d27af7 remove test because we can't reliably suppress retargeted clicks 2023-07-18 13:11:08 -04:00
Chellappan
7218a19357 Replace .bind() with arrow functions in form controller,modal and slot controller (#1453) 2023-07-18 13:05:00 -04:00
Cory LaViska
33d2d4368f fix logic 2023-07-18 13:03:34 -04:00
Cory LaViska
cca40ca710 remove test because we can't reliably prevent retargeted click handlers 2023-07-18 12:58:22 -04:00
Cory LaViska
c6281859fd remove dead logic 2023-07-18 12:49:22 -04:00
Cory LaViska
956271880d fix for contained 2023-07-18 12:39:44 -04:00
Cory LaViska
201ff4efc5 fix escape key in dialog/drawer; closes #1457 2023-07-18 12:37:52 -04:00
Cory LaViska
f954233bda Revert "Move keydown handler for sl-drawer back to base div (#1459)"
This reverts commit 1e243e4257.
2023-07-18 12:12:43 -04:00
Cory LaViska
8267968b76 update output 2023-07-18 12:08:50 -04:00
Stephen Sugden
1e243e4257 Move keydown handler for sl-drawer back to base div (#1459)
* Move keydown handler for sl-drawer back to base div

This restores the stacking behaviour of drawers

See: #1457

* Autofocus panel of sl-drawer when it is open on firstUpdate
2023-07-18 11:57:16 -04:00
Cory LaViska
0b6c3a46cf Quick fixes (#1458)
* update base path docs

* fix examples

* fix broken CEM data in <sl-popup>

* Update docs/pages/getting-started/installation.md

Co-authored-by: Lindsay M <126139086+lindsaym-fa@users.noreply.github.com>

---------

Co-authored-by: Lindsay M <126139086+lindsaym-fa@users.noreply.github.com>
2023-07-17 14:05:17 -04:00
Cory LaViska
a2e58b7696 fix link 2023-07-17 10:05:00 -04:00
Cory LaViska
119d299657 remove old SPA settings 2023-07-13 17:00:23 -04:00
Cory LaViska
e8634e4178 Popup virtual elements (#1449)
* 1433: POC for comments (+ fix build.watch())

* 1433: consolidate virtualAnchor into anchor

* add virtual element examples

* update changelog

---------

Co-authored-by: Marko <marko@modelcitizen.com>
2023-07-13 16:49:57 -04:00
Cory LaViska
2e2a683d11 cleanup /index.html from search results (#1454) 2023-07-13 16:20:26 -04:00
Cory LaViska
414197acc9 unset last focused item; #1436 (#1446) 2023-07-12 14:52:13 -04:00
Ben Anderson
8fd01e1eda Add event types to react wrapper components (#1419)
* Rename SlSlideChange for consistency with other events

* Setup React event types for events used by Shoelace components

Means that consumers of Shoelace via the React wrapper will be able to
use callback methods with the correct event type, instead of having to
rely on casting and friends when using Typescript.

* Add docs demonstrating importing event types for React callbacks
2023-07-12 11:31:27 -04:00
Cory LaViska
e1ca7d1f59 Lit a11y update (#1444)
* update eslint-plugin-lit-a11y to latest

* update eslint deps

* remove aria- and role attribs from slots; closes #1422
2023-07-12 11:12:15 -04:00
Cory LaViska
f84d6939bd Doc updates (#1445)
* rename to CSS parts

* fix double dashes from merging
2023-07-11 15:23:51 -04:00
Konnor Rogers
82446e2114 Add modal tab tracking (#1403)
* add modal tab tracking

* prettier

* sort by tabindex

* sort by tabindex

* add a dialog test case for shadow roots

* add a changelog note

* add a changelog note

* prettier + test fixes

* prettier + test fixes
2023-07-07 15:32:23 -04:00
Konnor Rogers
a4f0ae9088 fix: valueAsDate now falls back to native implementation (#1399)
* fix: valueAsDate now falls back to native implementation

* changelog

* prettier

* prettier
2023-07-07 13:51:22 -04:00
Cory LaViska
fe3906f766 Don't steal focus when removing focused tree items (#1430)
* don't steal focus when removing focused tree items; #1428

* update PR link
2023-07-06 10:36:41 -04:00
Cory LaViska
c9e644f3fc Allow selecting menu items with space (#1429)
* allow selecting menu items with space; #1423

* update PR
2023-07-06 10:36:29 -04:00
Cory LaViska
8ffbd02db7 update changelog 2023-07-05 16:32:59 -04:00
Evan Harrison
e88d57d17d change .floor to .ceil in getCurrentPage; modify prev function to return to closest previous snappable index (#1420) 2023-07-05 16:26:00 -04:00
Cory LaViska
5f4de6d9f5 skip flaky tests 2023-07-03 15:59:24 -04:00
Cory LaViska
2cce87deeb add links 2023-07-03 15:50:58 -04:00
Cory LaViska
630b5b19a0 fix regression; #1417 2023-07-03 15:49:25 -04:00
Cory LaViska
2ce1451a9f fix typo 2023-07-03 15:17:43 -04:00
Cory LaViska
2d1badba96 this is why we can't have nice things 2023-07-03 13:31:28 -04:00
Cory LaViska
1b5db078a7 move aria attribs off <slot>; fixes #1417 2023-07-03 12:39:42 -04:00
Cory LaViska
91095bd63a better description for lighthouse 2023-07-03 12:38:09 -04:00
Cory LaViska
d9703a64fd restore concurrency 2023-07-03 11:25:43 -04:00
Cory LaViska
4c22e72390 update changelog 2023-07-03 11:21:39 -04:00
dhellgartner
d05b8fca20 Qr code tests (#1416)
* Add tests for qr-code

* Fix a small bug in qr-code

The background color was not passed to the
qr code

---------

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
2023-07-03 11:19:50 -04:00
Evan Harrison
afca2ad2e0 change carousel docs to use correct attr name, slides-per-page (#1415) 2023-07-03 11:14:15 -04:00
Cory LaViska
b2aa854d98 update docs 2023-06-28 15:27:21 -04:00
Cory LaViska
287fff7cf1 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-06-26 12:25:30 -04:00
Cory LaViska
136ecae4a6 2.5.2 2023-06-26 12:21:04 -04:00
Cory LaViska
cac772d5e6 update version 2023-06-26 12:20:55 -04:00
Cory LaViska
e1dedcb1b5 Fix broken links (#1407)
* add spacing

* update old links
2023-06-26 12:20:13 -04:00
Cory LaViska
c4901eca68 update old links 2023-06-26 12:17:48 -04:00
Cory LaViska
a001c2d12b add spacing 2023-06-26 12:11:15 -04:00
Eddie Cheng R4
c4c622eabd redirect the link to the right page (#1404) 2023-06-26 12:08:54 -04:00
Cory LaViska
1ae018bedd fix broken source buttons in docs (#1401) 2023-06-23 12:03:51 -04:00
Cory LaViska
24929e27c1 skip 2023-06-22 11:23:45 -04:00
Cory LaViska
33a8d92aec update version 2023-06-22 11:11:11 -04:00
Cory LaViska
32d21fa560 2.5.1 2023-06-22 11:10:15 -04:00
Cory LaViska
347d8b7f79 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-06-22 11:04:34 -04:00
Cory LaViska
8f9c15913b update changelog 2023-06-22 11:04:33 -04:00
Konnor Rogers
60d7f688eb fix extensionless imports (#1394) 2023-06-22 10:56:24 -04:00
Cory LaViska
15f914914c simplify theme toggle 2023-06-22 10:47:41 -04:00
Alan Chambers
2914475821 docs changed theme toggle to theme selector (#1395) 2023-06-22 10:44:08 -04:00
Konnor Rogers
8e831aa3e7 fix source flavors (#1388)
* fix load flavor

* Update docs/assets/scripts/code-previews.js

* fix previews

---------

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
2023-06-21 11:12:09 -04:00
Cory LaViska
985d4585c4 fixes #1387 (#1392) 2023-06-21 11:07:02 -04:00
277 changed files with 15055 additions and 13737 deletions

View File

@@ -92,7 +92,8 @@ module.exports = {
'@typescript-eslint/member-delimiter-style': 'warn',
'@typescript-eslint/method-signature-style': 'warn',
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-parameter-properties': 'error',
'@typescript-eslint/no-redundant-type-constituents': 'off',
'@typescript-eslint/parameter-properties': 'error',
'@typescript-eslint/strict-boolean-expressions': 'off'
}
},
@@ -185,6 +186,17 @@ module.exports = {
]
}
],
'import/extensions': [
'error',
'always',
{
ignorePackages: true,
pattern: {
js: 'always',
ts: 'never'
}
}
],
'import/no-duplicates': 'warn',
'sort-imports-es6-autofix/sort-imports-es6': [
2,

View File

@@ -98,6 +98,7 @@
"minlength",
"monospace",
"mousedown",
"mousemove",
"mouseup",
"multiselectable",
"nextjs",

View File

@@ -3,6 +3,7 @@ import { parse } from 'comment-parser';
import { pascalCase } from 'pascal-case';
import commandLineArgs from 'command-line-args';
import fs from 'fs';
import * as path from 'path';
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const { name, description, version, author, homepage, license } = packageData;
@@ -26,7 +27,7 @@ function replace(string, terms) {
}
export default {
globs: ['src/components/**/*.ts'],
globs: ['src/components/**/*.component.ts'],
exclude: ['**/*.styles.ts', '**/*.test.ts'],
plugins: [
// Append package data
@@ -36,7 +37,32 @@ 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',
analyzePhase({ ts, node, moduleDoc }) {
switch (node.kind) {
case ts.SyntaxKind.ClassDeclaration: {
const className = node.name.getText();
const classDoc = moduleDoc?.declarations?.find(declaration => declaration.name === className);
const importPath = moduleDoc.path;
// This is kind of a best guess at components. "thing.component.ts"
if (!importPath.endsWith('.component.ts')) {
return;
}
const tagName = 'sl-' + path.basename(importPath, '.component.ts');
classDoc.tagName = tagName;
// This used to be set to true by @customElement
classDoc.customElement = true;
}
}
}
},
// Parse custom jsDoc tags
{
name: 'shoelace-custom-tags',
@@ -58,6 +84,9 @@ export default {
});
});
// This is what allows us to map JSDOC comments to ReactWrappers.
classDoc['jsDoc'] = node.jsDoc?.map(jsDoc => jsDoc.getFullText()).join('\n');
const parsed = parse(`${customComments}\n */`);
parsed[0].tags?.forEach(t => {
switch (t.tag) {
@@ -116,6 +145,7 @@ export default {
if (classDoc?.events) {
classDoc.events.forEach(event => {
event.reactName = `on${pascalCase(event.name)}`;
event.eventName = `${pascalCase(event.name)}Event`;
});
}
}

View File

@@ -176,7 +176,7 @@
<td class="nowrap"><code>updateComplete</code></td>
<td>
A read-only promise that resolves when the component has
<a href="/getting-started/usage?id=component-rendering-and-updating">finished updating</a>.
<a href="/getting-started/usage?#component-rendering-and-updating">finished updating</a>.
</td>
<td></td>
<td></td>

View File

@@ -42,8 +42,8 @@
<script>
(() => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = localStorage.getItem('theme');
document.documentElement.classList.toggle('sl-theme-dark', theme === 'dark' || (!theme && prefersDark));
const theme = localStorage.getItem('theme') || 'auto';
document.documentElement.classList.toggle('sl-theme-dark', theme === 'dark' || (theme === 'auto' && prefersDark));
})();
</script>
@@ -80,16 +80,19 @@
<sl-icon name="twitter"></sl-icon>
</a>
{# Theme toggle #}
<button
id="theme-toggle"
type="button"
aria-label="Toggle light and dark theme"
title="Toggle theme (press backslash)"
>
<sl-icon class="only-light" name="sun-fill"></sl-icon>
<sl-icon class="only-dark" name="moon-fill"></sl-icon>
</button>
{# Theme selector #}
<sl-dropdown id="theme-selector" placement="bottom-end" distance="3">
<sl-button slot="trigger" size="small" variant="text" caret title="Press \ to toggle">
<sl-icon class="only-light" name="sun-fill"></sl-icon>
<sl-icon class="only-dark" name="moon-fill"></sl-icon>
</sl-button>
<sl-menu>
<sl-menu-item type="checkbox" value="light">Light</sl-menu-item>
<sl-menu-item type="checkbox" value="dark">Dark</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item type="checkbox" value="auto">System</sl-menu-item>
</sl-menu>
</sl-dropdown>
</div>
<aside id="sidebar" data-preserve-scroll>
@@ -103,12 +106,12 @@
</header>
<div class="sidebar-buttons">
<sl-button size="small" class="repo-button repo-button--sponsor" href="https://github.com/sponsors/claviska" target="_blank">
<sl-icon slot="prefix" name="heart"></sl-icon> Sponsor
</sl-button>
<sl-button size="small" class="repo-button repo-button--github" href="https://github.com/shoelace-style/shoelace" target="_blank">
<sl-icon slot="prefix" name="github"></sl-icon> Code
</sl-button>
<sl-button size="small" class="repo-button repo-button--star" href="https://github.com/shoelace-style/shoelace/stargazers" target="_blank">
<sl-icon slot="prefix" name="star-fill"></sl-icon> Star
</sl-button>
<sl-button size="small" class="repo-button repo-button--twitter" href="https://twitter.com/shoelace_style" target="_blank">
<sl-icon slot="prefix" name="twitter"></sl-icon> Follow
</sl-button>

View File

@@ -40,7 +40,7 @@
</li>
{% endfor %}
</ul>
</li>
</li>
<li>
<h2>Design Tokens</h2>
<ul>
@@ -51,7 +51,7 @@
<li><a href="/tokens/border-radius">Border Radius</a></li>
<li><a href="/tokens/transition">Transition</a></li>
<li><a href="/tokens/z-index">Z-index</a></li>
<li><a href="/tokens/more">More</a></li>
<li><a href="/tokens/more">More Tokens</a></li>
</ul>
</li>
<li>

View File

@@ -0,0 +1,11 @@
<svg width="733" xmlns="http://www.w3.org/2000/svg" height="733">
<circle cy="366.5" cx="366.5" r="366.5"/>
<circle cy="366.5" cx="366.5" r="336.5" fill="#fede58"/>
<path d="M325 665c-121-21-194-115-212-233v-8l-25-1-1-18h481c6 13 10 27 13 41 13 94-38 146-114 193-45 23-93 29-142 26z"/>
<path d="M372 647c52-6 98-28 138-62 28-25 46-56 51-87 4-20 1-57-5-70l-423-1c-2 56 39 118 74 157 31 34 72 54 116 63 11 2 38 2 49 0z" fill="#871945"/>
<path d="M76 342c-13-26-13-57-9-85 6-27 18-52 35-68 21-20 50-23 77-18 15 4 28 12 39 23 18 17 30 40 36 67 4 20 4 41 0 60l-6 21z"/>
<path d="M234 323c5-6 6-40 2-58-3-16-4-16-10-10-14 14-38 14-52 0-15-18-12-41 6-55 3-3 5-5 5-6-1-4-22-8-34-7-42 4-57.6 40-66.2 77-3 17-1 53 4 59H234z" fill="#fff"/>
<path d="M378 343c-2-3-6-20-7-29-5-28-1-57 11-83 15-30 41-52 72-60 29-7 57 0 82 15 26 17 45 49 50 82 2 12 2 33 0 45-1 10-5 26-8 30z"/>
<path d="M565 324c4-5 5-34 4-50-2-14-6-24-8-24-1 0-3 2-6 5-17 17-47 13-58-9-7-16-4-31 8-43 4-4 7-8 7-9 0 0-4-2-8-3-51-17-105 20-115 80-3 15 0 43 3 53z" fill="#fff"/>
<path d="M504 590s-46 40-105 53c-66 15-114-7-114-7s14-76 93-95c76-18 126 49 126 49z" fill="#f9bedd"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -48,6 +48,22 @@
document.documentElement.classList.toggle('flavor-react', flavor === 'react');
}
function syncFlavor() {
setFlavor(getFlavor());
document.querySelectorAll('.code-preview__button--html').forEach(preview => {
if (flavor === 'html') {
preview.classList.add('code-preview__button--selected');
}
});
document.querySelectorAll('.code-preview__button--react').forEach(preview => {
if (flavor === 'react') {
preview.classList.add('code-preview__button--selected');
}
});
}
const shoelaceVersion = document.documentElement.getAttribute('data-shoelace-version');
const reactVersion = '18.2.0';
const cdndir = 'cdn';
@@ -61,19 +77,7 @@
}
// Sync flavor UI on page load
setFlavor(getFlavor());
document.querySelectorAll('.code-preview__button--html').forEach(preview => {
if (flavor === 'html') {
preview.classList.add('code-preview__button--selected');
}
});
document.querySelectorAll('.code-preview__button--react').forEach(preview => {
if (flavor === 'react') {
preview.classList.add('code-preview__button--selected');
}
});
syncFlavor();
//
// Resizing previews
@@ -147,12 +151,8 @@
});
function toggleSource(codeBlock, force) {
const toggle = codeBlock.querySelector('.code-preview__toggle');
if (toggle) {
codeBlock.classList.toggle('code-preview--expanded', force === undefined ? undefined : force);
event.target.setAttribute('aria-expanded', codeBlock.classList.contains('code-preview--expanded'));
}
codeBlock.classList.toggle('code-preview--expanded', force);
event.target.setAttribute('aria-expanded', codeBlock.classList.contains('code-preview--expanded'));
}
//
@@ -243,4 +243,7 @@
form.remove();
}
});
// Set the initial flavor
window.addEventListener('turbo:load', syncFlavor);
})();

View File

@@ -74,22 +74,56 @@
})();
//
// Theme switcher
// Theme selector
//
(() => {
function toggleTheme() {
const isDark = !document.documentElement.classList.contains('sl-theme-dark');
document.documentElement.classList.toggle('sl-theme-dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
function getTheme() {
return localStorage.getItem('theme') || 'auto';
}
// Toggle the theme
document.addEventListener('click', event => {
const themeToggle = event.target.closest('#theme-toggle');
if (!themeToggle) return;
toggleTheme();
function isDark() {
if (theme === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === 'dark';
}
function setTheme(newTheme) {
theme = newTheme;
localStorage.setItem('theme', theme);
// Update the UI
updateSelection();
// Toggle the dark mode class
document.documentElement.classList.toggle('sl-theme-dark', isDark());
}
function updateSelection() {
const menu = document.querySelector('#theme-selector sl-menu');
if (!menu) return;
[...menu.querySelectorAll('sl-menu-item')].map(item => (item.checked = item.getAttribute('value') === theme));
}
let theme = getTheme();
// Selection is not preserved when changing page, so update when opening dropdown
document.addEventListener('sl-show', event => {
const themeSelector = event.target.closest('#theme-selector');
if (!themeSelector) return;
updateSelection();
});
// Listen for selections
document.addEventListener('sl-select', event => {
const menu = event.target.closest('#theme-selector sl-menu');
if (!menu) return;
setTheme(event.detail.item.value);
});
// Update the theme when the preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => setTheme(theme));
// Toggle with backslash
document.addEventListener('keydown', event => {
if (
@@ -97,9 +131,12 @@
!event.composedPath().some(el => ['input', 'textarea'].includes(el?.tagName?.toLowerCase()))
) {
event.preventDefault();
toggleTheme();
setTheme(isDark() ? 'light' : 'dark');
}
});
// Set the initial theme and sync the UI
setTheme(theme);
})();
//

View File

@@ -58,15 +58,15 @@
const clearButton = siteSearch.querySelector('.search__clear-button');
const results = siteSearch.querySelector('.search__results');
const version = document.documentElement.getAttribute('data-shoelace-version');
const animationDuration = 150;
const key = `search_${version}`;
const searchDebounce = 50;
const animationDuration = 150;
let isShowing = false;
let searchTimeout;
let searchIndex;
let map;
const loadSearchIndex = new Promise(resolve => {
const key = `search_${version}`;
const cache = localStorage.getItem(key);
const wait = 'requestIdleCallback' in window ? requestIdleCallback : requestAnimationFrame;
@@ -284,7 +284,7 @@
const a = document.createElement('a');
const displayTitle = page.title ?? '';
const displayDescription = page.description ?? '';
const displayUrl = page.url.replace(/^\//, '');
const displayUrl = page.url.replace(/^\//, '').replace(/\/$/, '');
let icon = 'file-text';
a.setAttribute('role', 'option');
@@ -357,6 +357,13 @@
}
});
// Purge cache when we press CMD+CTRL+R
document.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'r') {
localStorage.clear();
}
});
input.addEventListener('input', handleInput);
clearButton.addEventListener('click', handleClear);

View File

@@ -180,7 +180,10 @@ p {
img {
max-width: 100%;
border-radius: var(--docs-border-radius);
}
.badges img {
border-radius: var(--sl-border-radius-medium);
}
.callout img,
@@ -234,6 +237,7 @@ kbd {
border: solid 1px var(--sl-color-neutral-200);
box-shadow: inset 0 1px 0 0 var(--sl-color-neutral-0), inset 0 -1px 0 0 var(--sl-color-neutral-200);
font-family: var(--sl-font-mono);
font-size: 0.9125em;
border-radius: var(--docs-border-radius);
color: var(--sl-color-neutral-800);
padding: 0.125em 0.4em;
@@ -1054,6 +1058,31 @@ html.sidebar-open #menu-toggle {
transition: 250ms scale ease;
}
#theme-selector:not(:defined) {
/* Hide when not defined to prevent extra wide icon toolbar while loading */
display: none;
}
#theme-selector sl-menu {
/* Set an initial size to prevent width being initally too small when first opening on small screen width */
width: 140px;
}
#theme-selector sl-button {
transition: 250ms scale ease;
}
#theme-selector sl-button::part(base) {
color: var(--sl-color-neutral-0);
}
#theme-selector sl-button::part(label) {
display: flex;
padding: 0.5rem;
}
#theme-selector sl-icon {
font-size: 1.25rem;
}
.sl-theme-dark #theme-selector sl-button::part(base) {
color: var(--sl-color-neutral-1000);
}
.sl-theme-dark #icon-toolbar {
background: var(--sl-color-neutral-200);
}
@@ -1064,7 +1093,8 @@ html.sidebar-open #menu-toggle {
}
#icon-toolbar button:hover,
#icon-toolbar a:hover {
#icon-toolbar a:hover,
#theme-selector sl-button:hover {
scale: 1.1;
}
@@ -1084,6 +1114,10 @@ html.sidebar-open #menu-toggle {
font-size: 1rem;
padding: 0.5rem;
}
#theme-selector sl-icon {
font-size: 1rem;
}
}
/* Sidebar addons */
@@ -1173,6 +1207,12 @@ html.sidebar-open #menu-toggle {
font-weight: var(--sl-font-weight-normal);
}
.splash li img {
width: 1em;
height: 1em;
vertical-align: -2px;
}
.splash-end {
display: flex;
align-items: flex-end;
@@ -1219,19 +1259,6 @@ html.sidebar-open #menu-toggle {
}
}
/* Repo buttons */
.repo-button--sponsor sl-icon {
color: var(--sl-color-pink-600);
}
.repo-button--github sl-icon {
color: var(--sl-color-neutral-700);
}
.repo-button--twitter sl-icon {
color: var(--sl-color-sky-500);
}
/* Component headers */
.component-header h1 {
margin-bottom: 0;
@@ -1261,14 +1288,24 @@ html.sidebar-open #menu-toggle {
}
/* Repo buttons */
.repo-button--sponsor sl-icon {
color: var(--sl-color-pink-600);
.sidebar-buttons {
display: flex;
gap: 0.125rem;
justify-content: space-between;
}
.sidebar-buttons .repo-button {
flex: 1 1 auto;
}
.repo-button--github sl-icon {
color: var(--sl-color-neutral-700);
}
.repo-button--star sl-icon {
color: var(--sl-color-yellow-500);
}
.repo-button--twitter sl-icon {
color: var(--sl-color-sky-500);
}

View File

@@ -171,7 +171,10 @@ module.exports = function (eleventyConfig) {
this.field('c'); // content
results.forEach((result, index) => {
const url = path.join('/', path.relative(eleventyConfig.dir.output, result.outputPath)).replace(/\\/g, '/');
const url = path
.join('/', path.relative(eleventyConfig.dir.output, result.outputPath))
.replace(/\\/g, '/') // convert backslashes to forward slashes
.replace(/\/index.html$/, '/'); // convert trailing /index.html to /
const doc = new JSDOM(result.content, {
// We must set a default URL so links are parsed with a hostname. Let's use a bogus TLD so we can easily
// identify which ones are internal and which ones are external.

View File

@@ -455,7 +455,7 @@ const App = () => (
### Disabled
Use the `disabled` attribute to disable a button. Clicks will be suppressed until the disabled state is removed.
Use the `disabled` attribute to disable a button.
```html:preview
<sl-button variant="default" disabled>Default</sl-button>

View File

@@ -506,7 +506,7 @@ const App = () => {
### Multiple Slides Per View
The `slides-per-view` attribute makes it possible to display multiple slides at a time. You can also use the `slides-per-move` attribute to advance more than once slide at a time, if desired.
The `slides-per-page` attribute makes it possible to display multiple slides at a time. You can also use the `slides-per-move` attribute to advance more than once slide at a time, if desired.
```html:preview
<sl-carousel navigation pagination slides-per-page="2" slides-per-move="2">

View File

@@ -7,7 +7,7 @@ layout: component
Dropdowns consist of a trigger and a panel. By default, activating the trigger will expose the panel and interacting outside of the panel will close it.
Dropdowns are designed to work well with [menus](/components/menu) to provide a list of options the user can select from. However, dropdowns can also be used in lower-level applications (e.g. [color picker](/components/color-picker) and [select](/components/select)). The API gives you complete control over showing, hiding, and positioning the panel.
Dropdowns are designed to work well with [menus](/components/menu) to provide a list of options the user can select from. However, dropdowns can also be used in lower-level applications (e.g. [color picker](/components/color-picker)). The API gives you complete control over showing, hiding, and positioning the panel.
```html:preview
<sl-dropdown>
@@ -289,7 +289,7 @@ const App = () => (
### Hoisting
Dropdown panels will be clipped if they're inside a container that has `overflow: auto|hidden`. The `hoist` attribute forces the panel to use a fixed positioning strategy, allowing it to break out of the container. In this case, the panel will be positioned relative to its containing block, which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
Dropdown panels will be clipped if they're inside a container that has `overflow: auto|hidden`. The `hoist` attribute forces the panel to use a fixed positioning strategy, allowing it to break out of the container. In this case, the panel will be positioned relative to its [containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#Identifying_the_containing_block), which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
```html:preview
<div class="dropdown-hoist">

View File

@@ -8,7 +8,7 @@ layout: component
Shoelace comes bundled with over 1,500 icons courtesy of the [Bootstrap Icons](https://icons.getbootstrap.com/) project. These icons are part of the `default` icon library. If you prefer, you can register [custom icon libraries](#icon-libraries) as well.
:::tip
Depending on how you're loading Shoelace, you may need to copy icon assets and/or [set the base path](getting-started/installation#setting-the-base-path) so Shoelace knows where to load them from. Otherwise, icons may not appear and you'll see 404 Not Found errors in the dev console.
Depending on how you're loading Shoelace, you may need to copy icon assets and/or [set the base path](/getting-started/installation/#setting-the-base-path) so Shoelace knows where to load them from. Otherwise, icons may not appear and you'll see 404 Not Found errors in the dev console.
:::
## Default Icons

View File

@@ -984,7 +984,7 @@ const App = () => {
By default, the popup is positioned using an absolute positioning strategy. However, if your anchor is fixed or exists within a container that has `overflow: auto|hidden`, the popup risks being clipped. To work around this, you can use a fixed positioning strategy by setting the `strategy` attribute to `fixed`.
The fixed positioning strategy reduces jumpiness when the anchor is fixed and allows the popup to break out containers that clip. When using this strategy, it's important to note that the content will be positioned _relative to its containing block_, which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
The fixed positioning strategy reduces jumpiness when the anchor is fixed and allows the popup to break out containers that clip. When using this strategy, it's important to note that the content will be positioned relative to its [containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#Identifying_the_containing_block), which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
In this example, you can see how the popup breaks out of the overflow container when it's fixed. The fixed positioning strategy tends to be less performant than absolute, so avoid using it unnecessarily.
@@ -1511,3 +1511,178 @@ const App = () => {
);
};
```
### 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.
```ts
const virtualElement = {
getBoundingClientRect() {
// ...
return { width, height, x, y, top, left, right, bottom };
}
};
```
This example anchors a popup to the mouse cursor using a virtual element. As such, a mouse is required to properly view it.
```html:preview
<div class="popup-virtual-element">
<sl-popup placement="right-start">
<div class="circle"></div>
</sl-popup>
<sl-switch>Highlight mouse cursor</sl-switch>
</div>
<script>
const container = document.querySelector('.popup-virtual-element');
const popup = container.querySelector('sl-popup');
const circle = container.querySelector('.circle');
const enabled = container.querySelector('sl-switch');
let clientX = 0;
let clientY = 0;
// Set the virtual element as a property
popup.anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: clientX,
y: clientY,
top: clientY,
left: clientX,
right: clientX,
bottom: clientY
};
}
};
// Only activate the popup when the switch is checked
enabled.addEventListener('sl-change', () => {
popup.active = enabled.checked;
});
// Listen for the mouse to move
document.addEventListener('mousemove', handleMouseMove);
// Update the virtual element as the mouse moves
function handleMouseMove(event) {
clientX = event.clientX;
clientY = event.clientY;
// Reposition the popup when the virtual anchor moves
if (popup.active) {
popup.reposition();
}
}
</script>
<style>
/* If you need to set a z-index, set it on the popup part like this */
.popup-virtual-element sl-popup::part(popup) {
z-index: 1000;
pointer-events: none;
}
.popup-virtual-element .circle {
width: 100px;
height: 100px;
border: solid 4px var(--sl-color-primary-600);
border-radius: 50%;
translate: -50px -50px;
animation: 1s virtual-cursor infinite;
}
@keyframes virtual-cursor {
0% { scale: 1; }
50% { scale: 1.1; }
}
</style>
```
```jsx:react
import { useRef, useState } from 'react';
import { SlPopup, SlSwitch } from '@shoelace-style/shoelace/dist/react';
const css = `
/* If you need to set a z-index, set it on the popup part like this */
.popup-virtual-element sl-popup::part(popup) {
z-index: 1000;
pointer-events: none;
}
.popup-virtual-element .circle {
width: 100px;
height: 100px;
border: solid 4px var(--sl-color-primary-600);
border-radius: 50%;
translate: -50px -50px;
animation: 1s virtual-cursor infinite;
}
@keyframes virtual-cursor {
0% { scale: 1; }
50% { scale: 1.1; }
}
`;
const App = () => {
const [enabled, setEnabled] = useState(false);
const [clientX, setClientX] = useState(0);
const [clientY, setClientY] = useState(0);
const popup = useRef(null);
const circle = useRef(null);
const virtualElement = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: clientX,
y: clientY,
top: clientY,
left: clientX,
right: clientX,
bottom: clientY
};
}
};
// Listen for the mouse to move
document.addEventListener('mousemove', handleMouseMove);
// Update the virtual element as the mouse moves
function handleMouseMove(event) {
setClientX(event.clientX);
setClientY(event.clientY);
// Reposition the popup when the virtual anchor moves
if (popup.active) {
popup.current.reposition();
}
}
return (
<>
<div className="popup-virtual-element">
<SlPopup
ref={popup}
placement="right-start"
active={enabled}
anchor={virtualElement}
>
<div ref={circle} className="circle" />
</SlPopup>
<SlSwitch checked={enabled} onSlChange={event => setEnabled(event.target.checked)}>
Highlight mouse cursor
</SlSwitch>
</div>
<style>{css}</style>
</>
);
};
```

View File

@@ -9,13 +9,13 @@ layout: component
<sl-split-panel>
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -69,13 +69,13 @@ To set the initial position, use the `position` attribute. If no position is pro
<sl-split-panel position="75">
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -90,13 +90,13 @@ To set the initial position in pixels instead of a percentage, use the `position
<sl-split-panel position-in-pixels="150">
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -148,13 +148,13 @@ Add the `vertical` attribute to render the split panel in a vertical orientation
<sl-split-panel vertical style="height: 400px;">
<div
slot="start"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -207,13 +207,13 @@ To snap panels at specific positions while dragging, add the `snap` attribute wi
<sl-split-panel snap="100px 50%">
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -328,13 +328,13 @@ Add the `disabled` attribute to prevent the divider from being repositioned.
<sl-split-panel disabled>
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -389,13 +389,13 @@ Try resizing the example below with each option and notice how the panels respon
<sl-split-panel>
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -482,13 +482,13 @@ This examples demonstrates how you can ensure both panels are at least 150px usi
<sl-split-panel style="--min: 150px; --max: calc(100% - 150px);">
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -540,7 +540,7 @@ Create complex layouts that can be repositioned independently by nesting split p
<sl-split-panel>
<div
slot="start"
style="height: 400px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 400px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden"
>
Start
</div>
@@ -548,13 +548,13 @@ Create complex layouts that can be repositioned independently by nesting split p
<sl-split-panel vertical style="height: 400px;">
<div
slot="start"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden"
>
Top
</div>
<div
slot="end"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden"
>
Bottom
</div>
@@ -625,13 +625,13 @@ You can target the `divider` part to apply CSS properties to the divider. To add
<sl-icon slot="divider" name="grip-vertical"></sl-icon>
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@@ -684,13 +684,13 @@ Here's a more elaborate example that changes the divider's color and width and a
<sl-icon slot="divider" name="grip-vertical"></sl-icon>
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>

View File

@@ -378,7 +378,7 @@ const App = () => (
### Hoisting
Tooltips will be clipped if they're inside a container that has `overflow: auto|hidden|scroll`. The `hoist` attribute forces the tooltip to use a fixed positioning strategy, allowing it to break out of the container. In this case, the tooltip will be positioned relative to its containing block, which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
Tooltips will be clipped if they're inside a container that has `overflow: auto|hidden|scroll`. The `hoist` attribute forces the tooltip to use a fixed positioning strategy, allowing it to break out of the container. In this case, the tooltip will be positioned relative to its [containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#Identifying_the_containing_block), which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
```html:preview
<div class="tooltip-hoist">

View File

@@ -83,6 +83,25 @@ function MyComponent() {
export default MyComponent;
```
You can also import the event type for use in your callbacks, shown below.
```tsx
import { useCallback, useState } from 'react';
import { SlInput, SlInputEvent } from '@shoelace-style/shoelace/%NPMDIR%/react';
import type SlInputElement from '@shoelace-style/shoelace/%NPMDIR%/components/input/input';
function MyComponent() {
const [value, setValue] = useState('');
const onInput = useCallback((event: SlInputEvent) => {
setValue(event.detail);
}, []);
return <SlInput value={value} onSlInput={event => setValue((event.target as SlInputElement).value)} />;
}
export default MyComponent;
```
## Testing with Jest
Testing with web components can be challenging if your test environment runs in a Node environment (i.e. it doesn't run in a real browser). Fortunately, [Jest](https://jestjs.io/) has made a number of strides to support web components and provide additional browser APIs. However, it's still not a complete replication of a browser environment.

View File

@@ -6,13 +6,13 @@ meta:
# Customizing
Shoelace components can be customized at a high level through design tokens. This gives you control over theme colors and general styling. For more advanced customizations, you can make use of component parts and custom properties to target individual components.
Shoelace components can be customized at a high level through design tokens. This gives you control over theme colors and general styling. For more advanced customizations, you can make use of CSS parts and custom properties to target individual components.
## Design Tokens
Shoelace makes use of several design tokens to provide a consistent appearance across components. You can customize them and use them in your own application with pure CSS — no preprocessor required.
Design tokens offer a high-level way to customize the library with minimal effort. There are no component-specific variables, however, as design tokens are intended to be generic and highly reusable. To customize an individual component, refer to the section entitled [Component Parts](#component-parts).
Design tokens offer a high-level way to customize the library with minimal effort. There are no component-specific variables, however, as design tokens are intended to be generic and highly reusable. To customize an individual component, refer to the section entitled [CSS Parts](#css-parts).
Design tokens are accessed through CSS custom properties that are defined in your theme. Because design tokens live at the page level, they're prefixed with `--sl-` to avoid collisions with other libraries.
@@ -37,9 +37,9 @@ To customize a design token, simply override it in your stylesheet using a `:roo
Many design tokens are described further along in this documentation. For a complete list, refer to `src/themes/light.css` in the project's [source code](https://github.com/shoelace-style/shoelace/blob/current/src/themes/light.css).
## Component Parts
## CSS Parts
Whereas design tokens offer a high-level way to customize the library, component parts offer a low-level way to customize individual components. Again, this is done with pure CSS — no preprocessor required.
Whereas design tokens offer a high-level way to customize the library, CSS parts offer a low-level way to customize individual components. Again, this is done with pure CSS — no preprocessor required.
Shoelace components use a [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM) to encapsulate their styles and behaviors. As a result, you can't simply target their internals with the usual CSS selectors. Instead, components expose "parts" that can be targeted with the [CSS part selector](https://developer.mozilla.org/en-US/docs/Web/CSS/::part), or `::part()`.
@@ -76,7 +76,7 @@ At first glance, this approach might seem a bit verbose or even limiting, but it
- Customizations can be made to components with explicit selectors, such as `::part(icon)`, rather than implicit selectors, such as `.button > div > span + .icon`, that are much more fragile.
- The internal structure of a component will likely change as it evolves. By exposing component parts through an API, the internals can be reworked without fear of breaking customizations as long as its parts remain intact.
- The internal structure of a component will likely change as it evolves. By exposing CSS parts through an API, the internals can be reworked without fear of breaking customizations as long as its parts remain intact.
- It encourages us to think more about how components are designed and how customizations should be allowed before users can take advantage of them. Once we opt a part into the component's API, it's guaranteed to be supported and can't be removed until a major version of the library is released.

View File

@@ -80,9 +80,20 @@ The form will not be submitted if a required field is incomplete.
<script type="module">
const form = document.querySelector('.input-validation-required');
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
// Wait for controls to be defined before attaching form listeners
await Promise.all([
customElements.whenDefined('sl-button'),
customElements.whenDefined('sl-checkbox'),
customElements.whenDefined('sl-input'),
customElements.whenDefined('sl-option'),
customElements.whenDefined('sl-select'),
customElements.whenDefined('sl-textarea')
]).then(() => {
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
});
</script>
```
@@ -134,9 +145,16 @@ To restrict a value to a specific [pattern](https://developer.mozilla.org/en-US/
<script type="module">
const form = document.querySelector('.input-validation-pattern');
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
// Wait for controls to be defined before attaching form listeners
await Promise.all([
customElements.whenDefined('sl-button'),
customElements.whenDefined('sl-input')
]).then(() => {
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
});
</script>
```
@@ -178,9 +196,16 @@ Some input types will automatically trigger constraints, such as `email` and `ur
<script type="module">
const form = document.querySelector('.input-validation-type');
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
// Wait for controls to be defined before attaching form listeners
await Promise.all([
customElements.whenDefined('sl-button'),
customElements.whenDefined('sl-input')
]).then(() => {
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
});
</script>
```
@@ -224,17 +249,23 @@ To create a custom validation error, pass a non-empty string to the `setCustomVa
const form = document.querySelector('.input-validation-custom');
const input = form.querySelector('sl-input');
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
// Wait for controls to be defined before attaching form listeners
await Promise.all([
customElements.whenDefined('sl-button'),
customElements.whenDefined('sl-input')
]).then(() => {
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
input.addEventListener('sl-input', () => {
if (input.value === 'shoelace') {
input.setCustomValidity('');
} else {
input.setCustomValidity("Hey, you're supposed to type 'shoelace' before submitting this!");
}
input.addEventListener('sl-input', () => {
if (input.value === 'shoelace') {
input.setCustomValidity('');
} else {
input.setCustomValidity("Hey, you're supposed to type 'shoelace' before submitting this!");
}
});
});
</script>
```
@@ -326,9 +357,19 @@ This example demonstrates custom validation styles using `data-user-invalid` and
<script type="module">
const form = document.querySelector('.validity-styles');
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
// Wait for controls to be defined before attaching form listeners
await Promise.all([
customElements.whenDefined('sl-button'),
customElements.whenDefined('sl-checkbox'),
customElements.whenDefined('sl-input'),
customElements.whenDefined('sl-option'),
customElements.whenDefined('sl-select')
]).then(() => {
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
});
</script>
@@ -417,33 +458,39 @@ To disable the browser's error messages, you need to cancel the `sl-invalid` eve
const form = document.querySelector('.inline-validation');
const nameError = document.querySelector('#name-error');
// A form control is invalid
form.addEventListener(
'sl-invalid',
event => {
// Suppress the browser's constraint validation message
// Wait for controls to be defined before attaching form listeners
await Promise.all([
customElements.whenDefined('sl-button'),
customElements.whenDefined('sl-input')
]).then(() => {
// A form control is invalid
form.addEventListener(
'sl-invalid',
event => {
// Suppress the browser's constraint validation message
event.preventDefault();
nameError.textContent = `Error: ${event.target.validationMessage}`;
nameError.hidden = false;
event.target.focus();
},
{ capture: true } // you must use capture since sl-invalid doesn't bubble!
);
// Handle form submit
form.addEventListener('submit', event => {
event.preventDefault();
nameError.hidden = true;
nameError.textContent = '';
setTimeout(() => alert('All fields are valid'), 50);
});
nameError.textContent = `Error: ${event.target.validationMessage}`;
nameError.hidden = false;
event.target.focus();
},
{ capture: true } // you must use capture since sl-invalid doesn't bubble!
);
// Handle form submit
form.addEventListener('submit', event => {
event.preventDefault();
nameError.hidden = true;
nameError.textContent = '';
setTimeout(() => alert('All fields are valid'), 50);
});
// Handle form reset
form.addEventListener('reset', event => {
nameError.hidden = true;
nameError.textContent = '';
// Handle form reset
form.addEventListener('reset', event => {
nameError.hidden = true;
nameError.textContent = '';
});
});
</script>

View File

@@ -112,15 +112,29 @@ However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Sh
```
:::tip
When setting a basePath, and easy way to check if it was down properly is by checking if an icon exists.
For example, if I set the basePath to `/dist`, I should be able to go to:
`https://<my-site>/dist/assets/icons/arrow-left.svg` and the browser should show me the SVG.
Shoelace also exports a `getBasePath()` method you can use to reference assets.
An easy way to make sure the base path is configured properly is to check if [icons](/components/icon) are loading.
:::
### Referencing Assets
Most of the magic behind assets is handled internally by Shoelace, but if you need to reference the base path for any reason, the same module exports a function called `getBasePath()`. An optional string argument can be passed, allowing you to get the full path to any asset.
```html
<script type="module">
import { getBasePath, setBasePath } from '@shoelace-style/shoelace/%NPMDIR%/utilities/base-path.js';
setBasePath('/path/to/assets');
// ...
// Get the base path, e.g. /path/to/assets
const basePath = getBasePath();
// Get the path to an asset, e.g. /path/to/assets/file.ext
const assetPath = getBasePath('file.ext');
</script>
```
## Cherry Picking
Cherry picking can be done from [the CDN](#cdn-installation-easiest) or from [npm](#npm-installation). This approach will load only the components you need up front, while limiting the number of files the browser has to download. The disadvantage is that you need to import each individual component.
@@ -182,6 +196,21 @@ setBasePath('/path/to/shoelace/%NPMDIR%
Component modules include side effects for registration purposes. Because of this, importing directly from `@shoelace-style/shoelace` may result in a larger bundle size than necessary. For optimal tree shaking, always cherry pick, i.e. import components and utilities from their respective files, as shown above.
:::
### Avoiding side-effect imports
By default, imports to components will auto-register themselves. This may not be ideal in all cases. To import just the component's class without auto-registering it's tag we can do the following:
```diff
- import SlButton from '@shoelace-style/shoelace/%NPMDIR%/components/button/button.js';
+ import SlButton from '@shoelace-style/shoelace/%NPMDIR%/components/button/button.component.js';
```
Notice how the import ends with `.component.js`. This is the current convention to convey the import does not register itself.
:::danger
While you can override the class or re-register the shoelace class under a different tag name, if you do so, many components wont work as expected.
:::
## The difference between CDN and npm
You'll notice that the CDN links all start with `/%CDNDIR%/<path>` and npm imports use `/%NPMDIR%/<path>`. The `/%CDNDIR%` files are bundled separately from the `/%NPMDIR%` files. The `/%CDNDIR%` files come pre-bundled, which means all dependencies are inlined so you do not need to worry about loading additional libraries. The `/%NPMDIR%` files **DO NOT** come pre-bundled, allowing your bundler of choice to more efficiently deduplicate dependencies, resulting in smaller bundles and optimal code sharing.

View File

@@ -19,8 +19,7 @@ toc: false
- First-class [React support](/frameworks/react) ⚛️
- Built-in localization 💬
- Open source 😸
Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska).
- [More awesome than ever](https://blog.fontawesome.com/shoelace-joins-font-awesome/) ![Awesome emoji](/assets/images/awesome.svg)
</div>
<div class="splash-end">
@@ -28,6 +27,8 @@ Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska).
</div>
</div>
<div class="badges">
[![jsDelivr](https://data.jsdelivr.com/v1/package/npm/@shoelace-style/shoelace/badge)](https://www.jsdelivr.com/package/npm/@shoelace-style/shoelace)
[![npm](https://img.shields.io/npm/dw/@shoelace-style/shoelace?label=npm&style=flat-square)](https://www.npmjs.com/package/@shoelace-style/shoelace)
[![License](https://img.shields.io/badge/license-MIT-232323.svg?style=flat-square)](https://github.com/shoelace-style/shoelace/blob/next/LICENSE.md)<br>
@@ -35,6 +36,8 @@ Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska).
[![Twitter](https://img.shields.io/badge/Twitter-Follow-00acee.svg?style=flat-square&logo=twitter&logoColor=white)](https://twitter.com/shoelace_style)
[![Sponsor](https://img.shields.io/badge/GitHub-Code-232323.svg?style=flat-square&logo=github&logoColor=white)](https://github.com/shoelace-style/shoelace)
</div>
## Quick Start
Add the following code to your page.
@@ -105,23 +108,7 @@ If you need to support IE11 or pre-Chromium Edge, this library isn't for you. Al
## License
Shoelace is designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska). It's available under the terms of the MIT license.
Designing, developing, and supporting this library requires a lot of time, effort, and skill. If you're using this software to make a profit, I respectfully ask that you help [fund its development](https://github.com/sponsors/claviska) by becoming a sponsor.
👇 Your support is very much appreciated! 👇
<sl-button class="repo-button repo-button--sponsor" href="https://github.com/sponsors/claviska" target="_blank">
<sl-icon slot="prefix" name="heart"></sl-icon> Become a sponsor
</sl-button>
<sl-button class="repo-button repo-button--github" href="https://github.com/shoelace-style/shoelace/stargazers" target="_blank">
<sl-icon slot="prefix" name="github"></sl-icon> Star
</sl-button>
<sl-button class="repo-button repo-button--twitter" href="https://twitter.com/shoelace_style" target="_blank">
<sl-icon slot="prefix" name="twitter"></sl-icon> Follow
</sl-button>
Shoelace was created in New Hampshire by [Cory LaViska](https://twitter.com/claviska). It's available under the terms of the [MIT license](https://github.com/shoelace-style/shoelace/blob/next/LICENSE.md).
## Attribution

View File

@@ -12,6 +12,46 @@ 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-dropdown>` where pressing [[Up]] or [[Down]] when focused on the trigger wouldn't focus the first/last menu items [#1472]
## 2.6.0
- Added JSDoc comments to React Wrappers for better documentation when hovering a component. [#1450]
- Added `displayName` to React Wrappers for better debugging. [#1450]
- Added non-auto-registering routes for Components to fix a number of issues around auto-registration. [#1450]
- Added a console warning if you attempt to register the same Shoelace component twice. [#1450]
- Added tests for `<sl-qr-code>` [#1416]
- Added support for pressing [[Space]] to select/toggle selected `<sl-menu-item>` elements [#1429]
- Added support for virtual elements in `<sl-popup>` [#1449]
- Added the `spinner` part to `<sl-button>` [#1460]
- Added a `shoelace.js` and `shoelace-autoloader.js` to exportmaps. [#1450]
- Added types to events emitted by React wrapped components [#1419]
- Fixed React component treeshaking by introducing `sideEffects` key in `package.json`. [#1450]
- Fixed a bug in `<sl-tree>` where it was auto-defining `<sl-tree-item>`. [#1450]
- Fixed a bug in focus trapping of modal elements like `<sl-dialog>`. We now manually handle focus ordering as well as added `offsetParent()` check for tabbable boundaries in Safari. Test cases added for `<sl-dialog>` inside a shadowRoot [#1403]
- Fixed a bug in `valueAsDate` on `<sl-input>` where it would always set `type="date"` for the underlying `<input>` element. It now falls back to the native browser implementation for the in-memory input. This may cause unexpected behavior if you're using `valueAsDate` on any input elements that aren't `type="date"`. [#1399]
- Fixed a bug in `<sl-qr-code>` where the `background` attribute was never passed to the QR code [#1416]
- Fixed a bug in `<sl-dropdown>` where aria attributes were incorrectly applied to the default `<slot>` causing Lighthouse errors [#1417]
- Fixed a bug in `<sl-carousel>` that caused navigation to work incorrectly in some case [#1420]
- Fixed a number of slots that incorrectly had aria- and/or role attributes directly on them [#1422]
- Fixed a bug in `<sl-tree>` that caused focus to be stolen when removing focused tree items [#1430]
- Fixed a bug in `<sl-dialog>` and `<sl-drawer>` that caused nested modals to respond too eagerly to the [[Esc]] key [#1457]
- Improved `<sl-details>` to use `<details>` internally for better semantics and to enable search to find in supportive browsers when collapsed [#1470]
- Updated ESLint and related plugins to the latest versions
- Changed the default entrypoint for jsDelivr to point to the autoloader. [#1450]
## 2.5.2
- Fixed broken source buttons in the docs [#1401]
## 2.5.1
- Fixed missing extensions from imports that broke with TypeScript 5 [#1391]
- Fixed a regression that caused slotted styles to not work in `<sl-select>` [#1387]
- Reimplemented the theme switcher so it supports light, dark, and system (auto) in the docs [#1395]
## 2.5.0
This release [unbundles Lit](https://github.com/shoelace-style/shoelace/issues/559) (and other dependencies) from Shoelace. There are now two distributions for the project:
@@ -23,24 +63,24 @@ This release [unbundles Lit](https://github.com/shoelace-style/shoelace/issues/5
If you're a CDN user, you must update your path to point to `cdn/` instead of `dist/`. You can copy and paste the latest paths from the [installation page](/getting-started/installation).
:::
- Added a `cdn/` distribution for bundled dependencies (imports for npm users remain the same) [#1369](https://github.com/shoelace-style/shoelace/pull/1369)
- Added the `checkbox` part and related exported parts to `<sl-tree-item>` so you can target it with CSS [#1318](https://github.com/shoelace-style/shoelace/discussions/1318)
- Added a `cdn/` distribution for bundled dependencies (imports for npm users remain the same) [#1369]
- Added the `checkbox` part and related exported parts to `<sl-tree-item>` so you can target it with CSS [#1318]
- Added the `submenu-icon` part to `<sl-menu-item>` (submenus have not been implemented yet, but this part is required to allow customizations)
- Added the ability to use Sprite Sheets when using `<sl-icon>` via a custom resolver.
- Added tests for `<sl-split-panel>` [#1343](https://github.com/shoelace-style/shoelace/pull/1343)
- Added tests for `<sl-split-panel>` [#1343]
- Fixed a bug where changing the size of `<sl-radio-group>` wouldn't update the size of child elements
- Fixed a bug in `<sl-select>` and `<sl-color-picker>` where the `size` attribute wasn't being reflected [#1318](https://github.com/shoelace-style/shoelace/issues/1348)
- Fixed a bug in `<sl-radio-group>` where `<sl-radio>` would not get checked if `<sl-radio-group>` was defined first. [#1364](https://github.com/shoelace-style/shoelace/pull/1364/)
- Fixed a bug in `<sl-input>` that caused date pickers to look filled in even when empty in Safari [#1341](https://github.com/shoelace-style/shoelace/pull/1341)
- Fixed a bug in `<sl-radio-group>` that sometimes caused dual scrollbars in containers that overflowed [#1380](https://github.com/shoelace-style/shoelace/issues/1380)
- Fixed a bug in `<sl-carousel>` not loading the English language pack automatically. [#1384](https://github.com/shoelace-style/shoelace/pull/1384)
- Improved `<sl-button>` so it can accept children of variable heights [#1317](https://github.com/shoelace-style/shoelace/pull/1317)
- Fixed a bug in `<sl-select>` and `<sl-color-picker>` where the `size` attribute wasn't being reflected [#1318]
- Fixed a bug in `<sl-radio-group>` where `<sl-radio>` would not get checked if `<sl-radio-group>` was defined first. [#1364]
- Fixed a bug in `<sl-input>` that caused date pickers to look filled in even when empty in Safari [#1341]
- Fixed a bug in `<sl-radio-group>` that sometimes caused dual scrollbars in containers that overflowed [#1380]
- Fixed a bug in `<sl-carousel>` not loading the English language pack automatically. [#1384]
- Improved `<sl-button>` so it can accept children of variable heights [#1317]
- Improved the docs to more clearly explain sizing radios and radio buttons
- Improved the performance of `<sl-rating>` by partially rendering unseen icons [#1310](https://github.com/shoelace-style/shoelace/pull/1310)
- Improved the Portuguese translation [#1336](https://github.com/shoelace-style/shoelace/pull/1336)
- Improved the German translation [#1339](https://github.com/shoelace-style/shoelace/pull/1339)
- Improved the autoloader so it watches `<html>` instead of `<body>` since the latter gets replaced by some frameworks [#1338](https://github.com/shoelace-style/shoelace/pull/1338)
- Improved the Rails documentation [#1258](https://github.com/shoelace-style/shoelace/pull/1258)
- Improved the performance of `<sl-rating>` by partially rendering unseen icons [#1310]
- Improved the Portuguese translation [#1336]
- Improved the German translation [#1339]
- Improved the autoloader so it watches `<html>` instead of `<body>` since the latter gets replaced by some frameworks [#1338]
- Improved the Rails documentation [#1258]
- Replaced Docsify with Eleventy to generate a static HTML version of the docs
- Updated esbuild to 0.18.2
- Updated Lit to 2.7.5
@@ -1130,12 +1170,12 @@ The most elegant solution I found was to use the [Web Animations API](https://de
## 2.0.0-beta.34
This release changes the way components are registered if you're [cherry picking](/getting-started/installation?id=cherry-picking) or [using a bundler](/getting-started/installation?id=bundling). This recommendation came from the LitElement team and simplifies Shoelace's dependency graph. It also eliminates the need to call a `register()` function before using each component.
This release changes the way components are registered if you're [cherry picking](/getting-started/installation#cherry-picking) or [using a bundler](/getting-started/installation#bundling). This recommendation came from the LitElement team and simplifies Shoelace's dependency graph. It also eliminates the need to call a `register()` function before using each component.
From now on, importing a component will register it automatically. The caveat is that bundlers may not tree shake the library properly if you import from `@shoelace-style/shoelace`, so the recommendation is to import components and utilities from their corresponding files instead.
- 🚨 BREAKING: removed `all.shoelace.js` (use `shoelace.js` instead)
- 🚨 BREAKING: component modules now have a side effect, so bundlers may not tree shake properly when importing from `@shoelace-style/shoelace` (see the [installation page](/getting-started/installation?id=bundling) for more details and how to update)
- 🚨 BREAKING: component modules now have a side effect, so bundlers may not tree shake properly when importing from `@shoelace-style/shoelace` (see the [installation page](/getting-started/installation#bundling) for more details and how to update)
- Added `sl-clear` event to `<sl-select>`
- Fixed a bug where dynamically changing menu items in `<sl-select>` would cause the display label to be blank [#374]
- Fixed a bug where setting the `value` attribute or property on `<sl-input>` and `<sl-textarea>` would trigger validation too soon

View File

@@ -367,7 +367,6 @@ Then use the following syntax for comments so they appear in the generated docs.
* @cssproperty --color: The component's text color.
* @cssproperty --background-color: The component's background color.
*/
@customElement('sl-example')
export default class SlExample {
// ...
}

View File

@@ -18,146 +18,146 @@ Currently, the source of design tokens is considered to be [`light.css`](https:/
Focus ring tokens control the appearance of focus rings. Note that form inputs use `--sl-input-focus-ring-*` tokens instead.
| Token | Value |
| ------------------------ | ------------------------------------------------------------------------------------- |
| `--sl-focus-ring-color` | var(--sl-color-primary-600) (light theme)<br>var(--sl-color-primary-700) (dark theme) |
| `--sl-focus-ring-style` | solid |
| `--sl-focus-ring-width` | 3px |
| `--sl-focus-ring` | var(--sl-focus-ring-style) var(--sl-focus-ring-width) var(--sl-focus-ring-color) |
| `--sl-focus-ring-offset` | 1px |
| Token | Value |
| ------------------------ | ----------------------------------------------------------------------------------------- |
| `--sl-focus-ring-color` | `var(--sl-color-primary-600)` (light theme)<br>`var(--sl-color-primary-700)` (dark theme) |
| `--sl-focus-ring-style` | `solid` |
| `--sl-focus-ring-width` | `3px` |
| `--sl-focus-ring` | `var(--sl-focus-ring-style) var(--sl-focus-ring-width) var(--sl-focus-ring-color)` |
| `--sl-focus-ring-offset` | `1px` |
## Buttons
Button tokens control the appearance of buttons. In addition, buttons also currently use some form input tokens such as `--sl-input-height-*` and `--sl-input-border-*`. More button tokens may be added in the future to make it easier to style them more independently.
| Token | Value |
| ------------------------------ | --------------------------- |
| `--sl-button-font-size-small` | var(--sl-font-size-x-small) |
| `--sl-button-font-size-medium` | var(--sl-font-size-small) |
| `--sl-button-font-size-large` | var(--sl-font-size-medium) |
| Token | Value |
| ------------------------------ | ----------------------------- |
| `--sl-button-font-size-small` | `var(--sl-font-size-x-small)` |
| `--sl-button-font-size-medium` | `var(--sl-font-size-small)` |
| `--sl-button-font-size-large` | `var(--sl-font-size-medium)` |
## Form Inputs
Form input tokens control the appearance of form controls such as [input](/components/input), [select](/components/select), [textarea](/components/textarea), etc.
| Token | Value |
| --------------------------------------- | -------------------------------- |
| `--sl-input-height-small` | 1.875rem; (30px @ 16px base) |
| `--sl-input-height-medium` | 2.5rem; (40px @ 16px base) |
| `--sl-input-height-large` | 3.125rem; (50px @ 16px base) |
| `--sl-input-background-color` | var(--sl-color-neutral-0) |
| `--sl-input-background-color-hover` | var(--sl-input-background-color) |
| `--sl-input-background-color-focus` | var(--sl-input-background-color) |
| `--sl-input-background-color-disabled` | var(--sl-color-neutral-100) |
| `--sl-input-border-color` | var(--sl-color-neutral-300) |
| `--sl-input-border-color-hover` | var(--sl-color-neutral-400) |
| `--sl-input-border-color-focus` | var(--sl-color-primary-500) |
| `--sl-input-border-color-disabled` | var(--sl-color-neutral-300) |
| `--sl-input-border-width` | 1px |
| `--sl-input-required-content` | "\*" |
| `--sl-input-required-content-offset` | -2px |
| `--sl-input-required-content-color` | var(--sl-input-label-color) |
| `--sl-input-border-radius-small` | var(--sl-border-radius-medium) |
| `--sl-input-border-radius-medium` | var(--sl-border-radius-medium) |
| `--sl-input-border-radius-large` | var(--sl-border-radius-medium) |
| `--sl-input-font-family` | var(--sl-font-sans) |
| `--sl-input-font-weight` | var(--sl-font-weight-normal) |
| `--sl-input-font-size-small` | var(--sl-font-size-small) |
| `--sl-input-font-size-medium` | var(--sl-font-size-medium) |
| `--sl-input-font-size-large` | var(--sl-font-size-large) |
| `--sl-input-letter-spacing` | var(--sl-letter-spacing-normal) |
| `--sl-input-color` | var(--sl-color-neutral-700) |
| `--sl-input-color-hover` | var(--sl-color-neutral-700) |
| `--sl-input-color-focus` | var(--sl-color-neutral-700) |
| `--sl-input-color-disabled` | var(--sl-color-neutral-900) |
| `--sl-input-icon-color` | var(--sl-color-neutral-500) |
| `--sl-input-icon-color-hover` | var(--sl-color-neutral-600) |
| `--sl-input-icon-color-focus` | var(--sl-color-neutral-600) |
| `--sl-input-placeholder-color` | var(--sl-color-neutral-500) |
| `--sl-input-placeholder-color-disabled` | var(--sl-color-neutral-600) |
| `--sl-input-spacing-small` | var(--sl-spacing-small) |
| `--sl-input-spacing-medium` | var(--sl-spacing-medium) |
| `--sl-input-spacing-large` | var(--sl-spacing-large) |
| `--sl-input-focus-ring-color` | hsl(198.6 88.7% 48.4% / 40%) |
| `--sl-input-focus-ring-offset` | 0 |
| Token | Value |
| --------------------------------------- | ---------------------------------- |
| `--sl-input-height-small` | `1.875rem` (30px @ 16px base) |
| `--sl-input-height-medium` | `2.5rem` (40px @ 16px base) |
| `--sl-input-height-large` | `3.125rem` (50px @ 16px base) |
| `--sl-input-background-color` | `var(--sl-color-neutral-0)` |
| `--sl-input-background-color-hover` | `var(--sl-input-background-color)` |
| `--sl-input-background-color-focus` | `var(--sl-input-background-color)` |
| `--sl-input-background-color-disabled` | `var(--sl-color-neutral-100)` |
| `--sl-input-border-color` | `var(--sl-color-neutral-300)` |
| `--sl-input-border-color-hover` | `var(--sl-color-neutral-400)` |
| `--sl-input-border-color-focus` | `var(--sl-color-primary-500)` |
| `--sl-input-border-color-disabled` | `var(--sl-color-neutral-300)` |
| `--sl-input-border-width` | `1px` |
| `--sl-input-required-content` | `*` |
| `--sl-input-required-content-offset` | `-2px` |
| `--sl-input-required-content-color` | `var(--sl-input-label-color)` |
| `--sl-input-border-radius-small` | `var(--sl-border-radius-medium)` |
| `--sl-input-border-radius-medium` | `var(--sl-border-radius-medium)` |
| `--sl-input-border-radius-large` | `var(--sl-border-radius-medium)` |
| `--sl-input-font-family` | `var(--sl-font-sans)` |
| `--sl-input-font-weight` | `var(--sl-font-weight-normal)` |
| `--sl-input-font-size-small` | `var(--sl-font-size-small)` |
| `--sl-input-font-size-medium` | `var(--sl-font-size-medium)` |
| `--sl-input-font-size-large` | `var(--sl-font-size-large)` |
| `--sl-input-letter-spacing` | `var(--sl-letter-spacing-normal)` |
| `--sl-input-color` | `var(--sl-color-neutral-700)` |
| `--sl-input-color-hover` | `var(--sl-color-neutral-700)` |
| `--sl-input-color-focus` | `var(--sl-color-neutral-700)` |
| `--sl-input-color-disabled` | `var(--sl-color-neutral-900)` |
| `--sl-input-icon-color` | `var(--sl-color-neutral-500)` |
| `--sl-input-icon-color-hover` | `var(--sl-color-neutral-600)` |
| `--sl-input-icon-color-focus` | `var(--sl-color-neutral-600)` |
| `--sl-input-placeholder-color` | `var(--sl-color-neutral-500)` |
| `--sl-input-placeholder-color-disabled` | `var(--sl-color-neutral-600)` |
| `--sl-input-spacing-small` | `var(--sl-spacing-small)` |
| `--sl-input-spacing-medium` | `var(--sl-spacing-medium)` |
| `--sl-input-spacing-large` | `var(--sl-spacing-large)` |
| `--sl-input-focus-ring-color` | `hsl(198.6 88.7% 48.4% / 40%)` |
| `--sl-input-focus-ring-offset` | `0` |
## Filled Form Inputs
Filled form input tokens control the appearance of form controls using the `filled` variant.
| Token | Value |
| --------------------------------------------- | --------------------------- |
| `--sl-input-filled-background-color` | var(--sl-color-neutral-100) |
| `--sl-input-filled-background-color-hover` | var(--sl-color-neutral-100) |
| `--sl-input-filled-background-color-focus` | var(--sl-color-neutral-100) |
| `--sl-input-filled-background-color-disabled` | var(--sl-color-neutral-100) |
| `--sl-input-filled-color` | var(--sl-color-neutral-800) |
| `--sl-input-filled-color-hover` | var(--sl-color-neutral-800) |
| `--sl-input-filled-color-focus` | var(--sl-color-neutral-700) |
| `--sl-input-filled-color-disabled` | var(--sl-color-neutral-800) |
| Token | Value |
| --------------------------------------------- | ----------------------------- |
| `--sl-input-filled-background-color` | `var(--sl-color-neutral-100)` |
| `--sl-input-filled-background-color-hover` | `var(--sl-color-neutral-100)` |
| `--sl-input-filled-background-color-focus` | `var(--sl-color-neutral-100)` |
| `--sl-input-filled-background-color-disabled` | `var(--sl-color-neutral-100)` |
| `--sl-input-filled-color` | `var(--sl-color-neutral-800)` |
| `--sl-input-filled-color-hover` | `var(--sl-color-neutral-800)` |
| `--sl-input-filled-color-focus` | `var(--sl-color-neutral-700)` |
| `--sl-input-filled-color-disabled` | `var(--sl-color-neutral-800)` |
## Form Labels
Form label tokens control the appearance of labels in form controls.
| Token | Value |
| ----------------------------------- | -------------------------- |
| `--sl-input-label-font-size-small` | var(--sl-font-size-small) |
| `--sl-input-label-font-size-medium` | var(--sl-font-size-medium) |
| `--sl-input-label-font-size-large` | var(--sl-font-size-large) |
| `--sl-input-label-color` | inherit |
| Token | Value |
| ----------------------------------- | ---------------------------- |
| `--sl-input-label-font-size-small` | `var(--sl-font-size-small)` |
| `--sl-input-label-font-size-medium` | `var(--sl-font-size-medium`) |
| `--sl-input-label-font-size-large` | `var(--sl-font-size-large)` |
| `--sl-input-label-color` | `inherit` |
## Help Text
Help text tokens control the appearance of help text in form controls.
| Token | Value |
| --------------------------------------- | --------------------------- |
| `--sl-input-help-text-font-size-small` | var(--sl-font-size-x-small) |
| `--sl-input-help-text-font-size-medium` | var(--sl-font-size-small) |
| `--sl-input-help-text-font-size-large` | var(--sl-font-size-medium) |
| `--sl-input-help-text-color` | var(--sl-color-neutral-500) |
| Token | Value |
| --------------------------------------- | ----------------------------- |
| `--sl-input-help-text-font-size-small` | `var(--sl-font-size-x-small)` |
| `--sl-input-help-text-font-size-medium` | `var(--sl-font-size-small)` |
| `--sl-input-help-text-font-size-large` | `var(--sl-font-size-medium)` |
| `--sl-input-help-text-color` | `var(--sl-color-neutral-500)` |
## Toggles
Toggle tokens control the appearance of toggles such as [checkbox](/components/checkbox), [radio](/components/radio), [switch](/components/switch), etc.
| Token | Value |
| ------------------------- | --------------------------- |
| `--sl-toggle-size-small` | 0.875rem (14px @ 16px base) |
| `--sl-toggle-size-medium` | 1.125rem (18px @ 16px base) |
| `--sl-toggle-size-large` | 1.375rem (22px @ 16px base) |
| Token | Value |
| ------------------------- | ----------------------------- |
| `--sl-toggle-size-small` | `0.875rem` (14px @ 16px base) |
| `--sl-toggle-size-medium` | `1.125rem` (18px @ 16px base) |
| `--sl-toggle-size-large` | `1.375rem` (22px @ 16px base) |
## Overlays
Overlay tokens control the appearance of overlays as used in [dialog](/components/dialog), [drawer](/components/drawer), etc.
| Token | Value |
| ------------------------------- | ------------------------- |
| `--sl-overlay-background-color` | hsl(240 3.8% 46.1% / 33%) |
| Token | Value |
| ------------------------------- | --------------------------- |
| `--sl-overlay-background-color` | `hsl(240 3.8% 46.1% / 33%)` |
## Panels
Panel tokens control the appearance of panels such as those used in [dialog](/components/dialog), [drawer](/components/drawer), [menu](/components/menu), etc.
| Token | Value |
| ----------------------------- | --------------------------- |
| `--sl-panel-background-color` | var(--sl-color-neutral-0) |
| `--sl-panel-border-color` | var(--sl-color-neutral-200) |
| `--sl-panel-border-width` | 1px |
| Token | Value |
| ----------------------------- | ----------------------------- |
| `--sl-panel-background-color` | `var(--sl-color-neutral-0)` |
| `--sl-panel-border-color` | `var(--sl-color-neutral-200)` |
| `--sl-panel-border-width` | `1px` |
## Tooltips
Tooltip tokens control the appearance of tooltips. This includes the [tooltip](/components/tooltip) component as well as other implementations, such [range tooltips](/components/range).
| Token | Value |
| ------------------------------- | ---------------------------------------------------- |
| `--sl-tooltip-border-radius` | var(--sl-border-radius-medium) |
| `--sl-tooltip-background-color` | var(--sl-color-neutral-800) |
| `--sl-tooltip-color` | var(--sl-color-neutral-0) |
| `--sl-tooltip-font-family` | var(--sl-font-sans) |
| `--sl-tooltip-font-weight` | var(--sl-font-weight-normal) |
| `--sl-tooltip-font-size` | var(--sl-font-size-small) |
| `--sl-tooltip-line-height` | var(--sl-line-height-dense) |
| `--sl-tooltip-padding` | var(--sl-spacing-2x-small) var(--sl-spacing-x-small) |
| `--sl-tooltip-arrow-size` | 6px |
| Token | Value |
| ------------------------------- | ------------------------------------------------------ |
| `--sl-tooltip-border-radius` | `var(--sl-border-radius-medium)` |
| `--sl-tooltip-background-color` | `var(--sl-color-neutral-800)` |
| `--sl-tooltip-color` | `var(--sl-color-neutral-0)` |
| `--sl-tooltip-font-family` | `var(--sl-font-sans)` |
| `--sl-tooltip-font-weight` | `var(--sl-font-weight-normal)` |
| `--sl-tooltip-font-size` | `var(--sl-font-size-small)` |
| `--sl-tooltip-line-height` | `var(--sl-line-height-dense)` |
| `--sl-tooltip-padding` | `var(--sl-spacing-2x-small) var(--sl-spacing-x-small)` |
| `--sl-tooltip-arrow-size` | `6px` |

View File

@@ -40,7 +40,7 @@ Import the Shoelace default theme (stylesheet) in `/resources/css/app.css`:
### Import Your Shoelace Components
Import each Shoelace component you plan to use in `/resources/js/bootstrap.js`. Use the full path to each component (as outlined in the [Cherry Picking instructions](https://shoelace.style/getting-started/installation?id=cherry-picking)). You can find the full import statement for a component in the _Importing_ section of the component's documentation (use the _Bundler_ import). Your imports should look similar to:
Import each Shoelace component you plan to use in `/resources/js/bootstrap.js`. Use the full path to each component (as outlined in the [Cherry Picking instructions](https://shoelace.style/getting-started/installation#cherry-picking)). You can find the full import statement for a component in the _Importing_ section of the component's documentation (use the _Bundler_ import). Your imports should look similar to:
```js
import '@shoelace-style/shoelace/dist/components/button/button.js';

View File

@@ -84,7 +84,7 @@ If we use `useEffect` instead of `useLayoutEffect`, the initial render will occu
:::
:::tip
This will import all Shoelace components for convenience. To selectively import components, refer to the [Using webpack](/getting-started/installation?id=using-webpack) section of the docs.
This will import all Shoelace components for convenience. To selectively import components, refer to the [Using webpack](/getting-started/installation#using-webpack) section of the docs.
:::
You may be wondering where the `URL` property is coming from. We'll address that in the next few sections.

View File

@@ -37,7 +37,7 @@ The next step is to import Shoelace's default theme (stylesheet) in `app/javascr
@import '@shoelace-style/shoelace/dist/themes/dark'; // Optional dark theme
```
Fore more details about themes, please refer to [Theme Basics](/getting-started/themes?id=theme-basics).
Fore more details about themes, please refer to [Theme Basics](/getting-started/themes#theme-basics).
### Importing Required Scripts

892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.5.0",
"version": "2.6.0",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",
@@ -9,12 +9,24 @@
"web-types": "dist/web-types.json",
"type": "module",
"types": "dist/shoelace.d.ts",
"jsdelivr": "./cdn/shoelace-autoloader.js",
"sideEffects": [
"./dist/shoelace.js",
"./dist/shoelace-autoloader.js",
"./dist/components/**/*.js",
"./dist/translations/**/*.*",
"./src/translations/**/*.*",
"// COMMENT: This monstrosity below isn't perfect, but its like 99% to get bundlers to recognize 'thing.component.ts' as having no side effects. Example: https://regexr.com/7grof",
"./dist/components/**/*((?<!(\\.component|\\.styles)))\\.js"
],
"exports": {
".": {
"types": "./dist/shoelace.d.ts",
"import": "./dist/shoelace.js"
},
"./dist/custom-elements.json": "./dist/custom-elements.json",
"./dist/shoelace.js": "./dist/shoelace.js",
"./dist/shoelace-autoloader.js": "./dist/shoelace-autoloader.js",
"./dist/themes/*": "./dist/themes/*",
"./dist/components/*": "./dist/components/*",
"./dist/utilities/*": "./dist/utilities/*",
@@ -79,8 +91,8 @@
"@open-wc/testing": "^3.1.7",
"@types/mocha": "^10.0.1",
"@types/react": "^18.0.26",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@web/dev-server-esbuild": "^0.3.3",
"@web/test-runner": "^0.15.0",
"@web/test-runner-commands": "^0.6.5",
@@ -96,15 +108,16 @@
"del": "^7.0.0",
"download": "^8.0.0",
"esbuild": "^0.18.2",
"eslint": "^8.31.0",
"esbuild-plugin-replace": "^1.4.0",
"eslint": "^8.44.0",
"eslint-plugin-chai-expect": "^3.0.0",
"eslint-plugin-chai-friendly": "^0.7.2",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-lit": "^1.8.2",
"eslint-plugin-lit-a11y": "^2.3.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-lit": "^1.8.3",
"eslint-plugin-lit-a11y": "^4.1.0",
"eslint-plugin-markdown": "^3.0.0",
"eslint-plugin-sort-imports-es6-autofix": "^0.6.0",
"eslint-plugin-wc": "^1.4.0",
"eslint-plugin-wc": "^1.5.0",
"front-matter": "^4.0.2",
"get-port": "^7.0.0",
"globby": "^13.1.3",

View File

@@ -11,6 +11,8 @@ import getPort, { portNumbers } from 'get-port';
import ora from 'ora';
import util from 'util';
import * as path from 'path';
import { readFileSync } from 'fs';
import { replace } from 'esbuild-plugin-replace';
const { serve } = commandLineArgs([{ name: 'serve', type: Boolean }]);
const outdir = 'dist';
@@ -22,6 +24,8 @@ let childProcess;
let buildResults;
const bundleDirectories = [cdndir, outdir];
let packageData = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
const shoelaceVersion = JSON.stringify(packageData.version.toString());
//
// Runs 11ty and builds the docs. The returned promise resolves after the initial publish has completed. The child
@@ -108,13 +112,18 @@ async function buildTheSource() {
//
external: alwaysExternal,
splitting: true,
plugins: []
plugins: [
replace({
__SHOELACE_VERSION__: shoelaceVersion
})
]
};
const npmConfig = {
...cdnConfig,
bundle: false,
external: undefined,
minify: false,
packages: 'external',
outdir
};
@@ -263,13 +272,16 @@ if (serve) {
});
// Rebuild and reload when source files change
bs.watch(['src/**/!(*.test).*']).on('change', async filename => {
bs.watch('src/**/!(*.test).*').on('change', async filename => {
console.log('[build] File changed: ', filename);
try {
const isTheme = /^src\/themes/.test(filename);
const isStylesheet = /(\.css|\.styles\.ts)$/.test(filename);
// Rebuild the source
await Promise.all([buildResults.map(result => result.rebuild())]);
const rebuildResults = buildResults.map(result => result.rebuild());
await Promise.all(rebuildResults);
// Rebuild stylesheets when a theme file changes
if (isTheme) {

View File

@@ -25,31 +25,61 @@ components.map(component => {
const componentDir = path.join(reactDir, tagWithoutPrefix);
const componentFile = path.join(componentDir, 'index.ts');
const importPath = component.path;
const events = (component.events || []).map(event => `${event.reactName}: '${event.name}'`).join(',\n');
const eventImports = (component.events || [])
.map(event => `import { ${event.eventName} } from '../../../src/events/events';`)
.join('\n');
const eventNameImport =
(component.events || []).length > 0 ? `import { type EventName } from '@lit-labs/react';` : ``;
const events = (component.events || [])
.map(event => `${event.reactName}: '${event.name}' as EventName<${event.eventName}>`)
.join(',\n');
fs.mkdirSync(componentDir, { recursive: true });
const jsDoc = component.jsDoc || '';
const source = prettier.format(
`
import * as React from 'react';
import { createComponent } from '@lit-labs/react';
import Component from '../../${importPath}';
export default createComponent({
tagName: '${component.tagName}',
${eventNameImport}
${eventImports}
const tagName = '${component.tagName}'
const component = createComponent({
tagName,
elementClass: Component,
react: React,
events: {
${events}
},
displayName: "${component.name}"
})
${jsDoc}
class SlComponent extends React.Component<Parameters<typeof component>[0]> {
constructor (...args: Parameters<typeof component>) {
super(...args)
Component.define(tagName)
}
});
render () {
const { children, ...props } = this.props
return React.createElement(component, props, children)
}
}
export default SlComponent;
`,
Object.assign(prettierConfig, {
parser: 'babel-ts'
})
);
index.push(`export { default as ${component.name} } from './${tagWithoutPrefix}';`);
index.push(`export { default as ${component.name} } from './${tagWithoutPrefix}/index.js';`);
fs.writeFileSync(componentFile, source, 'utf8');
});

View File

@@ -33,6 +33,11 @@ export default function (plop) {
{
type: 'add',
path: '../../src/components/{{ tagWithoutPrefix tag }}/{{ tagWithoutPrefix tag }}.ts',
templateFile: 'templates/component/define.hbs'
},
{
type: 'add',
path: '../../src/components/{{ tagWithoutPrefix tag }}/{{ tagWithoutPrefix tag }}.component.ts',
templateFile: 'templates/component/component.hbs'
},
{
@@ -54,7 +59,7 @@ export default function (plop) {
type: 'modify',
path: '../../src/shoelace.ts',
pattern: /\/\* plop:component \*\//,
template: `export { default as {{ properCase tag }} } from './components/{{ tagWithoutPrefix tag }}/{{ tagWithoutPrefix tag }}';\n/* plop:component */`
template: `export { default as {{ properCase tag }} } from './components/{{ tagWithoutPrefix tag }}/{{ tagWithoutPrefix tag }}.js';\n/* plop:component */`
}
]
});

View File

@@ -1,9 +1,9 @@
import { customElement, property } from 'lit/decorators.js';
import { property } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './{{ tagWithoutPrefix tag }}.styles';
import { LocalizeController } from '../../utilities/localize.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './{{ tagWithoutPrefix tag }}.styles.js';
import type { CSSResultGroup } from 'lit';
/**
@@ -23,7 +23,6 @@ import type { CSSResultGroup } from 'lit';
*
* @cssproperty --example - An example CSS custom property.
*/
@customElement('{{ tag }}')
export default class {{ properCase tag }} extends ShoelaceElement {
static styles: CSSResultGroup = styles;

View File

@@ -0,0 +1,4 @@
import {{ properCase tag }} from './{{ tagWithoutPrefix tag }}.component.js';
export * from './{{ tagWithoutPrefix tag }}.component.js';
export default {{ properCase tag }};
{{ properCase tag }}.define('{{ tag }}');

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -0,0 +1,247 @@
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
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 ShoelaceElement from '../../internal/shoelace-element.js';
import SlIconButton from '../icon-button/icon-button.component.js';
import styles from './alert.styles.js';
import type { CSSResultGroup } from 'lit';
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
/**
* @summary Alerts are used to display important messages inline or as toast notifications.
* @documentation https://shoelace.style/components/alert
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
* @slot - The alert's main content.
* @slot icon - An icon to show in the alert. Works best with `<sl-icon>`.
*
* @event sl-show - Emitted when the alert opens.
* @event sl-after-show - Emitted after the alert opens and all animations are complete.
* @event sl-hide - Emitted when the alert closes.
* @event sl-after-hide - Emitted after the alert closes and all animations are complete.
*
* @csspart base - The component's base wrapper.
* @csspart icon - The container that wraps the optional icon.
* @csspart message - The container that wraps the alert's main content.
* @csspart close-button - The close button, an `<sl-icon-button>`.
* @csspart close-button__base - The close button's exported `base` part.
*
* @animation alert.show - The animation to use when showing the alert.
* @animation alert.hide - The animation to use when hiding the alert.
*/
export default class SlAlert extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon-button': SlIconButton };
private autoHideTimeout: number;
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
private readonly localize = new LocalizeController(this);
@query('[part~="base"]') base: HTMLElement;
/**
* Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the alert's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/** Enables a close button that allows the user to dismiss the alert. */
@property({ type: Boolean, reflect: true }) closable = false;
/** The alert's theme variant. */
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
/**
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning
* the alert will not close on its own.
*/
@property({ type: Number }) duration = Infinity;
firstUpdated() {
this.base.hidden = !this.open;
}
private restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
}
}
private handleCloseClick() {
this.hide();
}
private handleMouseMove() {
this.restartAutoHide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sl-show');
if (this.duration < Infinity) {
this.restartAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.emit('sl-after-hide');
}
}
@watch('duration')
handleDurationChange() {
this.restartAutoHide();
}
/** Shows the alert. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the alert */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/**
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
* calling this method again. The returned promise will resolve after the alert is hidden.
*/
async toast() {
return new Promise<void>(resolve => {
if (toastStack.parentElement === null) {
document.body.append(toastStack);
}
toastStack.appendChild(this);
// Wait for the toast stack to render
requestAnimationFrame(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
this.clientWidth;
this.show();
});
this.addEventListener(
'sl-after-hide',
() => {
toastStack.removeChild(this);
resolve();
// Remove the toast stack from the DOM when there are no more alerts
if (toastStack.querySelector('sl-alert') === null) {
toastStack.remove();
}
},
{ once: true }
);
});
}
render() {
return html`
<div
part="base"
class=${classMap({
alert: true,
'alert--open': this.open,
'alert--closable': this.closable,
'alert--has-icon': this.hasSlotController.test('icon'),
'alert--primary': this.variant === 'primary',
'alert--success': this.variant === 'success',
'alert--neutral': this.variant === 'neutral',
'alert--warning': this.variant === 'warning',
'alert--danger': this.variant === 'danger'
})}
role="alert"
aria-hidden=${this.open ? 'false' : 'true'}
@mousemove=${this.handleMouseMove}
>
<div part="icon" class="alert__icon">
<slot name="icon"></slot>
</div>
<div part="message" class="alert__message" aria-live="polite">
<slot></slot>
</div>
${this.closable
? html`
<sl-icon-button
part="close-button"
exportparts="base:close-button__base"
class="alert__close-button"
name="x-lg"
library="system"
label=${this.localize.term('close')}
@click=${this.handleCloseClick}
></sl-icon-button>
`
: ''}
</div>
`;
}
}
setDefaultAnimation('alert.show', {
keyframes: [
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1 }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('alert.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.8 }
],
options: { duration: 250, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-alert': SlAlert;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,11 +1,11 @@
import '../../../dist/shoelace.js';
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { clickOnElement, moveMouseOnElement } from '../../internal/test';
import { queryByTestId } from '../../internal/test/data-testid-helpers';
import { clickOnElement, moveMouseOnElement } from '../../internal/test.js';
import { queryByTestId } from '../../internal/test/data-testid-helpers.js';
import { resetMouse } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlAlert from './alert';
import type SlIconButton from '../icon-button/icon-button';
import type SlAlert from './alert.js';
import type SlIconButton from '../icon-button/icon-button.js';
const getAlertContainer = (alert: SlAlert): HTMLElement => {
return alert.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;

View File

@@ -1,244 +1,4 @@
import '../icon-button/icon-button';
import { animateTo, stopAnimations } from '../../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './alert.styles';
import type { CSSResultGroup } from 'lit';
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
/**
* @summary Alerts are used to display important messages inline or as toast notifications.
* @documentation https://shoelace.style/components/alert
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
* @slot - The alert's main content.
* @slot icon - An icon to show in the alert. Works best with `<sl-icon>`.
*
* @event sl-show - Emitted when the alert opens.
* @event sl-after-show - Emitted after the alert opens and all animations are complete.
* @event sl-hide - Emitted when the alert closes.
* @event sl-after-hide - Emitted after the alert closes and all animations are complete.
*
* @csspart base - The component's base wrapper.
* @csspart icon - The container that wraps the optional icon.
* @csspart message - The container that wraps the alert's main content.
* @csspart close-button - The close button, an `<sl-icon-button>`.
* @csspart close-button__base - The close button's exported `base` part.
*
* @animation alert.show - The animation to use when showing the alert.
* @animation alert.hide - The animation to use when hiding the alert.
*/
@customElement('sl-alert')
export default class SlAlert extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private autoHideTimeout: number;
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
private readonly localize = new LocalizeController(this);
@query('[part~="base"]') base: HTMLElement;
/**
* Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the alert's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/** Enables a close button that allows the user to dismiss the alert. */
@property({ type: Boolean, reflect: true }) closable = false;
/** The alert's theme variant. */
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
/**
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning
* the alert will not close on its own.
*/
@property({ type: Number }) duration = Infinity;
firstUpdated() {
this.base.hidden = !this.open;
}
private restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
}
}
private handleCloseClick() {
this.hide();
}
private handleMouseMove() {
this.restartAutoHide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sl-show');
if (this.duration < Infinity) {
this.restartAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.emit('sl-after-hide');
}
}
@watch('duration')
handleDurationChange() {
this.restartAutoHide();
}
/** Shows the alert. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the alert */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/**
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
* calling this method again. The returned promise will resolve after the alert is hidden.
*/
async toast() {
return new Promise<void>(resolve => {
if (toastStack.parentElement === null) {
document.body.append(toastStack);
}
toastStack.appendChild(this);
// Wait for the toast stack to render
requestAnimationFrame(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
this.clientWidth;
this.show();
});
this.addEventListener(
'sl-after-hide',
() => {
toastStack.removeChild(this);
resolve();
// Remove the toast stack from the DOM when there are no more alerts
if (toastStack.querySelector('sl-alert') === null) {
toastStack.remove();
}
},
{ once: true }
);
});
}
render() {
return html`
<div
part="base"
class=${classMap({
alert: true,
'alert--open': this.open,
'alert--closable': this.closable,
'alert--has-icon': this.hasSlotController.test('icon'),
'alert--primary': this.variant === 'primary',
'alert--success': this.variant === 'success',
'alert--neutral': this.variant === 'neutral',
'alert--warning': this.variant === 'warning',
'alert--danger': this.variant === 'danger'
})}
role="alert"
aria-hidden=${this.open ? 'false' : 'true'}
@mousemove=${this.handleMouseMove}
>
<slot name="icon" part="icon" class="alert__icon"></slot>
<slot part="message" class="alert__message" aria-live="polite"></slot>
${this.closable
? html`
<sl-icon-button
part="close-button"
exportparts="base:close-button__base"
class="alert__close-button"
name="x-lg"
library="system"
label=${this.localize.term('close')}
@click=${this.handleCloseClick}
></sl-icon-button>
`
: ''}
</div>
`;
}
}
setDefaultAnimation('alert.show', {
keyframes: [
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1 }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('alert.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.8 }
],
options: { duration: 250, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-alert': SlAlert;
}
}
import SlAlert from './alert.component.js';
export * from './alert.component.js';
export default SlAlert;
SlAlert.define('sl-alert');

View File

@@ -0,0 +1,122 @@
import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './animated-image.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction.
* @documentation https://shoelace.style/components/animated-image
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @event sl-load - Emitted when the image loads successfully.
* @event sl-error - Emitted when the image fails to load.
*
* @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.
*
* @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 dependencies = { 'sl-icon': SlIcon };
@query('.animated-image__animated') animatedImage: HTMLImageElement;
@state() frozenFrame: string;
@state() isLoaded = false;
/** The path to the image to load. */
@property() src: string;
/** A description of the image used by assistive devices. */
@property() alt: string;
/** Plays the animation. When this attribute is remove, the animation will pause. */
@property({ type: Boolean, reflect: true }) play: boolean;
private handleClick() {
this.play = !this.play;
}
private handleLoad() {
const canvas = document.createElement('canvas');
const { width, height } = this.animatedImage;
canvas.width = width;
canvas.height = height;
canvas.getContext('2d')!.drawImage(this.animatedImage, 0, 0, width, height);
this.frozenFrame = canvas.toDataURL('image/gif');
if (!this.isLoaded) {
this.emit('sl-load');
this.isLoaded = true;
}
}
private handleError() {
this.emit('sl-error');
}
@watch('play', { waitUntilFirstUpdate: true })
handlePlayChange() {
// When the animation starts playing, reset the src so it plays from the beginning. Since the src is cached, this
// won't trigger another request.
if (this.play) {
this.animatedImage.src = '';
this.animatedImage.src = this.src;
}
}
@watch('src')
handleSrcChange() {
this.isLoaded = false;
}
render() {
return html`
<div class="animated-image">
<img
class="animated-image__animated"
src=${this.src}
alt=${this.alt}
crossorigin="anonymous"
aria-hidden=${this.play ? 'false' : 'true'}
@click=${this.handleClick}
@load=${this.handleLoad}
@error=${this.handleError}
/>
${this.isLoaded
? html`
<img
class="animated-image__frozen"
src=${this.frozenFrame}
alt=${this.alt}
aria-hidden=${this.play ? 'true' : 'false'}
@click=${this.handleClick}
/>
<div part="control-box" class="animated-image__control-box">
<slot name="play-icon"><sl-icon name="play-fill" library="system"></sl-icon></slot>
<slot name="pause-icon"><sl-icon name="pause-fill" library="system"></sl-icon></slot>
</div>
`
: ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-animated-image': SlAnimatedImage;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,7 +1,7 @@
import '../../../dist/shoelace.js';
import { clickOnElement } from '../../internal/test';
import { clickOnElement } from '../../internal/test.js';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import type SlAnimatedImage from './animated-image';
import type SlAnimatedImage from './animated-image.js';
describe('<sl-animated-image>', () => {
it('should render a component', async () => {

View File

@@ -1,122 +1,4 @@
import '../icon/icon';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './animated-image.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction.
* @documentation https://shoelace.style/components/animated-image
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @event sl-load - Emitted when the image loads successfully.
* @event sl-error - Emitted when the image fails to load.
*
* @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.
*
* @cssproperty --control-box-size - The size of the icon box.
* @cssproperty --icon-size - The size of the play/pause icons.
*/
@customElement('sl-animated-image')
export default class SlAnimatedImage extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.animated-image__animated') animatedImage: HTMLImageElement;
@state() frozenFrame: string;
@state() isLoaded = false;
/** The path to the image to load. */
@property() src: string;
/** A description of the image used by assistive devices. */
@property() alt: string;
/** Plays the animation. When this attribute is remove, the animation will pause. */
@property({ type: Boolean, reflect: true }) play: boolean;
private handleClick() {
this.play = !this.play;
}
private handleLoad() {
const canvas = document.createElement('canvas');
const { width, height } = this.animatedImage;
canvas.width = width;
canvas.height = height;
canvas.getContext('2d')!.drawImage(this.animatedImage, 0, 0, width, height);
this.frozenFrame = canvas.toDataURL('image/gif');
if (!this.isLoaded) {
this.emit('sl-load');
this.isLoaded = true;
}
}
private handleError() {
this.emit('sl-error');
}
@watch('play', { waitUntilFirstUpdate: true })
handlePlayChange() {
// When the animation starts playing, reset the src so it plays from the beginning. Since the src is cached, this
// won't trigger another request.
if (this.play) {
this.animatedImage.src = '';
this.animatedImage.src = this.src;
}
}
@watch('src')
handleSrcChange() {
this.isLoaded = false;
}
render() {
return html`
<div class="animated-image">
<img
class="animated-image__animated"
src=${this.src}
alt=${this.alt}
crossorigin="anonymous"
aria-hidden=${this.play ? 'false' : 'true'}
@click=${this.handleClick}
@load=${this.handleLoad}
@error=${this.handleError}
/>
${this.isLoaded
? html`
<img
class="animated-image__frozen"
src=${this.frozenFrame}
alt=${this.alt}
aria-hidden=${this.play ? 'true' : 'false'}
@click=${this.handleClick}
/>
<div part="control-box" class="animated-image__control-box">
<slot name="play-icon"><sl-icon name="play-fill" library="system"></sl-icon></slot>
<slot name="pause-icon"><sl-icon name="pause-fill" library="system"></sl-icon></slot>
</div>
`
: ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-animated-image': SlAnimatedImage;
}
}
import SlAnimatedImage from './animated-image.component.js';
export * from './animated-image.component.js';
export default SlAnimatedImage;
SlAnimatedImage.define('sl-animated-image');

View File

@@ -0,0 +1,226 @@
import { animations } from './animations.js';
import { html } from 'lit';
import { property, queryAsync } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './animation.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Animate elements declaratively with nearly 100 baked-in presets, or roll your own with custom keyframes. Powered by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API).
* @documentation https://shoelace.style/components/animation
* @status stable
* @since 2.0
*
* @event sl-cancel - Emitted when the animation is canceled.
* @event sl-finish - Emitted when the animation finishes.
* @event sl-start - Emitted when the animation starts or restarts.
*
* @slot - The element to animate. Avoid slotting in more than one element, as subsequent ones will be ignored. To
* 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;
private animation?: Animation;
private hasStarted = false;
@queryAsync('slot') defaultSlot: Promise<HTMLSlotElement>;
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
@property() name = 'none';
/**
* Plays the animation. When omitted, the animation will be paused. This attribute will be automatically removed when
* the animation finishes or gets canceled.
*/
@property({ type: Boolean, reflect: true }) play = false;
/** The number of milliseconds to delay the start of the animation. */
@property({ type: Number }) delay = 0;
/**
* Determines the direction of playback as well as the behavior when reaching the end of an iteration.
* [Learn more](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-direction)
*/
@property() direction: PlaybackDirection = 'normal';
/** The number of milliseconds each iteration of the animation takes to complete. */
@property({ type: Number }) duration = 1000;
/**
* The easing function to use for the animation. This can be a Shoelace easing function or a custom easing function
* such as `cubic-bezier(0, 1, .76, 1.14)`.
*/
@property() easing = 'linear';
/** The number of milliseconds to delay after the active period of an animation sequence. */
@property({ attribute: 'end-delay', type: Number }) endDelay = 0;
/** Sets how the animation applies styles to its target before and after its execution. */
@property() fill: FillMode = 'auto';
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
@property({ type: Number }) iterations = Infinity;
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
@property({ attribute: 'iteration-start', type: Number }) iterationStart = 0;
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
@property({ attribute: false }) keyframes?: Keyframe[];
/**
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
* to `2`, for example, will double the animation's speed. A negative value can be used to reverse the animation. This
* value can be changed without causing the animation to restart.
*/
@property({ attribute: 'playback-rate', type: Number }) playbackRate = 1;
/** Gets and sets the current animation time. */
get currentTime(): CSSNumberish {
return this.animation?.currentTime ?? 0;
}
set currentTime(time: number) {
if (this.animation) {
this.animation.currentTime = time;
}
}
connectedCallback() {
super.connectedCallback();
this.createAnimation();
}
disconnectedCallback() {
super.disconnectedCallback();
this.destroyAnimation();
}
private handleAnimationFinish = () => {
this.play = false;
this.hasStarted = false;
this.emit('sl-finish');
};
private handleAnimationCancel = () => {
this.play = false;
this.hasStarted = false;
this.emit('sl-cancel');
};
private handleSlotChange() {
this.destroyAnimation();
this.createAnimation();
}
private async createAnimation() {
const easing = animations.easings[this.easing] ?? this.easing;
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
const slot = await this.defaultSlot;
const element = slot.assignedElements()[0] as HTMLElement | undefined;
if (!element || !keyframes) {
return false;
}
this.destroyAnimation();
this.animation = element.animate(keyframes, {
delay: this.delay,
direction: this.direction,
duration: this.duration,
easing,
endDelay: this.endDelay,
fill: this.fill,
iterationStart: this.iterationStart,
iterations: this.iterations
});
this.animation.playbackRate = this.playbackRate;
this.animation.addEventListener('cancel', this.handleAnimationCancel);
this.animation.addEventListener('finish', this.handleAnimationFinish);
if (this.play) {
this.hasStarted = true;
this.emit('sl-start');
} else {
this.animation.pause();
}
return true;
}
private destroyAnimation() {
if (this.animation) {
this.animation.cancel();
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
this.animation.removeEventListener('finish', this.handleAnimationFinish);
this.hasStarted = false;
}
}
@watch([
'name',
'delay',
'direction',
'duration',
'easing',
'endDelay',
'fill',
'iterations',
'iterationsStart',
'keyframes'
])
handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
}
@watch('play')
handlePlayChange() {
if (this.animation) {
if (this.play && !this.hasStarted) {
this.hasStarted = true;
this.emit('sl-start');
}
if (this.play) {
this.animation.play();
} else {
this.animation.pause();
}
return true;
}
return false;
}
@watch('playbackRate')
handlePlaybackRateChange() {
if (this.animation) {
this.animation.playbackRate = this.playbackRate;
}
}
/** Clears all keyframe effects caused by this animation and aborts its playback. */
cancel() {
this.animation?.cancel();
}
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
finish() {
this.animation?.finish();
}
render() {
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-animation': SlAnimation;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,6 +1,6 @@
import '../../../dist/shoelace.js';
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import type SlAnimation from './animation';
import type SlAnimation from './animation.js';
describe('<sl-animation>', () => {
const boxToAnimate = html`<div style="width: 10px; height: 10px;" data-testid="animated-box"></div>`;

View File

@@ -1,227 +1,4 @@
import { animations } from './animations';
import { customElement, property, queryAsync } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './animation.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Animate elements declaratively with nearly 100 baked-in presets, or roll your own with custom keyframes. Powered by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API).
* @documentation https://shoelace.style/components/animation
* @status stable
* @since 2.0
*
* @event sl-cancel - Emitted when the animation is canceled.
* @event sl-finish - Emitted when the animation finishes.
* @event sl-start - Emitted when the animation starts or restarts.
*
* @slot - The element to animate. Avoid slotting in more than one element, as subsequent ones will be ignored. To
* animate multiple elements, either wrap them in a single container or use multiple `<sl-animation>` elements.
*/
@customElement('sl-animation')
export default class SlAnimation extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private animation?: Animation;
private hasStarted = false;
@queryAsync('slot') defaultSlot: Promise<HTMLSlotElement>;
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
@property() name = 'none';
/**
* Plays the animation. When omitted, the animation will be paused. This attribute will be automatically removed when
* the animation finishes or gets canceled.
*/
@property({ type: Boolean, reflect: true }) play = false;
/** The number of milliseconds to delay the start of the animation. */
@property({ type: Number }) delay = 0;
/**
* Determines the direction of playback as well as the behavior when reaching the end of an iteration.
* [Learn more](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-direction)
*/
@property() direction: PlaybackDirection = 'normal';
/** The number of milliseconds each iteration of the animation takes to complete. */
@property({ type: Number }) duration = 1000;
/**
* The easing function to use for the animation. This can be a Shoelace easing function or a custom easing function
* such as `cubic-bezier(0, 1, .76, 1.14)`.
*/
@property() easing = 'linear';
/** The number of milliseconds to delay after the active period of an animation sequence. */
@property({ attribute: 'end-delay', type: Number }) endDelay = 0;
/** Sets how the animation applies styles to its target before and after its execution. */
@property() fill: FillMode = 'auto';
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
@property({ type: Number }) iterations = Infinity;
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
@property({ attribute: 'iteration-start', type: Number }) iterationStart = 0;
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
@property({ attribute: false }) keyframes?: Keyframe[];
/**
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
* to `2`, for example, will double the animation's speed. A negative value can be used to reverse the animation. This
* value can be changed without causing the animation to restart.
*/
@property({ attribute: 'playback-rate', type: Number }) playbackRate = 1;
/** Gets and sets the current animation time. */
get currentTime(): CSSNumberish {
return this.animation?.currentTime ?? 0;
}
set currentTime(time: number) {
if (this.animation) {
this.animation.currentTime = time;
}
}
connectedCallback() {
super.connectedCallback();
this.createAnimation();
}
disconnectedCallback() {
super.disconnectedCallback();
this.destroyAnimation();
}
private handleAnimationFinish = () => {
this.play = false;
this.hasStarted = false;
this.emit('sl-finish');
};
private handleAnimationCancel = () => {
this.play = false;
this.hasStarted = false;
this.emit('sl-cancel');
};
private handleSlotChange() {
this.destroyAnimation();
this.createAnimation();
}
private async createAnimation() {
const easing = animations.easings[this.easing] ?? this.easing;
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
const slot = await this.defaultSlot;
const element = slot.assignedElements()[0] as HTMLElement | undefined;
if (!element || !keyframes) {
return false;
}
this.destroyAnimation();
this.animation = element.animate(keyframes, {
delay: this.delay,
direction: this.direction,
duration: this.duration,
easing,
endDelay: this.endDelay,
fill: this.fill,
iterationStart: this.iterationStart,
iterations: this.iterations
});
this.animation.playbackRate = this.playbackRate;
this.animation.addEventListener('cancel', this.handleAnimationCancel);
this.animation.addEventListener('finish', this.handleAnimationFinish);
if (this.play) {
this.hasStarted = true;
this.emit('sl-start');
} else {
this.animation.pause();
}
return true;
}
private destroyAnimation() {
if (this.animation) {
this.animation.cancel();
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
this.animation.removeEventListener('finish', this.handleAnimationFinish);
this.hasStarted = false;
}
}
@watch([
'name',
'delay',
'direction',
'duration',
'easing',
'endDelay',
'fill',
'iterations',
'iterationsStart',
'keyframes'
])
handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
}
@watch('play')
handlePlayChange() {
if (this.animation) {
if (this.play && !this.hasStarted) {
this.hasStarted = true;
this.emit('sl-start');
}
if (this.play) {
this.animation.play();
} else {
this.animation.pause();
}
return true;
}
return false;
}
@watch('playbackRate')
handlePlaybackRateChange() {
if (this.animation) {
this.animation.playbackRate = this.playbackRate;
}
}
/** Clears all keyframe effects caused by this animation and aborts its playback. */
cancel() {
this.animation?.cancel();
}
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
finish() {
this.animation?.finish();
}
render() {
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-animation': SlAnimation;
}
}
import SlAnimation from './animation.component.js';
export * from './animation.component.js';
export default SlAnimation;
SlAnimation.define('sl-animation');

View File

@@ -0,0 +1,104 @@
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 ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './avatar.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Avatars are used to represent a person or object.
* @documentation https://shoelace.style/components/avatar
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot icon - The default icon to use when no image or initials are present. Works best with `<sl-icon>`.
*
* @csspart base - The component's base wrapper.
* @csspart icon - The container that wraps the avatar's icon.
* @csspart initials - The container that wraps the avatar's initials.
* @csspart image - The avatar image. Only shown when the `image` attribute is set.
*
* @cssproperty --size - The size of the avatar.
*/
export default class SlAvatar extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = {
'sl-icon': SlIcon
};
@state() private hasError = false;
/** The image source to use for the avatar. */
@property() image = '';
/** A label to use to describe the avatar to assistive devices. */
@property() label = '';
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
@property() initials = '';
/** Indicates how the browser should load the image. */
@property() loading: 'eager' | 'lazy' = 'eager';
/** The shape of the avatar. */
@property({ reflect: true }) shape: 'circle' | 'square' | 'rounded' = 'circle';
@watch('image')
handleImageChange() {
// Reset the error when a new image is provided
this.hasError = false;
}
render() {
const avatarWithImage = html`
<img
part="image"
class="avatar__image"
src="${this.image}"
loading="${this.loading}"
alt=""
@error="${() => (this.hasError = true)}"
/>
`;
let avatarWithoutImage = html``;
if (this.initials) {
avatarWithoutImage = html`<div part="initials" class="avatar__initials">${this.initials}</div>`;
} else {
avatarWithoutImage = html`
<div part="icon" class="avatar__icon" aria-hidden="true">
<slot name="icon">
<sl-icon name="person-fill" library="system"></sl-icon>
</slot>
</div>
`;
}
return html`
<div
part="base"
class=${classMap({
avatar: true,
'avatar--circle': this.shape === 'circle',
'avatar--rounded': this.shape === 'rounded',
'avatar--square': this.shape === 'square'
})}
role="img"
aria-label=${this.label}
>
${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-avatar': SlAvatar;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,6 +1,6 @@
import '../../../dist/shoelace.js';
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import type SlAvatar from './avatar';
import type SlAvatar from './avatar.js';
// The default avatar background just misses AA contrast, but the next step up is way too dark. Since avatars aren't
// used to display text, we're going to relax this rule.

View File

@@ -1,100 +1,4 @@
import '../icon/icon';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, state } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './avatar.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Avatars are used to represent a person or object.
* @documentation https://shoelace.style/components/avatar
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot icon - The default icon to use when no image or initials are present. Works best with `<sl-icon>`.
*
* @csspart base - The component's base wrapper.
* @csspart icon - The container that wraps the avatar's icon.
* @csspart initials - The container that wraps the avatar's initials.
* @csspart image - The avatar image. Only shown when the `image` attribute is set.
*
* @cssproperty --size - The size of the avatar.
*/
@customElement('sl-avatar')
export default class SlAvatar extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@state() private hasError = false;
/** The image source to use for the avatar. */
@property() image = '';
/** A label to use to describe the avatar to assistive devices. */
@property() label = '';
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
@property() initials = '';
/** Indicates how the browser should load the image. */
@property() loading: 'eager' | 'lazy' = 'eager';
/** The shape of the avatar. */
@property({ reflect: true }) shape: 'circle' | 'square' | 'rounded' = 'circle';
@watch('image')
handleImageChange() {
// Reset the error when a new image is provided
this.hasError = false;
}
render() {
const avatarWithImage = html`
<img
part="image"
class="avatar__image"
src="${this.image}"
loading="${this.loading}"
alt=""
@error="${() => (this.hasError = true)}"
/>
`;
let avatarWithoutImage = html``;
if (this.initials) {
avatarWithoutImage = html`<div part="initials" class="avatar__initials">${this.initials}</div>`;
} else {
avatarWithoutImage = html`
<slot name="icon" part="icon" class="avatar__icon" aria-hidden="true">
<sl-icon name="person-fill" library="system"></sl-icon>
</slot>
`;
}
return html`
<div
part="base"
class=${classMap({
avatar: true,
'avatar--circle': this.shape === 'circle',
'avatar--rounded': this.shape === 'rounded',
'avatar--square': this.shape === 'square'
})}
role="img"
aria-label=${this.label}
>
${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-avatar': SlAvatar;
}
}
import SlAvatar from './avatar.component.js';
export * from './avatar.component.js';
export default SlAvatar;
SlAvatar.define('sl-avatar');

View File

@@ -0,0 +1,56 @@
import { classMap } from 'lit/directives/class-map.js';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './badge.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Badges are used to draw attention and display statuses or counts.
* @documentation https://shoelace.style/components/badge
* @status stable
* @since 2.0
*
* @slot - The badge's content.
*
* @csspart base - The component's base wrapper.
*/
export default class SlBadge extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** The badge's theme variant. */
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
/** Draws a pill-style badge with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/** Makes the badge pulsate to draw attention. */
@property({ type: Boolean, reflect: true }) pulse = false;
render() {
return html`
<span
part="base"
class=${classMap({
badge: true,
'badge--primary': this.variant === 'primary',
'badge--success': this.variant === 'success',
'badge--neutral': this.variant === 'neutral',
'badge--warning': this.variant === 'warning',
'badge--danger': this.variant === 'danger',
'badge--pill': this.pill,
'badge--pulse': this.pulse
})}
role="status"
>
<slot></slot>
</span>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-badge': SlBadge;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,6 +1,10 @@
import '../../../dist/shoelace.js';
import { expect, fixture, html } from '@open-wc/testing';
import type SlBadge from './badge';
import type SlBadge from './badge.js';
// The default badge background just misses AA contrast, but the next step up is way too dark. We're going to relax this
// rule for now.
const ignoredRules = ['color-contrast'];
describe('<sl-badge>', () => {
let el: SlBadge;
@@ -11,7 +15,7 @@ describe('<sl-badge>', () => {
});
it('should pass accessibility tests with a role of status on the base part.', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.getAttribute('role')).to.eq('status');
@@ -33,7 +37,7 @@ describe('<sl-badge>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('should append the pill class to the classlist to render a pill', () => {
@@ -48,7 +52,7 @@ describe('<sl-badge>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('should append the pulse class to the classlist to render a pulse', () => {
@@ -64,7 +68,7 @@ describe('<sl-badge>', () => {
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
await expect(el).to.be.accessible({ ignoredRules });
});
it('should default to square styling, with the primary color', () => {

View File

@@ -1,55 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './badge.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Badges are used to draw attention and display statuses or counts.
* @documentation https://shoelace.style/components/badge
* @status stable
* @since 2.0
*
* @slot - The badge's content.
*
* @csspart base - The component's base wrapper.
*/
@customElement('sl-badge')
export default class SlBadge extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** The badge's theme variant. */
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
/** Draws a pill-style badge with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/** Makes the badge pulsate to draw attention. */
@property({ type: Boolean, reflect: true }) pulse = false;
render() {
return html`
<slot
part="base"
class=${classMap({
badge: true,
'badge--primary': this.variant === 'primary',
'badge--success': this.variant === 'success',
'badge--neutral': this.variant === 'neutral',
'badge--warning': this.variant === 'warning',
'badge--danger': this.variant === 'danger',
'badge--pill': this.pill,
'badge--pulse': this.pulse
})}
role="status"
></slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-badge': SlBadge;
}
}
import SlBadge from './badge.component.js';
export * from './badge.component.js';
export default SlBadge;
SlBadge.define('sl-badge');

View File

@@ -0,0 +1,95 @@
import { classMap } from 'lit/directives/class-map.js';
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 ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './breadcrumb-item.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Breadcrumb Items are used inside [breadcrumbs](/components/breadcrumb) to represent different links.
* @documentation https://shoelace.style/components/breadcrumb-item
* @status stable
* @since 2.0
*
* @slot - The breadcrumb item's label.
* @slot prefix - An optional prefix, usually an icon or icon button.
* @slot suffix - An optional suffix, usually an icon or icon button.
* @slot separator - The separator to use for the breadcrumb item. This will only change the separator for this item. If
* you want to change it for all items in the group, set the separator on `<sl-breadcrumb>` instead.
*
* @csspart base - The component's base wrapper.
* @csspart label - The breadcrumb item's label.
* @csspart prefix - The container that wraps the prefix.
* @csspart suffix - The container that wraps the suffix.
* @csspart separator - The container that wraps the separator.
*/
export default class SlBreadcrumbItem extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
/**
* Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered
* internally. When unset, a button will be rendered instead.
*/
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** The `rel` attribute to use on the link. Only used when `href` is set. */
@property() rel = 'noreferrer noopener';
render() {
const isLink = this.href ? true : false;
return html`
<div
part="base"
class=${classMap({
'breadcrumb-item': true,
'breadcrumb-item--has-prefix': this.hasSlotController.test('prefix'),
'breadcrumb-item--has-suffix': this.hasSlotController.test('suffix')
})}
>
<span part="prefix" class="breadcrumb-item__prefix">
<slot name="prefix"></slot>
</span>
${isLink
? html`
<a
part="label"
class="breadcrumb-item__label breadcrumb-item__label--link"
href="${this.href!}"
target="${ifDefined(this.target ? this.target : undefined)}"
rel=${ifDefined(this.target ? this.rel : undefined)}
>
<slot></slot>
</a>
`
: html`
<button part="label" type="button" class="breadcrumb-item__label breadcrumb-item__label--button">
<slot></slot>
</button>
`}
<span part="suffix" class="breadcrumb-item__suffix">
<slot name="suffix"></slot>
</span>
<span part="separator" class="breadcrumb-item__separator" aria-hidden="true">
<slot name="separator"></slot>
</span>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-breadcrumb-item': SlBreadcrumbItem;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,6 +1,6 @@
import '../../../dist/shoelace.js';
import { expect, fixture, html } from '@open-wc/testing';
import type SlBreadcrumbItem from './breadcrumb-item';
import type SlBreadcrumbItem from './breadcrumb-item.js';
describe('<sl-breadcrumb-item>', () => {
let el: SlBreadcrumbItem;

View File

@@ -1,90 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './breadcrumb-item.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Breadcrumb Items are used inside [breadcrumbs](/components/breadcrumb) to represent different links.
* @documentation https://shoelace.style/components/breadcrumb-item
* @status stable
* @since 2.0
*
* @slot - The breadcrumb item's label.
* @slot prefix - An optional prefix, usually an icon or icon button.
* @slot suffix - An optional suffix, usually an icon or icon button.
* @slot separator - The separator to use for the breadcrumb item. This will only change the separator for this item. If
* you want to change it for all items in the group, set the separator on `<sl-breadcrumb>` instead.
*
* @csspart base - The component's base wrapper.
* @csspart label - The breadcrumb item's label.
* @csspart prefix - The container that wraps the prefix.
* @csspart suffix - The container that wraps the suffix.
* @csspart separator - The container that wraps the separator.
*/
@customElement('sl-breadcrumb-item')
export default class SlBreadcrumbItem extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
/**
* Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered
* internally. When unset, a button will be rendered instead.
*/
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** The `rel` attribute to use on the link. Only used when `href` is set. */
@property() rel = 'noreferrer noopener';
render() {
const isLink = this.href ? true : false;
return html`
<div
part="base"
class=${classMap({
'breadcrumb-item': true,
'breadcrumb-item--has-prefix': this.hasSlotController.test('prefix'),
'breadcrumb-item--has-suffix': this.hasSlotController.test('suffix')
})}
>
<slot name="prefix" part="prefix" class="breadcrumb-item__prefix"></slot>
${isLink
? html`
<a
part="label"
class="breadcrumb-item__label breadcrumb-item__label--link"
href="${this.href!}"
target="${ifDefined(this.target ? this.target : undefined)}"
rel=${ifDefined(this.target ? this.rel : undefined)}
>
<slot></slot>
</a>
`
: html`
<button part="label" type="button" class="breadcrumb-item__label breadcrumb-item__label--button">
<slot></slot>
</button>
`}
<slot name="suffix" part="suffix" class="breadcrumb-item__suffix"></slot>
<slot name="separator" part="separator" class="breadcrumb-item__separator" aria-hidden="true"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-breadcrumb-item': SlBreadcrumbItem;
}
}
import SlBreadcrumbItem from './breadcrumb-item.component.js';
export * from './breadcrumb-item.component.js';
export default SlBreadcrumbItem;
SlBreadcrumbItem.define('sl-breadcrumb-item');

View File

@@ -0,0 +1,106 @@
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './breadcrumb.styles.js';
import type { CSSResultGroup } from 'lit';
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item.js';
/**
* @summary Breadcrumbs provide a group of links so users can easily navigate a website's hierarchy.
* @documentation https://shoelace.style/components/breadcrumb
* @status stable
* @since 2.0
*
* @slot - One or more breadcrumb items to display.
* @slot separator - The separator to use between breadcrumb items. Works best with `<sl-icon>`.
*
* @dependency sl-icon
*
* @csspart base - The component's base wrapper.
*/
export default class SlBreadcrumb extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
private readonly localize = new LocalizeController(this);
private separatorDir = this.localize.dir();
@query('slot') defaultSlot: HTMLSlotElement;
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
/**
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
* screen readers and other assistive devices to provide more context for users.
*/
@property() label = '';
// Generates a clone of the separator element to use for each breadcrumb item
private getSeparator() {
const separator = this.separatorSlot.assignedElements({ flatten: true })[0] as HTMLElement;
// Clone it, remove ids, and slot it
const clone = separator.cloneNode(true) as HTMLElement;
[clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id'));
clone.setAttribute('data-default', '');
clone.slot = 'separator';
return clone;
}
private handleSlotChange() {
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
) as SlBreadcrumbItem[];
items.forEach((item, index) => {
// Append separators to each item if they don't already have one
const separator = item.querySelector('[slot="separator"]');
if (separator === null) {
// No separator exists, add one
item.append(this.getSeparator());
} else if (separator.hasAttribute('data-default')) {
// A default separator exists, replace it
separator.replaceWith(this.getSeparator());
} else {
// The user provided a custom separator, leave it alone
}
// The last breadcrumb item is the "current page"
if (index === items.length - 1) {
item.setAttribute('aria-current', 'page');
} else {
item.removeAttribute('aria-current');
}
});
}
render() {
// We clone the separator and inject them into breadcrumb items, so we need to regenerate the default ones when
// directionality changes. We do this by storing the current separator direction, waiting for render, then calling
// the function that regenerates them.
if (this.separatorDir !== this.localize.dir()) {
this.separatorDir = this.localize.dir();
this.updateComplete.then(() => this.handleSlotChange());
}
return html`
<nav part="base" class="breadcrumb" aria-label=${this.label}>
<slot @slotchange=${this.handleSlotChange}></slot>
</nav>
<span hidden aria-hidden="true">
<slot name="separator">
<sl-icon name=${this.localize.dir() === 'rtl' ? 'chevron-left' : 'chevron-right'} library="system"></sl-icon>
</slot>
</span>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-breadcrumb': SlBreadcrumb;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,6 +1,6 @@
import '../../../dist/shoelace.js';
import { expect, fixture, html } from '@open-wc/testing';
import type SlBreadcrumb from './breadcrumb';
import type SlBreadcrumb from './breadcrumb.js';
// The default link color just misses AA contrast, but the next step up is way too dark. Maybe we can solve this in the
// future with a prefers-contrast media query.

View File

@@ -1,104 +1,4 @@
import '../icon/icon';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './breadcrumb.styles';
import type { CSSResultGroup } from 'lit';
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item';
/**
* @summary Breadcrumbs provide a group of links so users can easily navigate a website's hierarchy.
* @documentation https://shoelace.style/components/breadcrumb
* @status stable
* @since 2.0
*
* @slot - One or more breadcrumb items to display.
* @slot separator - The separator to use between breadcrumb items. Works best with `<sl-icon>`.
*
* @dependency sl-icon
*
* @csspart base - The component's base wrapper.
*/
@customElement('sl-breadcrumb')
export default class SlBreadcrumb extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
private separatorDir = this.localize.dir();
@query('slot') defaultSlot: HTMLSlotElement;
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
/**
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
* screen readers and other assistive devices to provide more context for users.
*/
@property() label = '';
// Generates a clone of the separator element to use for each breadcrumb item
private getSeparator() {
const separator = this.separatorSlot.assignedElements({ flatten: true })[0] as HTMLElement;
// Clone it, remove ids, and slot it
const clone = separator.cloneNode(true) as HTMLElement;
[clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id'));
clone.setAttribute('data-default', '');
clone.slot = 'separator';
return clone;
}
private handleSlotChange() {
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
) as SlBreadcrumbItem[];
items.forEach((item, index) => {
// Append separators to each item if they don't already have one
const separator = item.querySelector('[slot="separator"]');
if (separator === null) {
// No separator exists, add one
item.append(this.getSeparator());
} else if (separator.hasAttribute('data-default')) {
// A default separator exists, replace it
separator.replaceWith(this.getSeparator());
} else {
// The user provided a custom separator, leave it alone
}
// The last breadcrumb item is the "current page"
if (index === items.length - 1) {
item.setAttribute('aria-current', 'page');
} else {
item.removeAttribute('aria-current');
}
});
}
render() {
// We clone the separator and inject them into breadcrumb items, so we need to regenerate the default ones when
// directionality changes. We do this by storing the current separator direction, waiting for render, then calling
// the function that regenerates them.
if (this.separatorDir !== this.localize.dir()) {
this.separatorDir = this.localize.dir();
this.updateComplete.then(() => this.handleSlotChange());
}
return html`
<nav part="base" class="breadcrumb" aria-label=${this.label}>
<slot @slotchange=${this.handleSlotChange}></slot>
</nav>
<slot name="separator" hidden aria-hidden="true">
<sl-icon name=${this.localize.dir() === 'rtl' ? 'chevron-left' : 'chevron-right'} library="system"></sl-icon>
</slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-breadcrumb': SlBreadcrumb;
}
}
import SlBreadcrumb from './breadcrumb.component.js';
export * from './breadcrumb.component.js';
export default SlBreadcrumb;
SlBreadcrumb.define('sl-breadcrumb');

View File

@@ -0,0 +1,97 @@
import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './button-group.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Button groups can be used to group related buttons into sections.
* @documentation https://shoelace.style/components/button-group
* @status stable
* @since 2.0
*
* @slot - One or more `<sl-button>` elements to display in the button group.
*
* @csspart base - The component's base wrapper.
*/
export default class SlButtonGroup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('slot') defaultSlot: HTMLSlotElement;
@state() disableRole = false;
/**
* A label to use for the button group. This won't be displayed on the screen, but it will be announced by assistive
* devices when interacting with the control and is strongly recommended.
*/
@property() label = '';
private handleFocus(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--focus');
}
private handleBlur(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--focus');
}
private handleMouseOver(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--hover');
}
private handleMouseOut(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--hover');
}
private handleSlotChange() {
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
slottedElements.forEach(el => {
const index = slottedElements.indexOf(el);
const button = findButton(el);
if (button !== null) {
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');
}
});
}
render() {
// eslint-disable-next-line lit-a11y/mouse-events-have-key-events
return html`
<div
part="base"
class="button-group"
role="${this.disableRole ? 'presentation' : 'group'}"
aria-label=${this.label}
@focusout=${this.handleBlur}
@focusin=${this.handleFocus}
@mouseover=${this.handleMouseOver}
@mouseout=${this.handleMouseOut}
>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>
`;
}
}
function findButton(el: HTMLElement) {
const selector = 'sl-button, sl-radio-button';
// The button could be the target element or a child of it (e.g. a dropdown or tooltip anchor)
return el.closest(selector) ?? el.querySelector(selector);
}
declare global {
interface HTMLElementTagNameMap {
'sl-button-group': SlButtonGroup;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

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

View File

@@ -1,97 +1,4 @@
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './button-group.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Button groups can be used to group related buttons into sections.
* @documentation https://shoelace.style/components/button-group
* @status stable
* @since 2.0
*
* @slot - One or more `<sl-button>` elements to display in the button group.
*
* @csspart base - The component's base wrapper.
*/
@customElement('sl-button-group')
export default class SlButtonGroup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('slot') defaultSlot: HTMLSlotElement;
@state() disableRole = false;
/**
* A label to use for the button group. This won't be displayed on the screen, but it will be announced by assistive
* devices when interacting with the control and is strongly recommended.
*/
@property() label = '';
private handleFocus(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--focus');
}
private handleBlur(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--focus');
}
private handleMouseOver(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--hover');
}
private handleMouseOut(event: Event) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--hover');
}
private handleSlotChange() {
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
slottedElements.forEach(el => {
const index = slottedElements.indexOf(el);
const button = findButton(el);
if (button !== null) {
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');
}
});
}
render() {
// eslint-disable-next-line lit-a11y/mouse-events-have-key-events
return html`
<slot
part="base"
class="button-group"
role="${this.disableRole ? 'presentation' : 'group'}"
aria-label=${this.label}
@focusout=${this.handleBlur}
@focusin=${this.handleFocus}
@mouseover=${this.handleMouseOver}
@mouseout=${this.handleMouseOut}
@slotchange=${this.handleSlotChange}
></slot>
`;
}
}
function findButton(el: HTMLElement) {
const selector = 'sl-button, sl-radio-button';
// The button could be the target element or a child of it (e.g. a dropdown or tooltip anchor)
return el.closest(selector) ?? el.querySelector(selector);
}
declare global {
interface HTMLElementTagNameMap {
'sl-button-group': SlButtonGroup;
}
}
import SlButtonGroup from './button-group.component.js';
export * from './button-group.component.js';
export default SlButtonGroup;
SlButtonGroup.define('sl-button-group');

View File

@@ -0,0 +1,336 @@
import { classMap } from 'lit/directives/class-map.js';
import { FormControlController, validValidityState } from '../../internal/form.js';
import { HasSlotController } from '../../internal/slot.js';
import { html, literal } from 'lit/static-html.js';
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 ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import SlSpinner from '../spinner/spinner.component.js';
import styles from './button.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
/**
* @summary Buttons represent actions that are available to the user.
* @documentation https://shoelace.style/components/button
* @status stable
* @since 2.0
*
* @dependency sl-icon
* @dependency sl-spinner
*
* @event sl-blur - Emitted when the button loses focus.
* @event sl-focus - Emitted when the button gains focus.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @slot - The button's label.
* @slot prefix - A presentational prefix icon or similar element.
* @slot suffix - A presentational suffix icon or similar element.
*
* @csspart base - The component's base wrapper.
* @csspart prefix - The container that wraps the prefix.
* @csspart label - The button's label.
* @csspart suffix - The container that wraps the suffix.
* @csspart caret - The button's caret icon, an `<sl-icon>` element.
* @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 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);
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
/** The button's theme variant. */
@property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' =
'default';
/** The button's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws the button with a caret. Used to indicate that the button triggers a dropdown menu or similar behavior. */
@property({ type: Boolean, reflect: true }) caret = false;
/** Disables the button. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Draws the button in a loading state. */
@property({ type: Boolean, reflect: true }) loading = false;
/** Draws an outlined button. */
@property({ type: Boolean, reflect: true }) outline = false;
/** Draws a pill-style button with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/**
* Draws a circular icon button. When this attribute is present, the button expects a single `<sl-icon>` in the
* default slot.
*/
@property({ type: Boolean, reflect: true }) circle = false;
/**
* The type of button. Note that the default value is `button` instead of `submit`, which is opposite of how native
* `<button>` elements behave. When the type is `submit`, the button will submit the surrounding form.
*/
@property() type: 'button' | 'submit' | 'reset' = 'button';
/**
* The name of the button, submitted as a name/value pair with form data, but only when this button is the submitter.
* This attribute is ignored when `href` is present.
*/
@property() name = '';
/**
* The value of the button, submitted as a pair with the button's name as part of the form data, but only when this
* button is the submitter. This attribute is ignored when `href` is present.
*/
@property() value = '';
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href = '';
/** Tells the browser where to open the link. Only used when `href` is present. */
@property() target: '_blank' | '_parent' | '_self' | '_top';
/**
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
* setting the attribute to an empty string or a value of your choice, respectively.
*/
@property() rel = 'noreferrer noopener';
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
@property() download?: string;
/**
* The "form owner" to associate the button with. If omitted, the closest containing form will be used instead. The
* value of this attribute must be an id of a form in the same document or shadow root as the button.
*/
@property() form: string;
/** Used to override the form owner's `action` attribute. */
@property({ attribute: 'formaction' }) formAction: string;
/** Used to override the form owner's `enctype` attribute. */
@property({ attribute: 'formenctype' })
formEnctype: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
/** Used to override the form owner's `method` attribute. */
@property({ attribute: 'formmethod' }) formMethod: 'post' | 'get';
/** Used to override the form owner's `novalidate` attribute. */
@property({ attribute: 'formnovalidate', type: Boolean }) formNoValidate: boolean;
/** Used to override the form owner's `target` attribute. */
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
/** Gets the validity state object */
get validity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validity;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validationMessage;
}
return '';
}
firstUpdated() {
if (this.isButton()) {
this.formControlController.updateValidity();
}
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick() {
if (this.type === 'submit') {
this.formControlController.submit(this);
}
if (this.type === 'reset') {
this.formControlController.reset(this);
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private isButton() {
return this.href ? false : true;
}
private isLink() {
return this.href ? true : false;
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
if (this.isButton()) {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
}
/** Simulates a click on the button. */
click() {
this.button.click();
}
/** Sets focus on the button. */
focus(options?: FocusOptions) {
this.button.focus(options);
}
/** Removes focus from the button. */
blur() {
this.button.blur();
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).checkValidity();
}
return true;
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).reportValidity();
}
return true;
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
if (this.isButton()) {
(this.button as HTMLButtonElement).setCustomValidity(message);
this.formControlController.updateValidity();
}
}
render() {
const isLink = this.isLink();
const tag = isLink ? literal`a` : literal`button`;
/* eslint-disable lit/no-invalid-html */
/* eslint-disable lit/binding-positions */
return html`
<${tag}
part="base"
class=${classMap({
button: true,
'button--default': this.variant === 'default',
'button--primary': this.variant === 'primary',
'button--success': this.variant === 'success',
'button--neutral': this.variant === 'neutral',
'button--warning': this.variant === 'warning',
'button--danger': this.variant === 'danger',
'button--text': this.variant === 'text',
'button--small': this.size === 'small',
'button--medium': this.size === 'medium',
'button--large': this.size === 'large',
'button--caret': this.caret,
'button--circle': this.circle,
'button--disabled': this.disabled,
'button--focused': this.hasFocus,
'button--loading': this.loading,
'button--standard': !this.outline,
'button--outline': this.outline,
'button--pill': this.pill,
'button--rtl': this.localize.dir() === 'rtl',
'button--has-label': this.hasSlotController.test('[default]'),
'button--has-prefix': this.hasSlotController.test('prefix'),
'button--has-suffix': this.hasSlotController.test('suffix')
})}
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
type=${ifDefined(isLink ? undefined : this.type)}
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
name=${ifDefined(isLink ? undefined : this.name)}
value=${ifDefined(isLink ? undefined : this.value)}
href=${ifDefined(isLink ? this.href : undefined)}
target=${ifDefined(isLink ? this.target : undefined)}
download=${ifDefined(isLink ? this.download : undefined)}
rel=${ifDefined(isLink ? this.rel : undefined)}
role=${ifDefined(isLink ? undefined : 'button')}
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@invalid=${this.isButton() ? this.handleInvalid : null}
@click=${this.handleClick}
>
<slot name="prefix" part="prefix" class="button__prefix"></slot>
<slot part="label" class="button__label"></slot>
<slot name="suffix" part="suffix" class="button__suffix"></slot>
${
this.caret ? html` <sl-icon part="caret" class="button__caret" library="system" name="caret"></sl-icon> ` : ''
}
${this.loading ? html`<sl-spinner part="spinner"></sl-spinner>` : ''}
</${tag}>
`;
/* eslint-enable lit/no-invalid-html */
/* eslint-enable lit/binding-positions */
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-button': SlButton;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,8 +1,8 @@
import '../../../dist/shoelace.js';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import sinon from 'sinon';
import type SlButton from './button';
import type SlButton from './button.js';
const variants = ['default', 'primary', 'success', 'neutral', 'warning', 'danger'];
@@ -68,27 +68,6 @@ describe('<sl-button>', () => {
const el = await fixture<SlButton>(html` <sl-button href="some/path" disabled>Button Label</sl-button> `);
expect(el.shadowRoot!.querySelector('a[disabled]')).not.to.exist;
});
it('should not bubble up clicks', async () => {
const button = await fixture<SlButton>(html` <sl-button disabled>Button Label</sl-button> `);
const handleClick = sinon.spy();
button.addEventListener('click', handleClick);
button.click();
expect(handleClick).not.to.have.been.called;
button.shadowRoot!.querySelector('button')!.click();
expect(handleClick).not.to.have.been.called;
const buttonLink = await fixture<SlButton>(html` <sl-button href="some/path" disabled>Button Label</sl-button> `);
buttonLink.addEventListener('click', handleClick);
buttonLink.click();
expect(handleClick).not.to.have.been.called;
buttonLink.shadowRoot!.querySelector('a')!.click();
expect(handleClick).not.to.have.been.called;
});
});
it('should have title if title attribute is set', async () => {

View File

@@ -1,345 +1,4 @@
import '../icon/icon';
import '../spinner/spinner';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { FormControlController, validValidityState } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { html, literal } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './button.styles';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
/**
* @summary Buttons represent actions that are available to the user.
* @documentation https://shoelace.style/components/button
* @status stable
* @since 2.0
*
* @dependency sl-icon
* @dependency sl-spinner
*
* @event sl-blur - Emitted when the button loses focus.
* @event sl-focus - Emitted when the button gains focus.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @slot - The button's label.
* @slot prefix - A presentational prefix icon or similar element.
* @slot suffix - A presentational suffix icon or similar element.
*
* @csspart base - The component's base wrapper.
* @csspart prefix - The container that wraps the prefix.
* @csspart label - The button's label.
* @csspart suffix - The container that wraps the suffix.
* @csspart caret - The button's caret icon, an `<sl-icon>` element.
*/
@customElement('sl-button')
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
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);
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
/** The button's theme variant. */
@property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' =
'default';
/** The button's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws the button with a caret. Used to indicate that the button triggers a dropdown menu or similar behavior. */
@property({ type: Boolean, reflect: true }) caret = false;
/** Disables the button. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Draws the button in a loading state. */
@property({ type: Boolean, reflect: true }) loading = false;
/** Draws an outlined button. */
@property({ type: Boolean, reflect: true }) outline = false;
/** Draws a pill-style button with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/**
* Draws a circular icon button. When this attribute is present, the button expects a single `<sl-icon>` in the
* default slot.
*/
@property({ type: Boolean, reflect: true }) circle = false;
/**
* The type of button. Note that the default value is `button` instead of `submit`, which is opposite of how native
* `<button>` elements behave. When the type is `submit`, the button will submit the surrounding form.
*/
@property() type: 'button' | 'submit' | 'reset' = 'button';
/**
* The name of the button, submitted as a name/value pair with form data, but only when this button is the submitter.
* This attribute is ignored when `href` is present.
*/
@property() name = '';
/**
* The value of the button, submitted as a pair with the button's name as part of the form data, but only when this
* button is the submitter. This attribute is ignored when `href` is present.
*/
@property() value = '';
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href = '';
/** Tells the browser where to open the link. Only used when `href` is present. */
@property() target: '_blank' | '_parent' | '_self' | '_top';
/**
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
* setting the attribute to an empty string or a value of your choice, respectively.
*/
@property() rel = 'noreferrer noopener';
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
@property() download?: string;
/**
* The "form owner" to associate the button with. If omitted, the closest containing form will be used instead. The
* value of this attribute must be an id of a form in the same document or shadow root as the button.
*/
@property() form: string;
/** Used to override the form owner's `action` attribute. */
@property({ attribute: 'formaction' }) formAction: string;
/** Used to override the form owner's `enctype` attribute. */
@property({ attribute: 'formenctype' })
formEnctype: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
/** Used to override the form owner's `method` attribute. */
@property({ attribute: 'formmethod' }) formMethod: 'post' | 'get';
/** Used to override the form owner's `novalidate` attribute. */
@property({ attribute: 'formnovalidate', type: Boolean }) formNoValidate: boolean;
/** Used to override the form owner's `target` attribute. */
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
/** Gets the validity state object */
get validity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validity;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validationMessage;
}
return '';
}
constructor() {
super();
this.addEventListener('click', this.handleHostClick);
}
firstUpdated() {
if (this.isButton()) {
this.formControlController.updateValidity();
}
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick() {
if (this.type === 'submit') {
this.formControlController.submit(this);
}
if (this.type === 'reset') {
this.formControlController.reset(this);
}
}
private handleHostClick = (event: MouseEvent) => {
// Prevent the click event from being emitted when the button is disabled or loading
if (this.disabled || this.loading) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private isButton() {
return this.href ? false : true;
}
private isLink() {
return this.href ? true : false;
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
if (this.isButton()) {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
}
/** Simulates a click on the button. */
click() {
this.button.click();
}
/** Sets focus on the button. */
focus(options?: FocusOptions) {
this.button.focus(options);
}
/** Removes focus from the button. */
blur() {
this.button.blur();
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).checkValidity();
}
return true;
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).reportValidity();
}
return true;
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
if (this.isButton()) {
(this.button as HTMLButtonElement).setCustomValidity(message);
this.formControlController.updateValidity();
}
}
render() {
const isLink = this.isLink();
const tag = isLink ? literal`a` : literal`button`;
/* eslint-disable lit/no-invalid-html */
/* eslint-disable lit/binding-positions */
return html`
<${tag}
part="base"
class=${classMap({
button: true,
'button--default': this.variant === 'default',
'button--primary': this.variant === 'primary',
'button--success': this.variant === 'success',
'button--neutral': this.variant === 'neutral',
'button--warning': this.variant === 'warning',
'button--danger': this.variant === 'danger',
'button--text': this.variant === 'text',
'button--small': this.size === 'small',
'button--medium': this.size === 'medium',
'button--large': this.size === 'large',
'button--caret': this.caret,
'button--circle': this.circle,
'button--disabled': this.disabled,
'button--focused': this.hasFocus,
'button--loading': this.loading,
'button--standard': !this.outline,
'button--outline': this.outline,
'button--pill': this.pill,
'button--rtl': this.localize.dir() === 'rtl',
'button--has-label': this.hasSlotController.test('[default]'),
'button--has-prefix': this.hasSlotController.test('prefix'),
'button--has-suffix': this.hasSlotController.test('suffix')
})}
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
type=${ifDefined(isLink ? undefined : this.type)}
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
name=${ifDefined(isLink ? undefined : this.name)}
value=${ifDefined(isLink ? undefined : this.value)}
href=${ifDefined(isLink ? this.href : undefined)}
target=${ifDefined(isLink ? this.target : undefined)}
download=${ifDefined(isLink ? this.download : undefined)}
rel=${ifDefined(isLink ? this.rel : undefined)}
role=${ifDefined(isLink ? undefined : 'button')}
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@invalid=${this.isButton() ? this.handleInvalid : null}
@click=${this.handleClick}
>
<slot name="prefix" part="prefix" class="button__prefix"></slot>
<slot part="label" class="button__label"></slot>
<slot name="suffix" part="suffix" class="button__suffix"></slot>
${
this.caret ? html` <sl-icon part="caret" class="button__caret" library="system" name="caret"></sl-icon> ` : ''
}
${this.loading ? html`<sl-spinner></sl-spinner>` : ''}
</${tag}>
`;
/* eslint-enable lit/no-invalid-html */
/* eslint-enable lit/binding-positions */
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-button': SlButton;
}
}
import SlButton from './button.component.js';
export * from './button.component.js';
export default SlButton;
SlButton.define('sl-button');

View File

@@ -0,0 +1,59 @@
import { classMap } from 'lit/directives/class-map.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './card.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Cards can be used to group related subjects in a container.
* @documentation https://shoelace.style/components/card
* @status stable
* @since 2.0
*
* @slot - The card's main content.
* @slot header - An optional header for the card.
* @slot footer - An optional footer for the card.
* @slot image - An optional image to render at the start of the card.
*
* @csspart base - The component's base wrapper.
* @csspart image - The container that wraps the card's image.
* @csspart header - The container that wraps the card's header.
* @csspart body - The container that wraps the card's main content.
* @csspart footer - The container that wraps the card's footer.
*
* @cssproperty --border-color - The card's border color, including borders that occur inside the card.
* @cssproperty --border-radius - The border radius for the card's edges.
* @cssproperty --border-width - The width of the card's borders.
* @cssproperty --padding - The padding to use for the card's sections.
*/
export default class SlCard extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
render() {
return html`
<div
part="base"
class=${classMap({
card: true,
'card--has-footer': this.hasSlotController.test('footer'),
'card--has-image': this.hasSlotController.test('image'),
'card--has-header': this.hasSlotController.test('header')
})}
>
<slot name="image" part="image" class="card__image"></slot>
<slot name="header" part="header" class="card__header"></slot>
<slot part="body" class="card__body"></slot>
<slot name="footer" part="footer" class="card__footer"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-card': SlCard;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,6 +1,6 @@
import '../../../dist/shoelace.js';
import { expect, fixture, html } from '@open-wc/testing';
import type SlCard from './card';
import type SlCard from './card.js';
describe('<sl-card>', () => {
let el: SlCard;

View File

@@ -1,61 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './card.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Cards can be used to group related subjects in a container.
* @documentation https://shoelace.style/components/card
* @status stable
* @since 2.0
*
* @slot - The card's main content.
* @slot header - An optional header for the card.
* @slot footer - An optional footer for the card.
* @slot image - An optional image to render at the start of the card.
*
* @csspart base - The component's base wrapper.
* @csspart image - The container that wraps the card's image.
* @csspart header - The container that wraps the card's header.
* @csspart body - The container that wraps the card's main content.
* @csspart footer - The container that wraps the card's footer.
*
* @cssproperty --border-color - The card's border color, including borders that occur inside the card.
* @cssproperty --border-radius - The border radius for the card's edges.
* @cssproperty --border-width - The width of the card's borders.
* @cssproperty --padding - The padding to use for the card's sections.
*/
@customElement('sl-card')
export default class SlCard extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
render() {
return html`
<div
part="base"
class=${classMap({
card: true,
'card--has-footer': this.hasSlotController.test('footer'),
'card--has-image': this.hasSlotController.test('image'),
'card--has-header': this.hasSlotController.test('header')
})}
>
<slot name="image" part="image" class="card__image"></slot>
<slot name="header" part="header" class="card__header"></slot>
<slot part="body" class="card__body"></slot>
<slot name="footer" part="footer" class="card__footer"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-card': SlCard;
}
}
import SlCard from './card.component.js';
export * from './card.component.js';
export default SlCard;
SlCard.define('sl-card');

View File

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

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,8 +1,8 @@
import '../../../dist/shoelace.js';
import { clickOnElement } from '../../internal/test';
import { clickOnElement } from '../../internal/test.js';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import sinon from 'sinon';
import type SlCarousel from './carousel';
import type SlCarousel from './carousel.js';
describe('<sl-carousel>', () => {
it('should render a carousel with default configuration', async () => {

View File

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

View File

@@ -1,6 +1,6 @@
import { debounce } from '../../internal/debounce';
import { prefersReducedMotion } from '../../internal/animate';
import { waitForEvent } from '../../internal/event';
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 {

View File

@@ -0,0 +1,251 @@
import { classMap } from 'lit/directives/class-map.js';
import { defaultValue } from '../../internal/default-value.js';
import { FormControlController } from '../../internal/form.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 ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './checkbox.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
/**
* @summary Checkboxes allow the user to toggle an option on or off.
* @documentation https://shoelace.style/components/checkbox
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The checkbox's label.
*
* @event sl-blur - Emitted when the checkbox loses focus.
* @event sl-change - Emitted when the checked state changes.
* @event sl-focus - Emitted when the checkbox gains focus.
* @event sl-input - Emitted when the checkbox receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart control - The square container that wraps the checkbox's checked state.
* @csspart control--checked - Matches the control part when the checkbox is checked.
* @csspart control--indeterminate - Matches the control part when the checkbox is indeterminate.
* @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.
*/
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
private readonly formControlController = new FormControlController(this, {
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
defaultValue: (control: SlCheckbox) => control.defaultChecked,
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
});
@query('input[type="checkbox"]') input: HTMLInputElement;
@state() private hasFocus = false;
@property() title = ''; // make reactive to pass through
/** The name of the checkbox, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the checkbox, submitted as a name/value pair with form data. */
@property() value: string;
/** The checkbox's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Disables the checkbox. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Draws the checkbox in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/**
* Draws the checkbox in an indeterminate state. This is usually applied to checkboxes that represents a "select
* all/none" behavior when associated checkboxes have a mix of checked and unchecked states.
*/
@property({ type: Boolean, reflect: true }) indeterminate = false;
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue('checked') defaultChecked = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
private handleClick() {
this.checked = !this.checked;
this.indeterminate = false;
this.emit('sl-change');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleInput() {
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked; // force a sync update
this.input.indeterminate = this.indeterminate; // force a sync update
this.formControlController.updateValidity();
}
/** Simulates a click on the checkbox. */
click() {
this.input.click();
}
/** Sets focus on the checkbox. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the checkbox. */
blur() {
this.input.blur();
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
}
/**
* Sets a custom validation message. The value provided will be shown to the user when the form is submitted. To clear
* the custom validation message, call this method with an empty string.
*/
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
//
// NOTE: we use a <div> around the label slot because of this Chrome bug.
//
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
//
return html`
<label
part="base"
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'
})}
>
<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"
>
${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>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-checkbox': SlCheckbox;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,10 +1,10 @@
import '../../../dist/shoelace.js';
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { clickOnElement } from '../../internal/test.js';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlCheckbox from './checkbox';
import type SlCheckbox from './checkbox.js';
describe('<sl-checkbox>', () => {
it('should pass accessibility tests', async () => {

View File

@@ -1,251 +1,4 @@
import '../icon/icon';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { defaultValue } from '../../internal/default-value';
import { FormControlController } from '../../internal/form';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './checkbox.styles';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
/**
* @summary Checkboxes allow the user to toggle an option on or off.
* @documentation https://shoelace.style/components/checkbox
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The checkbox's label.
*
* @event sl-blur - Emitted when the checkbox loses focus.
* @event sl-change - Emitted when the checked state changes.
* @event sl-focus - Emitted when the checkbox gains focus.
* @event sl-input - Emitted when the checkbox receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart control - The square container that wraps the checkbox's checked state.
* @csspart control--checked - Matches the control part when the checkbox is checked.
* @csspart control--indeterminate - Matches the control part when the checkbox is indeterminate.
* @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.
*/
@customElement('sl-checkbox')
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this, {
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
defaultValue: (control: SlCheckbox) => control.defaultChecked,
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
});
@query('input[type="checkbox"]') input: HTMLInputElement;
@state() private hasFocus = false;
@property() title = ''; // make reactive to pass through
/** The name of the checkbox, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the checkbox, submitted as a name/value pair with form data. */
@property() value: string;
/** The checkbox's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Disables the checkbox. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Draws the checkbox in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/**
* Draws the checkbox in an indeterminate state. This is usually applied to checkboxes that represents a "select
* all/none" behavior when associated checkboxes have a mix of checked and unchecked states.
*/
@property({ type: Boolean, reflect: true }) indeterminate = false;
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue('checked') defaultChecked = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
private handleClick() {
this.checked = !this.checked;
this.indeterminate = false;
this.emit('sl-change');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleInput() {
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked; // force a sync update
this.input.indeterminate = this.indeterminate; // force a sync update
this.formControlController.updateValidity();
}
/** Simulates a click on the checkbox. */
click() {
this.input.click();
}
/** Sets focus on the checkbox. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the checkbox. */
blur() {
this.input.blur();
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
}
/**
* Sets a custom validation message. The value provided will be shown to the user when the form is submitted. To clear
* the custom validation message, call this method with an empty string.
*/
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
//
// NOTE: we use a <div> around the label slot because of this Chrome bug.
//
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
//
return html`
<label
part="base"
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'
})}
>
<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"
>
${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>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-checkbox': SlCheckbox;
}
}
import SlCheckbox from './checkbox.component.js';
export * from './checkbox.component.js';
export default SlCheckbox;
SlCheckbox.define('sl-checkbox');

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

View File

@@ -1,11 +1,11 @@
import '../../../dist/shoelace.js';
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { clickOnElement } 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';
import { serialize } from '../../utilities/form.js';
import sinon from 'sinon';
import type SlColorPicker from './color-picker';
import type SlColorPicker from './color-picker.js';
describe('<sl-color-picker>', () => {
describe('when the value changes', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { html } from 'lit';
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 ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './details.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Details show a brief summary and expand to show additional content.
* @documentation https://shoelace.style/components/details
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The details' main content.
* @slot summary - The details' summary. Alternatively, you can use the `summary` attribute.
* @slot expand-icon - Optional expand icon to use instead of the default. Works best with `<sl-icon>`.
* @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with `<sl-icon>`.
*
* @event sl-show - Emitted when the details opens.
* @event sl-after-show - Emitted after the details opens and all animations are complete.
* @event sl-hide - Emitted when the details closes.
* @event sl-after-hide - Emitted after the details closes and all animations are complete.
*
* @csspart base - The component's base wrapper.
* @csspart header - The header that wraps both the summary and the expand/collapse icon.
* @csspart summary - The container that wraps the summary.
* @csspart summary-icon - The container that wraps the expand/collapse icons.
* @csspart content - The details content.
*
* @animation details.show - The animation to use when showing details. You can use `height: auto` with this animation.
* @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 dependencies = {
'sl-icon': SlIcon
};
private readonly localize = new LocalizeController(this);
@query('.details') details: HTMLDetailsElement;
@query('.details__header') header: HTMLElement;
@query('.details__body') body: HTMLElement;
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
detailsObserver: MutationObserver;
/**
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/** The summary to show in the header. If you need to display HTML, use the `summary` slot instead. */
@property() summary: string;
/** Disables the details so it can't be toggled. */
@property({ type: Boolean, reflect: true }) disabled = false;
firstUpdated() {
this.body.style.height = this.open ? 'auto' : '0';
if (this.open) {
this.details.open = true;
}
this.detailsObserver = new MutationObserver(changes => {
for (const change of changes) {
if (change.type === 'attributes' && change.attributeName === 'open') {
if (this.details.open) {
this.show();
} else {
this.hide();
}
}
}
});
this.detailsObserver.observe(this.details, { attributes: true });
}
disconnectedCallback() {
this.detailsObserver.disconnect();
}
private handleSummaryClick(ev: MouseEvent) {
ev.preventDefault();
if (!this.disabled) {
if (this.open) {
this.hide();
} else {
this.show();
}
this.header.focus();
}
}
private handleSummaryKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (this.open) {
this.hide();
} else {
this.show();
}
}
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault();
this.hide();
}
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault();
this.show();
}
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
this.details.open = true;
// Show
const slShow = this.emit('sl-show', { cancelable: true });
if (slShow.defaultPrevented) {
this.open = false;
this.details.open = false;
return;
}
await stopAnimations(this.body);
const { keyframes, options } = getAnimation(this, 'details.show', { dir: this.localize.dir() });
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
this.body.style.height = 'auto';
this.emit('sl-after-show');
} else {
// Hide
const slHide = this.emit('sl-hide', { cancelable: true });
if (slHide.defaultPrevented) {
this.details.open = true;
this.open = true;
return;
}
await stopAnimations(this.body);
const { keyframes, options } = getAnimation(this, 'details.hide', { dir: this.localize.dir() });
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
this.body.style.height = 'auto';
this.details.open = false;
this.emit('sl-after-hide');
}
}
/** Shows the details. */
async show() {
if (this.open || this.disabled) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the details */
async hide() {
if (!this.open || this.disabled) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
const isRtl = this.localize.dir() === 'rtl';
return html`
<details
part="base"
class=${classMap({
details: true,
'details--open': this.open,
'details--disabled': this.disabled,
'details--rtl': isRtl
})}
>
<summary
part="header"
id="header"
class="details__header"
role="button"
aria-expanded=${this.open ? 'true' : 'false'}
aria-controls="content"
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
@click=${this.handleSummaryClick}
@keydown=${this.handleSummaryKeyDown}
>
<slot name="summary" part="summary" class="details__summary">${this.summary}</slot>
<span part="summary-icon" class="details__summary-icon">
<slot name="expand-icon">
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
</slot>
<slot name="collapse-icon">
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
</slot>
</span>
</summary>
<div class="details__body" role="region" aria-labelledby="header">
<slot part="content" id="content" class="details__content"></slot>
</div>
</details>
`;
}
}
setDefaultAnimation('details.show', {
keyframes: [
{ height: '0', opacity: '0' },
{ height: 'auto', opacity: '1' }
],
options: { duration: 250, easing: 'linear' }
});
setDefaultAnimation('details.hide', {
keyframes: [
{ height: 'auto', opacity: '1' },
{ height: '0', opacity: '0' }
],
options: { duration: 250, easing: 'linear' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-details': SlDetails;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}
@@ -28,6 +28,10 @@ export default css`
cursor: pointer;
}
.details__header::-webkit-details-marker {
display: none;
}
.details__header:focus {
outline: none;
}

View File

@@ -31,20 +31,19 @@ describe('<sl-details>', () => {
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(body.hidden).to.be.false;
expect(parseInt(getComputedStyle(body).height)).to.be.greaterThan(0);
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<SlDetails>(html`
<sl-details>
<sl-details summary="click me">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</sl-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(body.hidden).to.be.true;
expect(parseInt(getComputedStyle(body).height)).to.equal(0);
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
@@ -55,7 +54,6 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
@@ -68,7 +66,6 @@ describe('<sl-details>', () => {
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
@@ -79,7 +76,6 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
@@ -92,7 +88,6 @@ describe('<sl-details>', () => {
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
@@ -127,7 +122,6 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
@@ -140,7 +134,6 @@ describe('<sl-details>', () => {
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.true;
});
it('should not open when preventing sl-show', async () => {

View File

@@ -1,225 +1,4 @@
import '../icon/icon';
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize';
import { waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './details.styles';
import type { CSSResultGroup } from 'lit';
/**
* @summary Details show a brief summary and expand to show additional content.
* @documentation https://shoelace.style/components/details
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The details' main content.
* @slot summary - The details' summary. Alternatively, you can use the `summary` attribute.
* @slot expand-icon - Optional expand icon to use instead of the default. Works best with `<sl-icon>`.
* @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with `<sl-icon>`.
*
* @event sl-show - Emitted when the details opens.
* @event sl-after-show - Emitted after the details opens and all animations are complete.
* @event sl-hide - Emitted when the details closes.
* @event sl-after-hide - Emitted after the details closes and all animations are complete.
*
* @csspart base - The component's base wrapper.
* @csspart header - The header that wraps both the summary and the expand/collapse icon.
* @csspart summary - The container that wraps the summary.
* @csspart summary-icon - The container that wraps the expand/collapse icons.
* @csspart content - The details content.
*
* @animation details.show - The animation to use when showing details. You can use `height: auto` with this animation.
* @animation details.hide - The animation to use when hiding details. You can use `height: auto` with this animation.
*/
@customElement('sl-details')
export default class SlDetails extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.details') details: HTMLElement;
@query('.details__header') header: HTMLElement;
@query('.details__body') body: HTMLElement;
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
/**
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/** The summary to show in the header. If you need to display HTML, use the `summary` slot instead. */
@property() summary: string;
/** Disables the details so it can't be toggled. */
@property({ type: Boolean, reflect: true }) disabled = false;
firstUpdated() {
this.body.hidden = !this.open;
this.body.style.height = this.open ? 'auto' : '0';
}
private handleSummaryClick() {
if (!this.disabled) {
if (this.open) {
this.hide();
} else {
this.show();
}
this.header.focus();
}
}
private handleSummaryKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (this.open) {
this.hide();
} else {
this.show();
}
}
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault();
this.hide();
}
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault();
this.show();
}
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
const slShow = this.emit('sl-show', { cancelable: true });
if (slShow.defaultPrevented) {
this.open = false;
return;
}
await stopAnimations(this.body);
this.body.hidden = false;
const { keyframes, options } = getAnimation(this, 'details.show', { dir: this.localize.dir() });
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
this.body.style.height = 'auto';
this.emit('sl-after-show');
} else {
// Hide
const slHide = this.emit('sl-hide', { cancelable: true });
if (slHide.defaultPrevented) {
this.open = true;
return;
}
await stopAnimations(this.body);
const { keyframes, options } = getAnimation(this, 'details.hide', { dir: this.localize.dir() });
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
this.body.hidden = true;
this.body.style.height = 'auto';
this.emit('sl-after-hide');
}
}
/** Shows the details. */
async show() {
if (this.open || this.disabled) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the details */
async hide() {
if (!this.open || this.disabled) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
const isRtl = this.localize.dir() === 'rtl';
return html`
<div
part="base"
class=${classMap({
details: true,
'details--open': this.open,
'details--disabled': this.disabled,
'details--rtl': isRtl
})}
>
<div
part="header"
id="header"
class="details__header"
role="button"
aria-expanded=${this.open ? 'true' : 'false'}
aria-controls="content"
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
@click=${this.handleSummaryClick}
@keydown=${this.handleSummaryKeyDown}
>
<slot name="summary" part="summary" class="details__summary">${this.summary}</slot>
<span part="summary-icon" class="details__summary-icon">
<slot name="expand-icon">
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
</slot>
<slot name="collapse-icon">
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
</slot>
</span>
</div>
<div class="details__body" role="region" aria-labelledby="header">
<slot part="content" id="content" class="details__content"></slot>
</div>
</div>
`;
}
}
setDefaultAnimation('details.show', {
keyframes: [
{ height: '0', opacity: '0' },
{ height: 'auto', opacity: '1' }
],
options: { duration: 250, easing: 'linear' }
});
setDefaultAnimation('details.hide', {
keyframes: [
{ height: 'auto', opacity: '1' },
{ height: '0', opacity: '0' }
],
options: { duration: 250, easing: 'linear' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-details': SlDetails;
}
}
import SlDetails from './details.component.js';
export * from './details.component.js';
export default SlDetails;
SlDetails.define('sl-details');

View File

@@ -0,0 +1,347 @@
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize.js';
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 Modal from '../../internal/modal.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIconButton from '../icon-button/icon-button.component.js';
import styles from './dialog.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Dialogs, sometimes called "modals", appear above the page and require the user's immediate attention.
* @documentation https://shoelace.style/components/dialog
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
* @slot - The dialog's main content.
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
*
* @event sl-show - Emitted when the dialog opens.
* @event sl-after-show - Emitted after the dialog opens and all animations are complete.
* @event sl-hide - Emitted when the dialog closes.
* @event sl-after-hide - Emitted after the dialog closes and all animations are complete.
* @event sl-initial-focus - Emitted when the dialog opens and is ready to receive focus. Calling
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
* close the dialog by clicking the close button, clicking the overlay, or pressing escape. Calling
* `event.preventDefault()` will keep the dialog open. Avoid using this unless closing the dialog will result in
* destructive behavior such as data loss.
*
* @csspart base - The component's base wrapper.
* @csspart overlay - The overlay that covers the screen behind the dialog.
* @csspart panel - The dialog's panel (where the dialog and its content are rendered).
* @csspart header - The dialog's header. This element wraps the title and header actions.
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
* @csspart title - The dialog's title.
* @csspart close-button - The close button, an `<sl-icon-button>`.
* @csspart close-button__base - The close button's exported `base` part.
* @csspart body - The dialog's body.
* @csspart footer - The dialog's footer.
*
* @cssproperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
* @cssproperty --header-spacing - The amount of padding to use for the header.
* @cssproperty --body-spacing - The amount of padding to use for the body.
* @cssproperty --footer-spacing - The amount of padding to use for the footer.
*
* @animation dialog.show - The animation to use when showing the dialog.
* @animation dialog.hide - The animation to use when hiding the dialog.
* @animation dialog.denyClose - The animation to use when a request to close the dialog is denied.
* @animation dialog.overlay.show - The animation to use when showing the dialog's overlay.
* @animation dialog.overlay.hide - The animation to use when hiding the dialog's overlay.
*/
export default class SlDialog extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = {
'sl-icon-button': SlIconButton
};
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal = new Modal(this);
private originalTrigger: HTMLElement | null;
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
/**
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* The dialog's label as displayed in the header. You should always include a relevant label even when using
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
*/
@property({ reflect: true }) label = '';
/**
* Disables the header. This will also remove the default close button, so please ensure you provide an easy,
* accessible way for users to dismiss the dialog.
*/
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
firstUpdated() {
this.dialog.hidden = !this.open;
if (this.open) {
this.addOpenListeners();
this.modal.activate();
lockBodyScrolling(this);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.modal.deactivate();
unlockBodyScrolling(this);
}
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
const slRequestClose = this.emit('sl-request-close', {
cancelable: true,
detail: { source }
});
if (slRequestClose.defaultPrevented) {
const animation = getAnimation(this, 'dialog.denyClose', { dir: this.localize.dir() });
animateTo(this.panel, animation.keyframes, animation.options);
return;
}
this.hide();
}
private addOpenListeners() {
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
private removeOpenListeners() {
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
private handleDocumentKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
event.stopPropagation();
this.requestClose('keyboard');
}
};
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sl-show');
this.addOpenListeners();
this.originalTrigger = document.activeElement as HTMLElement;
this.modal.activate();
lockBodyScrolling(this);
// When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause
// the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call
// `focus({ preventScroll: true })` ourselves, and add the attribute back afterwards.
//
// Related: https://github.com/shoelace-style/shoelace/issues/693
//
const autoFocusTarget = this.querySelector('[autofocus]');
if (autoFocusTarget) {
autoFocusTarget.removeAttribute('autofocus');
}
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
this.dialog.hidden = false;
// Set initial focus
requestAnimationFrame(() => {
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
if (!slInitialFocus.defaultPrevented) {
// Set focus to the autofocus target and restore the attribute
if (autoFocusTarget) {
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
} else {
this.panel.focus({ preventScroll: true });
}
}
// Restore the autofocus attribute
if (autoFocusTarget) {
autoFocusTarget.setAttribute('autofocus', '');
}
});
const panelAnimation = getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() });
await Promise.all([
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
]);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
this.removeOpenListeners();
this.modal.deactivate();
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
const panelAnimation = getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() });
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
// unexpectedly. We'll unhide them after all animations have completed.
await Promise.all([
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
this.overlay.hidden = true;
}),
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
this.panel.hidden = true;
})
]);
this.dialog.hidden = true;
// Now that the dialog is hidden, restore the overlay and panel for next time
this.overlay.hidden = false;
this.panel.hidden = false;
unlockBodyScrolling(this);
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (typeof trigger?.focus === 'function') {
setTimeout(() => trigger.focus());
}
this.emit('sl-after-hide');
}
}
/** Shows the dialog. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dialog */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div
part="base"
class=${classMap({
dialog: true,
'dialog--open': this.open,
'dialog--has-footer': this.hasSlotController.test('footer')
})}
>
<div part="overlay" class="dialog__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
<div
part="panel"
class="dialog__panel"
role="dialog"
aria-modal="true"
aria-hidden=${this.open ? 'false' : 'true'}
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
tabindex="-1"
>
${!this.noHeader
? html`
<header part="header" class="dialog__header">
<h2 part="title" class="dialog__title" id="title">
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
</h2>
<div part="header-actions" class="dialog__header-actions">
<slot name="header-actions"></slot>
<sl-icon-button
part="close-button"
exportparts="base:close-button__base"
class="dialog__close"
name="x-lg"
label=${this.localize.term('close')}
library="system"
@click="${() => this.requestClose('close-button')}"
></sl-icon-button>
</div>
</header>
`
: ''}
${
'' /* 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. */
}
<slot part="body" class="dialog__body" tabindex="-1"></slot>
<footer part="footer" class="dialog__footer">
<slot name="footer"></slot>
</footer>
</div>
</div>
`;
}
}
setDefaultAnimation('dialog.show', {
keyframes: [
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1 }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('dialog.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.8 }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('dialog.denyClose', {
keyframes: [{ scale: 1 }, { scale: 1.02 }, { scale: 1 }],
options: { duration: 250 }
});
setDefaultAnimation('dialog.overlay.show', {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 250 }
});
setDefaultAnimation('dialog.overlay.hide', {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
options: { duration: 250 }
});
declare global {
interface HTMLElementTagNameMap {
'sl-dialog': SlDialog;
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}

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