Compare commits

..

42 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
160 changed files with 14452 additions and 13370 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'
}
},

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

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

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

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

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

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

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

@@ -27,6 +27,8 @@ toc: false
</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>
@@ -34,6 +36,8 @@ toc: false
[![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.

View File

@@ -14,15 +14,37 @@ New versions of Shoelace are released as-needed and generally occur when a criti
## 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]
- Fixed broken links in the docs [#1407]
## 2.5.1

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` |

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.2",
"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,24 +25,54 @@ 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'

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'
},
{

View File

@@ -1,4 +1,4 @@
import { customElement, property } from 'lit/decorators.js';
import { property } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { watch } from '../../internal/watch.js';
@@ -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

@@ -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,244 +1,4 @@
import '../icon-button/icon-button.js';
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.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 { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.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.
*/
@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,122 +1,4 @@
import '../icon/icon.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.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.
*/
@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,227 +1,4 @@
import { animations } from './animations.js';
import { customElement, property, queryAsync } from 'lit/decorators.js';
import { html } from 'lit';
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.
*/
@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,100 +1,4 @@
import '../icon/icon.js';
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.js';
import ShoelaceElement from '../../internal/shoelace-element.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.
*/
@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

@@ -2,6 +2,10 @@ import '../../../dist/shoelace.js';
import { expect, fixture, html } from '@open-wc/testing';
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.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.
*/
@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,90 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.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.
*/
@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,104 +1,4 @@
import '../icon/icon.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import ShoelaceElement from '../../internal/shoelace-element.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.
*/
@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,97 +1,4 @@
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
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.
*/
@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

@@ -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.js';
import '../spinner/spinner.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.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 { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.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.
*/
@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,61 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement } from 'lit/decorators.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.
*/
@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,40 +1,4 @@
import { customElement } from 'lit/decorators.js';
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.
*
*/
@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,471 +1,4 @@
import '../icon/icon.js';
import { AutoplayController } from './autoplay-controller.js';
import { clamp } from '../../internal/math.js';
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.js';
import { map } from 'lit/directives/map.js';
import { prefersReducedMotion } from '../../internal/animate.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.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.
*/
@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

@@ -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,251 +1,4 @@
import '../icon/icon.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.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 { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.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.
*/
@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

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

@@ -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.js';
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.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.
*/
@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,6 +1,7 @@
import '../../../dist/shoelace.js';
// cspell:dictionaries lorem-ipsum
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { aTimeout, elementUpdated, expect, fixture, html, waitUntil } from '@open-wc/testing';
import { LitElement } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlDialog from './dialog';
@@ -146,4 +147,124 @@ describe('<sl-dialog>', () => {
expect(el.open).to.be.false;
});
// https://github.com/shoelace-style/shoelace/issues/1382
it('should properly cycle through tabbable elements when sl-dialog is used in a shadowRoot', async () => {
class AContainer extends LitElement {
get dialog() {
return this.shadowRoot?.querySelector('sl-dialog');
}
openDialog() {
this.dialog?.show();
}
render() {
return html`
<h1>Dialog Example</h1>
<sl-dialog label="Dialog" class="dialog-overview">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
<br />
<label><input type="checkbox" />A</label>
<label><input type="checkbox" />B</label>
<button>Button</button>
</sl-dialog>
<sl-button @click=${this.openDialog}>Open Dialog</sl-button>
`;
}
}
if (!window.customElements.get('a-container')) {
window.customElements.define('a-container', AContainer);
}
const testCase = await fixture(html`
<div>
<a-container></a-container>
<p>
Open the dialog, then use <kbd>Tab</kbd> to cycle through the inputs. Focus should be trapped, but it reaches
things outside the dialog.
</p>
</div>
`);
const container = testCase.querySelector('a-container');
if (!container) {
throw Error('Could not find <a-container> element.');
}
await elementUpdated(container);
const dialog = container.shadowRoot?.querySelector('sl-dialog');
if (!dialog) {
throw Error('Could not find <sl-dialog> element.');
}
const closeButton = dialog.shadowRoot?.querySelector('sl-icon-button');
const checkbox1 = dialog.querySelector("input[type='checkbox']");
const checkbox2 = dialog.querySelectorAll("input[type='checkbox']")[1];
const button = dialog.querySelector('button');
// Opens modal.
const openModalButton = container.shadowRoot?.querySelector('sl-button');
if (openModalButton) openModalButton.click();
// Test tab cycling
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(dialog);
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(checkbox1);
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(checkbox2);
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(button);
await pressTab();
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(checkbox1);
// Test Shift+Tab cycling
// I found these timeouts were needed for WebKit locally.
await aTimeout(10);
await sendKeys({ down: 'Shift' });
await aTimeout(10);
await pressTab();
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(button);
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(checkbox2);
await pressTab();
expect(container.shadowRoot?.activeElement).to.equal(checkbox1);
await pressTab();
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
// End shift+tab cycling
await sendKeys({ up: 'Shift' });
});
});
// We wait 50ms just to give the browser some time to figure out the current focus.
// 50 was the magic number I found locally :shrug:
async function pressTab() {
await aTimeout(50);
await sendKeys({ press: 'Tab' });
await aTimeout(50);
}

View File

@@ -1,342 +1,4 @@
import '../icon-button/icon-button.js';
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.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 { 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 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.
*/
@customElement('sl-dialog')
export default class SlDialog extends ShoelaceElement {
static styles: CSSResultGroup = styles;
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();
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 (this.open && event.key === 'Escape') {
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="0"
>
${!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>
`
: ''}
<slot part="body" class="dialog__body"></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;
}
}
import SlDialog from './dialog.component.js';
export * from './dialog.component.js';
export default SlDialog;
SlDialog.define('sl-dialog');

View File

@@ -0,0 +1,38 @@
import { property } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './divider.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Dividers are used to visually separate or group elements.
* @documentation https://shoelace.style/components/divider
* @status stable
* @since 2.0
*
* @cssproperty --color - The color of the divider.
* @cssproperty --width - The width of the divider.
* @cssproperty --spacing - The spacing of the divider.
*/
export default class SlDivider extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** Draws the divider in a vertical orientation. */
@property({ type: Boolean, reflect: true }) vertical = false;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'separator');
}
@watch('vertical')
handleVerticalChange() {
this.setAttribute('aria-orientation', this.vertical ? 'vertical' : 'horizontal');
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-divider': SlDivider;
}
}

View File

@@ -1,39 +1,4 @@
import { customElement, property } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './divider.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Dividers are used to visually separate or group elements.
* @documentation https://shoelace.style/components/divider
* @status stable
* @since 2.0
*
* @cssproperty --color - The color of the divider.
* @cssproperty --width - The width of the divider.
* @cssproperty --spacing - The spacing of the divider.
*/
@customElement('sl-divider')
export default class SlDivider extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** Draws the divider in a vertical orientation. */
@property({ type: Boolean, reflect: true }) vertical = false;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'separator');
}
@watch('vertical')
handleVerticalChange() {
this.setAttribute('aria-orientation', this.vertical ? 'vertical' : 'horizontal');
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-divider': SlDivider;
}
}
import SlDivider from './divider.component.js';
export * from './divider.component.js';
export default SlDivider;
SlDivider.define('sl-divider');

View File

@@ -0,0 +1,467 @@
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 { uppercaseFirstLetter } from '../../internal/string.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 './drawer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Drawers slide in from a container to expose additional options and information.
* @documentation https://shoelace.style/components/drawer
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
* @slot - The drawer's main content.
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
*
* @event sl-show - Emitted when the drawer opens.
* @event sl-after-show - Emitted after the drawer opens and all animations are complete.
* @event sl-hide - Emitted when the drawer closes.
* @event sl-after-hide - Emitted after the drawer closes and all animations are complete.
* @event sl-initial-focus - Emitted when the drawer 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 drawer by clicking the close button, clicking the overlay, or pressing escape. Calling
* `event.preventDefault()` will keep the drawer open. Avoid using this unless closing the drawer 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 drawer.
* @csspart panel - The drawer's panel (where the drawer and its content are rendered).
* @csspart header - The drawer'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 drawer'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 drawer's body.
* @csspart footer - The drawer's footer.
*
* @cssproperty --size - The preferred size of the drawer. This will be applied to the drawer's width or height
* depending on its `placement`. Note that the drawer 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 drawer.showTop - The animation to use when showing a drawer with `top` placement.
* @animation drawer.showEnd - The animation to use when showing a drawer with `end` placement.
* @animation drawer.showBottom - The animation to use when showing a drawer with `bottom` placement.
* @animation drawer.showStart - The animation to use when showing a drawer with `start` placement.
* @animation drawer.hideTop - The animation to use when hiding a drawer with `top` placement.
* @animation drawer.hideEnd - The animation to use when hiding a drawer with `end` placement.
* @animation drawer.hideBottom - The animation to use when hiding a drawer with `bottom` placement.
* @animation drawer.hideStart - The animation to use when hiding a drawer with `start` placement.
* @animation drawer.denyClose - The animation to use when a request to close the drawer is denied.
* @animation drawer.overlay.show - The animation to use when showing the drawer's overlay.
* @animation drawer.overlay.hide - The animation to use when hiding the drawer's overlay.
*/
export default class SlDrawer 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('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
/**
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* The drawer'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 = '';
/** The direction from which the drawer will open. */
@property({ reflect: true }) placement: 'top' | 'end' | 'bottom' | 'start' = 'end';
/**
* By default, the drawer slides out of its containing block (usually the viewport). To make the drawer slide out of
* its parent element, set this attribute and add `position: relative` to the parent.
*/
@property({ type: Boolean, reflect: true }) contained = false;
/**
* Removes the header. This will also remove the default close button, so please ensure you provide an easy,
* accessible way for users to dismiss the drawer.
*/
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
firstUpdated() {
this.drawer.hidden = !this.open;
if (this.open) {
this.addOpenListeners();
if (!this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
}
}
disconnectedCallback() {
super.disconnectedCallback();
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, 'drawer.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) => {
// Contained drawers aren't modal and don't response to the escape key
if (this.contained) {
return;
}
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
event.stopImmediatePropagation();
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;
// Lock body scrolling only if the drawer isn't contained
if (!this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
// When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the
// drawer's animation to jitter, 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.drawer), stopAnimations(this.overlay)]);
this.drawer.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, `drawer.show${uppercaseFirstLetter(this.placement)}`, {
dir: this.localize.dir()
});
const overlayAnimation = getAnimation(this, 'drawer.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();
if (!this.contained) {
this.modal.deactivate();
unlockBodyScrolling(this);
}
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`, {
dir: this.localize.dir()
});
const overlayAnimation = getAnimation(this, 'drawer.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.drawer.hidden = true;
// Now that the dialog is hidden, restore the overlay and panel for next time
this.overlay.hidden = false;
this.panel.hidden = false;
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (typeof trigger?.focus === 'function') {
setTimeout(() => trigger.focus());
}
this.emit('sl-after-hide');
}
}
@watch('contained', { waitUntilFirstUpdate: true })
handleNoModalChange() {
if (this.open && !this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
if (this.open && this.contained) {
this.modal.deactivate();
unlockBodyScrolling(this);
}
}
/** Shows the drawer. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the drawer */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div
part="base"
class=${classMap({
drawer: true,
'drawer--open': this.open,
'drawer--top': this.placement === 'top',
'drawer--end': this.placement === 'end',
'drawer--bottom': this.placement === 'bottom',
'drawer--start': this.placement === 'start',
'drawer--contained': this.contained,
'drawer--fixed': !this.contained,
'drawer--rtl': this.localize.dir() === 'rtl',
'drawer--has-footer': this.hasSlotController.test('footer')
})}
>
<div part="overlay" class="drawer__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
<div
part="panel"
class="drawer__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="0"
>
${!this.noHeader
? html`
<header part="header" class="drawer__header">
<h2 part="title" class="drawer__title" id="title">
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
</h2>
<div part="header-actions" class="drawer__header-actions">
<slot name="header-actions"></slot>
<sl-icon-button
part="close-button"
exportparts="base:close-button__base"
class="drawer__close"
name="x-lg"
label=${this.localize.term('close')}
library="system"
@click=${() => this.requestClose('close-button')}
></sl-icon-button>
</div>
</header>
`
: ''}
<slot part="body" class="drawer__body"></slot>
<footer part="footer" class="drawer__footer">
<slot name="footer"></slot>
</footer>
</div>
</div>
`;
}
}
// Top
setDefaultAnimation('drawer.showTop', {
keyframes: [
{ opacity: 0, translate: '0 -100%' },
{ opacity: 1, translate: '0 0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideTop', {
keyframes: [
{ opacity: 1, translate: '0 0' },
{ opacity: 0, translate: '0 -100%' }
],
options: { duration: 250, easing: 'ease' }
});
// End
setDefaultAnimation('drawer.showEnd', {
keyframes: [
{ opacity: 0, translate: '100%' },
{ opacity: 1, translate: '0' }
],
rtlKeyframes: [
{ opacity: 0, translate: '-100%' },
{ opacity: 1, translate: '0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideEnd', {
keyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '100%' }
],
rtlKeyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '-100%' }
],
options: { duration: 250, easing: 'ease' }
});
// Bottom
setDefaultAnimation('drawer.showBottom', {
keyframes: [
{ opacity: 0, translate: '0 100%' },
{ opacity: 1, translate: '0 0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideBottom', {
keyframes: [
{ opacity: 1, translate: '0 0' },
{ opacity: 0, translate: '0 100%' }
],
options: { duration: 250, easing: 'ease' }
});
// Start
setDefaultAnimation('drawer.showStart', {
keyframes: [
{ opacity: 0, translate: '-100%' },
{ opacity: 1, translate: '0' }
],
rtlKeyframes: [
{ opacity: 0, translate: '100%' },
{ opacity: 1, translate: '0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideStart', {
keyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '-100%' }
],
rtlKeyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '100%' }
],
options: { duration: 250, easing: 'ease' }
});
// Deny close
setDefaultAnimation('drawer.denyClose', {
keyframes: [{ scale: 1 }, { scale: 1.01 }, { scale: 1 }],
options: { duration: 250 }
});
// Overlay
setDefaultAnimation('drawer.overlay.show', {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 250 }
});
setDefaultAnimation('drawer.overlay.hide', {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
options: { duration: 250 }
});
declare global {
interface HTMLElementTagNameMap {
'sl-drawer': SlDrawer;
}
}

View File

@@ -1,462 +1,4 @@
import '../icon-button/icon-button.js';
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.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 { uppercaseFirstLetter } from '../../internal/string.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 styles from './drawer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Drawers slide in from a container to expose additional options and information.
* @documentation https://shoelace.style/components/drawer
* @status stable
* @since 2.0
*
* @dependency sl-icon-button
*
* @slot - The drawer's main content.
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
*
* @event sl-show - Emitted when the drawer opens.
* @event sl-after-show - Emitted after the drawer opens and all animations are complete.
* @event sl-hide - Emitted when the drawer closes.
* @event sl-after-hide - Emitted after the drawer closes and all animations are complete.
* @event sl-initial-focus - Emitted when the drawer 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 drawer by clicking the close button, clicking the overlay, or pressing escape. Calling
* `event.preventDefault()` will keep the drawer open. Avoid using this unless closing the drawer 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 drawer.
* @csspart panel - The drawer's panel (where the drawer and its content are rendered).
* @csspart header - The drawer'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 drawer'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 drawer's body.
* @csspart footer - The drawer's footer.
*
* @cssproperty --size - The preferred size of the drawer. This will be applied to the drawer's width or height
* depending on its `placement`. Note that the drawer 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 drawer.showTop - The animation to use when showing a drawer with `top` placement.
* @animation drawer.showEnd - The animation to use when showing a drawer with `end` placement.
* @animation drawer.showBottom - The animation to use when showing a drawer with `bottom` placement.
* @animation drawer.showStart - The animation to use when showing a drawer with `start` placement.
* @animation drawer.hideTop - The animation to use when hiding a drawer with `top` placement.
* @animation drawer.hideEnd - The animation to use when hiding a drawer with `end` placement.
* @animation drawer.hideBottom - The animation to use when hiding a drawer with `bottom` placement.
* @animation drawer.hideStart - The animation to use when hiding a drawer with `start` placement.
* @animation drawer.denyClose - The animation to use when a request to close the drawer is denied.
* @animation drawer.overlay.show - The animation to use when showing the drawer's overlay.
* @animation drawer.overlay.hide - The animation to use when hiding the drawer's overlay.
*/
@customElement('sl-drawer')
export default class SlDrawer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal = new Modal(this);
private originalTrigger: HTMLElement | null;
@query('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
/**
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* The drawer'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 = '';
/** The direction from which the drawer will open. */
@property({ reflect: true }) placement: 'top' | 'end' | 'bottom' | 'start' = 'end';
/**
* By default, the drawer slides out of its containing block (usually the viewport). To make the drawer slide out of
* its parent element, set this attribute and add `position: relative` to the parent.
*/
@property({ type: Boolean, reflect: true }) contained = false;
/**
* Removes the header. This will also remove the default close button, so please ensure you provide an easy,
* accessible way for users to dismiss the drawer.
*/
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
firstUpdated() {
this.drawer.hidden = !this.open;
if (this.open) {
this.addOpenListeners();
if (!this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
}
}
disconnectedCallback() {
super.disconnectedCallback();
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, 'drawer.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 (this.open && !this.contained && event.key === 'Escape') {
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;
// Lock body scrolling only if the drawer isn't contained
if (!this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
// When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the
// drawer's animation to jitter, 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.drawer), stopAnimations(this.overlay)]);
this.drawer.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, `drawer.show${uppercaseFirstLetter(this.placement)}`, {
dir: this.localize.dir()
});
const overlayAnimation = getAnimation(this, 'drawer.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();
if (!this.contained) {
this.modal.deactivate();
unlockBodyScrolling(this);
}
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`, {
dir: this.localize.dir()
});
const overlayAnimation = getAnimation(this, 'drawer.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.drawer.hidden = true;
// Now that the dialog is hidden, restore the overlay and panel for next time
this.overlay.hidden = false;
this.panel.hidden = false;
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (typeof trigger?.focus === 'function') {
setTimeout(() => trigger.focus());
}
this.emit('sl-after-hide');
}
}
@watch('contained', { waitUntilFirstUpdate: true })
handleNoModalChange() {
if (this.open && !this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
if (this.open && this.contained) {
this.modal.deactivate();
unlockBodyScrolling(this);
}
}
/** Shows the drawer. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the drawer */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div
part="base"
class=${classMap({
drawer: true,
'drawer--open': this.open,
'drawer--top': this.placement === 'top',
'drawer--end': this.placement === 'end',
'drawer--bottom': this.placement === 'bottom',
'drawer--start': this.placement === 'start',
'drawer--contained': this.contained,
'drawer--fixed': !this.contained,
'drawer--rtl': this.localize.dir() === 'rtl',
'drawer--has-footer': this.hasSlotController.test('footer')
})}
>
<div part="overlay" class="drawer__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
<div
part="panel"
class="drawer__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="0"
>
${!this.noHeader
? html`
<header part="header" class="drawer__header">
<h2 part="title" class="drawer__title" id="title">
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
</h2>
<div part="header-actions" class="drawer__header-actions">
<slot name="header-actions"></slot>
<sl-icon-button
part="close-button"
exportparts="base:close-button__base"
class="drawer__close"
name="x-lg"
label=${this.localize.term('close')}
library="system"
@click=${() => this.requestClose('close-button')}
></sl-icon-button>
</div>
</header>
`
: ''}
<slot part="body" class="drawer__body"></slot>
<footer part="footer" class="drawer__footer">
<slot name="footer"></slot>
</footer>
</div>
</div>
`;
}
}
// Top
setDefaultAnimation('drawer.showTop', {
keyframes: [
{ opacity: 0, translate: '0 -100%' },
{ opacity: 1, translate: '0 0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideTop', {
keyframes: [
{ opacity: 1, translate: '0 0' },
{ opacity: 0, translate: '0 -100%' }
],
options: { duration: 250, easing: 'ease' }
});
// End
setDefaultAnimation('drawer.showEnd', {
keyframes: [
{ opacity: 0, translate: '100%' },
{ opacity: 1, translate: '0' }
],
rtlKeyframes: [
{ opacity: 0, translate: '-100%' },
{ opacity: 1, translate: '0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideEnd', {
keyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '100%' }
],
rtlKeyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '-100%' }
],
options: { duration: 250, easing: 'ease' }
});
// Bottom
setDefaultAnimation('drawer.showBottom', {
keyframes: [
{ opacity: 0, translate: '0 100%' },
{ opacity: 1, translate: '0 0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideBottom', {
keyframes: [
{ opacity: 1, translate: '0 0' },
{ opacity: 0, translate: '0 100%' }
],
options: { duration: 250, easing: 'ease' }
});
// Start
setDefaultAnimation('drawer.showStart', {
keyframes: [
{ opacity: 0, translate: '-100%' },
{ opacity: 1, translate: '0' }
],
rtlKeyframes: [
{ opacity: 0, translate: '100%' },
{ opacity: 1, translate: '0' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideStart', {
keyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '-100%' }
],
rtlKeyframes: [
{ opacity: 1, translate: '0' },
{ opacity: 0, translate: '100%' }
],
options: { duration: 250, easing: 'ease' }
});
// Deny close
setDefaultAnimation('drawer.denyClose', {
keyframes: [{ scale: 1 }, { scale: 1.01 }, { scale: 1 }],
options: { duration: 250 }
});
// Overlay
setDefaultAnimation('drawer.overlay.show', {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 250 }
});
setDefaultAnimation('drawer.overlay.hide', {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
options: { duration: 250 }
});
declare global {
interface HTMLElementTagNameMap {
'sl-drawer': SlDrawer;
}
}
import SlDrawer from './drawer.component.js';
export * from './drawer.component.js';
export default SlDrawer;
SlDrawer.define('sl-drawer');

View File

@@ -0,0 +1,444 @@
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { getTabbableBoundary } from '../../internal/tabbable.js';
import { html } from 'lit';
import { 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 SlPopup from '../popup/popup.component.js';
import styles from './dropdown.styles.js';
import type { CSSResultGroup } from 'lit';
import type SlButton from '../button/button.js';
import type SlIconButton from '../icon-button/icon-button.js';
import type SlMenu from '../menu/menu.js';
import type SlSelectEvent from '../../events/sl-select.js';
/**
* @summary Dropdowns expose additional content that "drops down" in a panel.
* @documentation https://shoelace.style/components/dropdown
* @status stable
* @since 2.0
*
* @dependency sl-popup
*
* @slot - The dropdown's main content.
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
*
* @event sl-show - Emitted when the dropdown opens.
* @event sl-after-show - Emitted after the dropdown opens and all animations are complete.
* @event sl-hide - Emitted when the dropdown closes.
* @event sl-after-hide - Emitted after the dropdown closes and all animations are complete.
*
* @csspart base - The component's base wrapper.
* @csspart trigger - The container that wraps the trigger.
* @csspart panel - The panel that gets shown when the dropdown is open.
*
* @animation dropdown.show - The animation to use when showing the dropdown.
* @animation dropdown.hide - The animation to use when hiding the dropdown.
*/
export default class SlDropdown extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-popup': SlPopup };
@query('.dropdown') popup: SlPopup;
@query('.dropdown__trigger') trigger: HTMLSlotElement;
@query('.dropdown__panel') panel: HTMLSlotElement;
private readonly localize = new LocalizeController(this);
/**
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
* inside of the viewport.
*/
@property({ reflect: true }) placement:
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'right'
| 'right-start'
| 'right-end'
| 'left'
| 'left-start'
| 'left-end' = 'bottom-start';
/** Disables the dropdown so the panel will not open. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
* dropdowns that allow for multiple interactions.
*/
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
/**
* The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
* components that use a dropdown internally.
*/
@property({ attribute: false }) containingElement?: HTMLElement;
/** The distance in pixels from which to offset the panel away from its trigger. */
@property({ type: Number }) distance = 0;
/** The distance in pixels from which to offset the panel along its trigger. */
@property({ type: Number }) skidding = 0;
/**
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
*/
@property({ type: Boolean }) hoist = false;
connectedCallback() {
super.connectedCallback();
if (!this.containingElement) {
this.containingElement = this;
}
}
firstUpdated() {
this.panel.hidden = !this.open;
// If the dropdown is visible on init, update its position
if (this.open) {
this.addOpenListeners();
this.popup.active = true;
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeOpenListeners();
this.hide();
}
focusOnTrigger() {
const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (typeof trigger?.focus === 'function') {
trigger.focus();
}
}
getMenu() {
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
| SlMenu
| undefined;
}
private handleKeyDown = (event: KeyboardEvent) => {
// Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation
// in case any ancestors are also listening for this key.
if (this.open && event.key === 'Escape') {
event.stopPropagation();
this.hide();
this.focusOnTrigger();
}
};
private handleDocumentKeyDown = (event: KeyboardEvent) => {
// Close when escape or tab is pressed
if (event.key === 'Escape' && this.open) {
event.stopPropagation();
this.focusOnTrigger();
this.hide();
return;
}
// Handle tabbing
if (event.key === 'Tab') {
// Tabbing within an open menu should close the dropdown and refocus the trigger
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
event.preventDefault();
this.hide();
this.focusOnTrigger();
return;
}
// Tabbing outside of the containing element closes the panel
//
// If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
setTimeout(() => {
const activeElement =
this.containingElement?.getRootNode() instanceof ShadowRoot
? document.activeElement?.shadowRoot?.activeElement
: document.activeElement;
if (
!this.containingElement ||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
) {
this.hide();
}
});
}
};
private handleDocumentMouseDown = (event: MouseEvent) => {
// Close when clicking outside of the containing element
const path = event.composedPath();
if (this.containingElement && !path.includes(this.containingElement)) {
this.hide();
}
};
private handlePanelSelect = (event: SlSelectEvent) => {
const target = event.target as HTMLElement;
// Hide the dropdown when a menu item is selected
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
this.hide();
this.focusOnTrigger();
}
};
handleTriggerClick() {
if (this.open) {
this.hide();
} else {
this.show();
this.focusOnTrigger();
}
}
async handleTriggerKeyDown(event: KeyboardEvent) {
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
// key again to hide the menu in case they don't want to make a selection.
if ([' ', 'Enter'].includes(event.key)) {
event.preventDefault();
this.handleTriggerClick();
return;
}
const menu = this.getMenu();
if (menu) {
const menuItems = menu.getAllItems();
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
// faster navigation.
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
// Show the menu if it's not already open
if (!this.open) {
this.show();
// Wait for the dropdown to open before focusing, but not the animation
await this.updateComplete;
}
if (menuItems.length > 0) {
// Focus on the first/last menu item after showing
this.updateComplete.then(() => {
if (event.key === 'ArrowDown' || event.key === 'Home') {
menu.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
}
if (event.key === 'ArrowUp' || event.key === 'End') {
menu.setCurrentItem(lastMenuItem);
lastMenuItem.focus();
}
});
}
}
}
}
handleTriggerKeyUp(event: KeyboardEvent) {
// Prevent space from triggering a click event in Firefox
if (event.key === ' ') {
event.preventDefault();
}
}
handleTriggerSlotChange() {
this.updateAccessibleTrigger();
}
//
// Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
// `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
// that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
// a child of the slotted element, or an element in the slotted element's shadow root.
//
// For example, the accessible trigger of an <sl-button> is a <button> located inside its shadow root.
//
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
//
updateAccessibleTrigger() {
const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
let target: HTMLElement;
if (accessibleTrigger) {
switch (accessibleTrigger.tagName.toLowerCase()) {
// Shoelace buttons have to update the internal button so it's announced correctly by screen readers
case 'sl-button':
case 'sl-icon-button':
target = (accessibleTrigger as SlButton | SlIconButton).button;
break;
default:
target = accessibleTrigger;
}
target.setAttribute('aria-haspopup', 'true');
target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
}
}
/** Shows the dropdown panel. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dropdown panel */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/**
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
* is activated.
*/
reposition() {
this.popup.reposition();
}
addOpenListeners() {
this.panel.addEventListener('sl-select', this.handlePanelSelect);
this.panel.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
removeOpenListeners() {
if (this.panel) {
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
this.panel.removeEventListener('keydown', this.handleKeyDown);
}
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.disabled) {
this.open = false;
return;
}
this.updateAccessibleTrigger();
if (this.open) {
// Show
this.emit('sl-show');
this.addOpenListeners();
await stopAnimations(this);
this.panel.hidden = false;
this.popup.active = true;
const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
this.removeOpenListeners();
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.panel.hidden = true;
this.popup.active = false;
this.emit('sl-after-hide');
}
}
render() {
return html`
<sl-popup
part="base"
id="dropdown"
placement=${this.placement}
distance=${this.distance}
skidding=${this.skidding}
strategy=${this.hoist ? 'fixed' : 'absolute'}
flip
shift
auto-size="vertical"
auto-size-padding="10"
class=${classMap({
dropdown: true,
'dropdown--open': this.open
})}
>
<slot
name="trigger"
slot="anchor"
part="trigger"
class="dropdown__trigger"
@click=${this.handleTriggerClick}
@keydown=${this.handleTriggerKeyDown}
@keyup=${this.handleTriggerKeyUp}
@slotchange=${this.handleTriggerSlotChange}
></slot>
<div aria-hidden=${this.open ? 'false' : 'true'} aria-labelledby="dropdown">
<slot part="panel" class="dropdown__panel"></slot>
</div>
</sl-popup>
`;
}
}
setDefaultAnimation('dropdown.show', {
keyframes: [
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1 }
],
options: { duration: 100, easing: 'ease' }
});
setDefaultAnimation('dropdown.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.9 }
],
options: { duration: 100, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-dropdown': SlDropdown;
}
}

View File

@@ -162,7 +162,7 @@ describe('<sl-dropdown>', () => {
expect(el.open).to.be.true;
});
it('should open on arrow navigation', async () => {
it('should open on arrow down navigation', async () => {
const el = await fixture<SlDropdown>(html`
<sl-dropdown>
<sl-button slot="trigger" caret>Toggle</sl-button>
@@ -173,12 +173,35 @@ describe('<sl-dropdown>', () => {
</sl-dropdown>
`);
const trigger = el.querySelector('sl-button')!;
const firstMenuItem = el.querySelectorAll('sl-menu-item')[0];
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(firstMenuItem);
});
it('should open on arrow up navigation', async () => {
const el = await fixture<SlDropdown>(html`
<sl-dropdown>
<sl-button slot="trigger" caret>Toggle</sl-button>
<sl-menu>
<sl-menu-item>Item 1</sl-menu-item>
<sl-menu-item>Item 2</sl-menu-item>
</sl-menu>
</sl-dropdown>
`);
const trigger = el.querySelector('sl-button')!;
const secondMenuItem = el.querySelectorAll('sl-menu-item')[1];
trigger.focus();
await sendKeys({ press: 'ArrowUp' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should navigate to first focusable item on arrow navigation', async () => {

View File

@@ -1,442 +1,4 @@
import '../popup/popup.js';
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { getTabbableBoundary } from '../../internal/tabbable.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './dropdown.styles.js';
import type { CSSResultGroup } from 'lit';
import type SlButton from '../button/button.js';
import type SlIconButton from '../icon-button/icon-button.js';
import type SlMenu from '../menu/menu.js';
import type SlPopup from '../popup/popup.js';
import type SlSelectEvent from '../../events/sl-select.js';
/**
* @summary Dropdowns expose additional content that "drops down" in a panel.
* @documentation https://shoelace.style/components/dropdown
* @status stable
* @since 2.0
*
* @dependency sl-popup
*
* @slot - The dropdown's main content.
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
*
* @event sl-show - Emitted when the dropdown opens.
* @event sl-after-show - Emitted after the dropdown opens and all animations are complete.
* @event sl-hide - Emitted when the dropdown closes.
* @event sl-after-hide - Emitted after the dropdown closes and all animations are complete.
*
* @csspart base - The component's base wrapper.
* @csspart trigger - The container that wraps the trigger.
* @csspart panel - The panel that gets shown when the dropdown is open.
*
* @animation dropdown.show - The animation to use when showing the dropdown.
* @animation dropdown.hide - The animation to use when hiding the dropdown.
*/
@customElement('sl-dropdown')
export default class SlDropdown extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.dropdown') popup: SlPopup;
@query('.dropdown__trigger') trigger: HTMLSlotElement;
@query('.dropdown__panel') panel: HTMLSlotElement;
private readonly localize = new LocalizeController(this);
/**
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
* inside of the viewport.
*/
@property({ reflect: true }) placement:
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'right'
| 'right-start'
| 'right-end'
| 'left'
| 'left-start'
| 'left-end' = 'bottom-start';
/** Disables the dropdown so the panel will not open. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
* dropdowns that allow for multiple interactions.
*/
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
/**
* The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
* components that use a dropdown internally.
*/
@property({ attribute: false }) containingElement?: HTMLElement;
/** The distance in pixels from which to offset the panel away from its trigger. */
@property({ type: Number }) distance = 0;
/** The distance in pixels from which to offset the panel along its trigger. */
@property({ type: Number }) skidding = 0;
/**
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
*/
@property({ type: Boolean }) hoist = false;
connectedCallback() {
super.connectedCallback();
if (!this.containingElement) {
this.containingElement = this;
}
}
firstUpdated() {
this.panel.hidden = !this.open;
// If the dropdown is visible on init, update its position
if (this.open) {
this.addOpenListeners();
this.popup.active = true;
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeOpenListeners();
this.hide();
}
focusOnTrigger() {
const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (typeof trigger?.focus === 'function') {
trigger.focus();
}
}
getMenu() {
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
| SlMenu
| undefined;
}
private handleKeyDown = (event: KeyboardEvent) => {
// Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation
// in case any ancestors are also listening for this key.
if (this.open && event.key === 'Escape') {
event.stopPropagation();
this.hide();
this.focusOnTrigger();
}
};
private handleDocumentKeyDown = (event: KeyboardEvent) => {
// Close when escape or tab is pressed
if (event.key === 'Escape' && this.open) {
event.stopPropagation();
this.focusOnTrigger();
this.hide();
return;
}
// Handle tabbing
if (event.key === 'Tab') {
// Tabbing within an open menu should close the dropdown and refocus the trigger
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
event.preventDefault();
this.hide();
this.focusOnTrigger();
return;
}
// Tabbing outside of the containing element closes the panel
//
// If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
setTimeout(() => {
const activeElement =
this.containingElement?.getRootNode() instanceof ShadowRoot
? document.activeElement?.shadowRoot?.activeElement
: document.activeElement;
if (
!this.containingElement ||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
) {
this.hide();
}
});
}
};
private handleDocumentMouseDown = (event: MouseEvent) => {
// Close when clicking outside of the containing element
const path = event.composedPath();
if (this.containingElement && !path.includes(this.containingElement)) {
this.hide();
}
};
private handlePanelSelect = (event: SlSelectEvent) => {
const target = event.target as HTMLElement;
// Hide the dropdown when a menu item is selected
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
this.hide();
this.focusOnTrigger();
}
};
handleTriggerClick() {
if (this.open) {
this.hide();
} else {
this.show();
this.focusOnTrigger();
}
}
handleTriggerKeyDown(event: KeyboardEvent) {
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
// key again to hide the menu in case they don't want to make a selection.
if ([' ', 'Enter'].includes(event.key)) {
event.preventDefault();
this.handleTriggerClick();
return;
}
const menu = this.getMenu();
if (menu) {
const menuItems = menu.getAllItems();
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
// faster navigation.
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
// Show the menu if it's not already open
if (!this.open) {
this.show();
}
if (menuItems.length > 0) {
// Focus on the first/last menu item after showing
this.updateComplete.then(() => {
if (event.key === 'ArrowDown' || event.key === 'Home') {
menu.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
}
if (event.key === 'ArrowUp' || event.key === 'End') {
menu.setCurrentItem(lastMenuItem);
lastMenuItem.focus();
}
});
}
}
}
}
handleTriggerKeyUp(event: KeyboardEvent) {
// Prevent space from triggering a click event in Firefox
if (event.key === ' ') {
event.preventDefault();
}
}
handleTriggerSlotChange() {
this.updateAccessibleTrigger();
}
//
// Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
// `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
// that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
// a child of the slotted element, or an element in the slotted element's shadow root.
//
// For example, the accessible trigger of an <sl-button> is a <button> located inside its shadow root.
//
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
//
updateAccessibleTrigger() {
const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
let target: HTMLElement;
if (accessibleTrigger) {
switch (accessibleTrigger.tagName.toLowerCase()) {
// Shoelace buttons have to update the internal button so it's announced correctly by screen readers
case 'sl-button':
case 'sl-icon-button':
target = (accessibleTrigger as SlButton | SlIconButton).button;
break;
default:
target = accessibleTrigger;
}
target.setAttribute('aria-haspopup', 'true');
target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
}
}
/** Shows the dropdown panel. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dropdown panel */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/**
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
* is activated.
*/
reposition() {
this.popup.reposition();
}
addOpenListeners() {
this.panel.addEventListener('sl-select', this.handlePanelSelect);
this.panel.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
removeOpenListeners() {
if (this.panel) {
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
this.panel.removeEventListener('keydown', this.handleKeyDown);
}
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.disabled) {
this.open = false;
return;
}
this.updateAccessibleTrigger();
if (this.open) {
// Show
this.emit('sl-show');
this.addOpenListeners();
await stopAnimations(this);
this.panel.hidden = false;
this.popup.active = true;
const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
this.removeOpenListeners();
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.panel.hidden = true;
this.popup.active = false;
this.emit('sl-after-hide');
}
}
render() {
return html`
<sl-popup
part="base"
id="dropdown"
placement=${this.placement}
distance=${this.distance}
skidding=${this.skidding}
strategy=${this.hoist ? 'fixed' : 'absolute'}
flip
shift
auto-size="vertical"
auto-size-padding="10"
class=${classMap({
dropdown: true,
'dropdown--open': this.open
})}
>
<slot
name="trigger"
slot="anchor"
part="trigger"
class="dropdown__trigger"
@click=${this.handleTriggerClick}
@keydown=${this.handleTriggerKeyDown}
@keyup=${this.handleTriggerKeyUp}
@slotchange=${this.handleTriggerSlotChange}
></slot>
<div aria-hidden=${this.open ? 'false' : 'true'} aria-labelledby="dropdown">
<slot part="panel" class="dropdown__panel"></slot>
</div>
</sl-popup>
`;
}
}
setDefaultAnimation('dropdown.show', {
keyframes: [
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1 }
],
options: { duration: 100, easing: 'ease' }
});
setDefaultAnimation('dropdown.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.9 }
],
options: { duration: 100, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-dropdown': SlDropdown;
}
}
import SlDropdown from './dropdown.component.js';
export * from './dropdown.component.js';
export default SlDropdown;
SlDropdown.define('sl-dropdown');

View File

@@ -0,0 +1,47 @@
import { LocalizeController } from '../../utilities/localize.js';
import { property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
/**
* @summary Formats a number as a human readable bytes value.
* @documentation https://shoelace.style/components/format-bytes
* @status stable
* @since 2.0
*/
export default class SlFormatBytes extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
/** The number to format in bytes. */
@property({ type: Number }) value = 0;
/** The type of unit to display. */
@property() unit: 'byte' | 'bit' = 'byte';
/** Determines how to display the result, e.g. "100 bytes", "100 b", or "100b". */
@property() display: 'long' | 'short' | 'narrow' = 'short';
render() {
if (isNaN(this.value)) {
return '';
}
const bitPrefixes = ['', 'kilo', 'mega', 'giga', 'tera']; // petabit isn't a supported unit
const bytePrefixes = ['', 'kilo', 'mega', 'giga', 'tera', 'peta'];
const prefix = this.unit === 'bit' ? bitPrefixes : bytePrefixes;
const index = Math.max(0, Math.min(Math.floor(Math.log10(this.value) / 3), prefix.length - 1));
const unit = prefix[index] + this.unit;
const valueToFormat = parseFloat((this.value / Math.pow(1000, index)).toPrecision(3));
return this.localize.number(valueToFormat, {
style: 'unit',
unit,
unitDisplay: this.display
});
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-format-bytes': SlFormatBytes;
}
}

View File

@@ -1,48 +1,4 @@
import { customElement, property } from 'lit/decorators.js';
import { LocalizeController } from '../../utilities/localize.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
/**
* @summary Formats a number as a human readable bytes value.
* @documentation https://shoelace.style/components/format-bytes
* @status stable
* @since 2.0
*/
@customElement('sl-format-bytes')
export default class SlFormatBytes extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
/** The number to format in bytes. */
@property({ type: Number }) value = 0;
/** The type of unit to display. */
@property() unit: 'byte' | 'bit' = 'byte';
/** Determines how to display the result, e.g. "100 bytes", "100 b", or "100b". */
@property() display: 'long' | 'short' | 'narrow' = 'short';
render() {
if (isNaN(this.value)) {
return '';
}
const bitPrefixes = ['', 'kilo', 'mega', 'giga', 'tera']; // petabit isn't a supported unit
const bytePrefixes = ['', 'kilo', 'mega', 'giga', 'tera', 'peta'];
const prefix = this.unit === 'bit' ? bitPrefixes : bytePrefixes;
const index = Math.max(0, Math.min(Math.floor(Math.log10(this.value) / 3), prefix.length - 1));
const unit = prefix[index] + this.unit;
const valueToFormat = parseFloat((this.value / Math.pow(1000, index)).toPrecision(3));
return this.localize.number(valueToFormat, {
style: 'unit',
unit,
unitDisplay: this.display
});
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-format-bytes': SlFormatBytes;
}
}
import SlFormatBytes from './format-bytes.component.js';
export * from './format-bytes.component.js';
export default SlFormatBytes;
SlFormatBytes.define('sl-format-bytes');

View File

@@ -0,0 +1,88 @@
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
/**
* @summary Formats a date/time using the specified locale and options.
* @documentation https://shoelace.style/components/format-date
* @status stable
* @since 2.0
*/
export default class SlFormatDate extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
/**
* The date/time to format. If not set, the current date and time will be used. When passing a string, it's strongly
* recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert a date to this format
* in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
*/
@property() date: Date | string = new Date();
/** The format for displaying the weekday. */
@property() weekday: 'narrow' | 'short' | 'long';
/** The format for displaying the era. */
@property() era: 'narrow' | 'short' | 'long';
/** The format for displaying the year. */
@property() year: 'numeric' | '2-digit';
/** The format for displaying the month. */
@property() month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
/** The format for displaying the day. */
@property() day: 'numeric' | '2-digit';
/** The format for displaying the hour. */
@property() hour: 'numeric' | '2-digit';
/** The format for displaying the minute. */
@property() minute: 'numeric' | '2-digit';
/** The format for displaying the second. */
@property() second: 'numeric' | '2-digit';
/** The format for displaying the time. */
@property({ attribute: 'time-zone-name' }) timeZoneName: 'short' | 'long';
/** The time zone to express the time in. */
@property({ attribute: 'time-zone' }) timeZone: string;
/** The format for displaying the hour. */
@property({ attribute: 'hour-format' }) hourFormat: 'auto' | '12' | '24' = 'auto';
render() {
const date = new Date(this.date);
const hour12 = this.hourFormat === 'auto' ? undefined : this.hourFormat === '12';
// Check for an invalid date
if (isNaN(date.getMilliseconds())) {
return undefined;
}
return html`
<time datetime=${date.toISOString()}>
${this.localize.date(date, {
weekday: this.weekday,
era: this.era,
year: this.year,
month: this.month,
day: this.day,
hour: this.hour,
minute: this.minute,
second: this.second,
timeZoneName: this.timeZoneName,
timeZone: this.timeZone,
hour12: hour12
})}
</time>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-format-date': SlFormatDate;
}
}

View File

@@ -1,89 +1,4 @@
import { customElement, property } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
/**
* @summary Formats a date/time using the specified locale and options.
* @documentation https://shoelace.style/components/format-date
* @status stable
* @since 2.0
*/
@customElement('sl-format-date')
export default class SlFormatDate extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
/**
* The date/time to format. If not set, the current date and time will be used. When passing a string, it's strongly
* recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert a date to this format
* in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
*/
@property() date: Date | string = new Date();
/** The format for displaying the weekday. */
@property() weekday: 'narrow' | 'short' | 'long';
/** The format for displaying the era. */
@property() era: 'narrow' | 'short' | 'long';
/** The format for displaying the year. */
@property() year: 'numeric' | '2-digit';
/** The format for displaying the month. */
@property() month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
/** The format for displaying the day. */
@property() day: 'numeric' | '2-digit';
/** The format for displaying the hour. */
@property() hour: 'numeric' | '2-digit';
/** The format for displaying the minute. */
@property() minute: 'numeric' | '2-digit';
/** The format for displaying the second. */
@property() second: 'numeric' | '2-digit';
/** The format for displaying the time. */
@property({ attribute: 'time-zone-name' }) timeZoneName: 'short' | 'long';
/** The time zone to express the time in. */
@property({ attribute: 'time-zone' }) timeZone: string;
/** The format for displaying the hour. */
@property({ attribute: 'hour-format' }) hourFormat: 'auto' | '12' | '24' = 'auto';
render() {
const date = new Date(this.date);
const hour12 = this.hourFormat === 'auto' ? undefined : this.hourFormat === '12';
// Check for an invalid date
if (isNaN(date.getMilliseconds())) {
return undefined;
}
return html`
<time datetime=${date.toISOString()}>
${this.localize.date(date, {
weekday: this.weekday,
era: this.era,
year: this.year,
month: this.month,
day: this.day,
hour: this.hour,
minute: this.minute,
second: this.second,
timeZoneName: this.timeZoneName,
timeZone: this.timeZone,
hour12: hour12
})}
</time>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-format-date': SlFormatDate;
}
}
import SlFormatDate from './format-date.component.js';
export * from './format-date.component.js';
export default SlFormatDate;
SlFormatDate.define('sl-format-date');

View File

@@ -0,0 +1,67 @@
import { LocalizeController } from '../../utilities/localize.js';
import { property } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
/**
* @summary Formats a number using the specified locale and options.
* @documentation https://shoelace.style/components/format-number
* @status stable
* @since 2.0
*/
export default class SlFormatNumber extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
/** The number to format. */
@property({ type: Number }) value = 0;
/** The formatting style to use. */
@property() type: 'currency' | 'decimal' | 'percent' = 'decimal';
/** Turns off grouping separators. */
@property({ attribute: 'no-grouping', type: Boolean }) noGrouping = false;
/** The [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code to use when formatting. */
@property() currency = 'USD';
/** How to display the currency. */
@property({ attribute: 'currency-display' }) currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
/** The minimum number of integer digits to use. Possible values are 1-21. */
@property({ attribute: 'minimum-integer-digits', type: Number }) minimumIntegerDigits: number;
/** The minimum number of fraction digits to use. Possible values are 0-20. */
@property({ attribute: 'minimum-fraction-digits', type: Number }) minimumFractionDigits: number;
/** The maximum number of fraction digits to use. Possible values are 0-0. */
@property({ attribute: 'maximum-fraction-digits', type: Number }) maximumFractionDigits: number;
/** The minimum number of significant digits to use. Possible values are 1-21. */
@property({ attribute: 'minimum-significant-digits', type: Number }) minimumSignificantDigits: number;
/** The maximum number of significant digits to use,. Possible values are 1-21. */
@property({ attribute: 'maximum-significant-digits', type: Number }) maximumSignificantDigits: number;
render() {
if (isNaN(this.value)) {
return '';
}
return this.localize.number(this.value, {
style: this.type,
currency: this.currency,
currencyDisplay: this.currencyDisplay,
useGrouping: !this.noGrouping,
minimumIntegerDigits: this.minimumIntegerDigits,
minimumFractionDigits: this.minimumFractionDigits,
maximumFractionDigits: this.maximumFractionDigits,
minimumSignificantDigits: this.minimumSignificantDigits,
maximumSignificantDigits: this.maximumSignificantDigits
});
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-format-number': SlFormatNumber;
}
}

View File

@@ -1,68 +1,4 @@
import { customElement, property } from 'lit/decorators.js';
import { LocalizeController } from '../../utilities/localize.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
/**
* @summary Formats a number using the specified locale and options.
* @documentation https://shoelace.style/components/format-number
* @status stable
* @since 2.0
*/
@customElement('sl-format-number')
export default class SlFormatNumber extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
/** The number to format. */
@property({ type: Number }) value = 0;
/** The formatting style to use. */
@property() type: 'currency' | 'decimal' | 'percent' = 'decimal';
/** Turns off grouping separators. */
@property({ attribute: 'no-grouping', type: Boolean }) noGrouping = false;
/** The [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code to use when formatting. */
@property() currency = 'USD';
/** How to display the currency. */
@property({ attribute: 'currency-display' }) currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
/** The minimum number of integer digits to use. Possible values are 1-21. */
@property({ attribute: 'minimum-integer-digits', type: Number }) minimumIntegerDigits: number;
/** The minimum number of fraction digits to use. Possible values are 0-20. */
@property({ attribute: 'minimum-fraction-digits', type: Number }) minimumFractionDigits: number;
/** The maximum number of fraction digits to use. Possible values are 0-0. */
@property({ attribute: 'maximum-fraction-digits', type: Number }) maximumFractionDigits: number;
/** The minimum number of significant digits to use. Possible values are 1-21. */
@property({ attribute: 'minimum-significant-digits', type: Number }) minimumSignificantDigits: number;
/** The maximum number of significant digits to use,. Possible values are 1-21. */
@property({ attribute: 'maximum-significant-digits', type: Number }) maximumSignificantDigits: number;
render() {
if (isNaN(this.value)) {
return '';
}
return this.localize.number(this.value, {
style: this.type,
currency: this.currency,
currencyDisplay: this.currencyDisplay,
useGrouping: !this.noGrouping,
minimumIntegerDigits: this.minimumIntegerDigits,
minimumFractionDigits: this.minimumFractionDigits,
maximumFractionDigits: this.maximumFractionDigits,
minimumSignificantDigits: this.minimumSignificantDigits,
maximumSignificantDigits: this.maximumSignificantDigits
});
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-format-number': SlFormatNumber;
}
}
import SlFormatNumber from './format-number.component.js';
export * from './format-number.component.js';
export default SlFormatNumber;
SlFormatNumber.define('sl-format-number');

View File

@@ -0,0 +1,136 @@
import { classMap } from 'lit/directives/class-map.js';
import { html, literal } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { property, query, state } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './icon-button.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
* @documentation https://shoelace.style/components/icon-button
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @event sl-blur - Emitted when the icon button loses focus.
* @event sl-focus - Emitted when the icon button gains focus.
*
* @csspart base - The component's base wrapper.
*/
export default class SlIconButton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property() name?: string;
/** The name of a registered custom icon library. */
@property() library?: string;
/**
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
* can result in XSS attacks.
*/
@property() src?: string;
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
@property() download?: string;
/**
* A description that gets read by assistive devices. For optimal accessibility, you should always include a label
* that describes what the icon button does.
*/
@property() label = '';
/** Disables the button. */
@property({ type: Boolean, reflect: true }) disabled = false;
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
/** Simulates a click on the icon button. */
click() {
this.button.click();
}
/** Sets focus on the icon button. */
focus(options?: FocusOptions) {
this.button.focus(options);
}
/** Removes focus from the icon button. */
blur() {
this.button.blur();
}
render() {
const isLink = this.href ? true : false;
const tag = isLink ? literal`a` : literal`button`;
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
return html`
<${tag}
part="base"
class=${classMap({
'icon-button': true,
'icon-button--disabled': !isLink && this.disabled,
'icon-button--focused': this.hasFocus
})}
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
type=${ifDefined(isLink ? undefined : 'button')}
href=${ifDefined(isLink ? this.href : undefined)}
target=${ifDefined(isLink ? this.target : undefined)}
download=${ifDefined(isLink ? this.download : undefined)}
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
role=${ifDefined(isLink ? undefined : 'button')}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-label="${this.label}"
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@click=${this.handleClick}
>
<sl-icon
class="icon-button__icon"
name=${ifDefined(this.name)}
library=${ifDefined(this.library)}
src=${ifDefined(this.src)}
aria-hidden="true"
></sl-icon>
</${tag}>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-icon-button': SlIconButton;
}
}

View File

@@ -1,136 +1,4 @@
import '../icon/icon.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html, literal } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './icon-button.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
* @documentation https://shoelace.style/components/icon-button
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @event sl-blur - Emitted when the icon button loses focus.
* @event sl-focus - Emitted when the icon button gains focus.
*
* @csspart base - The component's base wrapper.
*/
@customElement('sl-icon-button')
export default class SlIconButton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property() name?: string;
/** The name of a registered custom icon library. */
@property() library?: string;
/**
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
* can result in XSS attacks.
*/
@property() src?: string;
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
@property() download?: string;
/**
* A description that gets read by assistive devices. For optimal accessibility, you should always include a label
* that describes what the icon button does.
*/
@property() label = '';
/** Disables the button. */
@property({ type: Boolean, reflect: true }) disabled = false;
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
/** Simulates a click on the icon button. */
click() {
this.button.click();
}
/** Sets focus on the icon button. */
focus(options?: FocusOptions) {
this.button.focus(options);
}
/** Removes focus from the icon button. */
blur() {
this.button.blur();
}
render() {
const isLink = this.href ? true : false;
const tag = isLink ? literal`a` : literal`button`;
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
return html`
<${tag}
part="base"
class=${classMap({
'icon-button': true,
'icon-button--disabled': !isLink && this.disabled,
'icon-button--focused': this.hasFocus
})}
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
type=${ifDefined(isLink ? undefined : 'button')}
href=${ifDefined(isLink ? this.href : undefined)}
target=${ifDefined(isLink ? this.target : undefined)}
download=${ifDefined(isLink ? this.download : undefined)}
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
role=${ifDefined(isLink ? undefined : 'button')}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-label="${this.label}"
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@click=${this.handleClick}
>
<sl-icon
class="icon-button__icon"
name=${ifDefined(this.name)}
library=${ifDefined(this.library)}
src=${ifDefined(this.src)}
aria-hidden="true"
></sl-icon>
</${tag}>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-icon-button': SlIconButton;
}
}
import SlIconButton from './icon-button.component.js';
export * from './icon-button.component.js';
export default SlIconButton;
SlIconButton.define('sl-icon-button');

View File

@@ -0,0 +1,189 @@
import { getIconLibrary, type IconLibrary, unwatchIcon, watchIcon } from './library.js';
import { html } from 'lit';
import { isTemplateResult } from 'lit/directive-helpers.js';
import { property, state } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './icon.styles.js';
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
const CACHEABLE_ERROR = Symbol();
const RETRYABLE_ERROR = Symbol();
type SVGResult = HTMLTemplateResult | SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR;
let parser: DOMParser;
const iconCache = new Map<string, Promise<SVGResult>>();
/**
* @summary Icons are symbols that can be used to represent various options within an application.
* @documentation https://shoelace.style/components/icon
* @status stable
* @since 2.0
*
* @event sl-load - Emitted when the icon has loaded. When using `spriteSheet: true` this will not emit.
* @event sl-error - Emitted when the icon fails to load due to an error. When using `spriteSheet: true` this will not emit.
*
* @csspart svg - The internal SVG element.
* @csspart use - The <use> element generated when using `spriteSheet: true`
*/
export default class SlIcon extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private initialRender = false;
/** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */
private async resolveIcon(url: string, library?: IconLibrary): Promise<SVGResult> {
let fileData: Response;
if (library?.spriteSheet) {
return html`<svg part="svg">
<use part="use" href="${url}"></use>
</svg>`;
}
try {
fileData = await fetch(url, { mode: 'cors' });
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
} catch {
return RETRYABLE_ERROR;
}
try {
const div = document.createElement('div');
div.innerHTML = await fileData.text();
const svg = div.firstElementChild;
if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR;
if (!parser) parser = new DOMParser();
const doc = parser.parseFromString(svg.outerHTML, 'text/html');
const svgEl = doc.body.querySelector('svg');
if (!svgEl) return CACHEABLE_ERROR;
svgEl.part.add('svg');
return document.adoptNode(svgEl);
} catch {
return CACHEABLE_ERROR;
}
}
@state() private svg: SVGElement | HTMLTemplateResult | null = null;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property({ reflect: true }) name?: string;
/**
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
* can result in XSS attacks.
*/
@property() src?: string;
/**
* An alternate description to use for assistive devices. If omitted, the icon will be considered presentational and
* ignored by assistive devices.
*/
@property() label = '';
/** The name of a registered custom icon library. */
@property({ reflect: true }) library = 'default';
connectedCallback() {
super.connectedCallback();
watchIcon(this);
}
firstUpdated() {
this.initialRender = true;
this.setIcon();
}
disconnectedCallback() {
super.disconnectedCallback();
unwatchIcon(this);
}
private getUrl() {
const library = getIconLibrary(this.library);
if (this.name && library) {
return library.resolver(this.name);
}
return this.src;
}
@watch('label')
handleLabelChange() {
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
if (hasLabel) {
this.setAttribute('role', 'img');
this.setAttribute('aria-label', this.label);
this.removeAttribute('aria-hidden');
} else {
this.removeAttribute('role');
this.removeAttribute('aria-label');
this.setAttribute('aria-hidden', 'true');
}
}
@watch(['name', 'src', 'library'])
async setIcon() {
const library = getIconLibrary(this.library);
const url = this.getUrl();
if (!url) {
this.svg = null;
return;
}
let iconResolver = iconCache.get(url);
if (!iconResolver) {
iconResolver = this.resolveIcon(url, library);
iconCache.set(url, iconResolver);
}
// If we haven't rendered yet, exit early. This avoids unnecessary work due to watching multiple props.
if (!this.initialRender) {
return;
}
const svg = await iconResolver;
if (svg === RETRYABLE_ERROR) {
iconCache.delete(url);
}
if (url !== this.getUrl()) {
// If the url has changed while fetching the icon, ignore this request
return;
}
if (isTemplateResult(svg)) {
this.svg = svg;
return;
}
switch (svg) {
case RETRYABLE_ERROR:
case CACHEABLE_ERROR:
this.svg = null;
this.emit('sl-error');
break;
default:
this.svg = svg.cloneNode(true) as SVGElement;
library?.mutator?.(this.svg);
this.emit('sl-load');
}
}
render() {
return this.svg;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-icon': SlIcon;
}
}

View File

@@ -155,7 +155,7 @@ describe('<sl-icon>', () => {
});
});
describe('svg spritesheets', () => {
describe('svg sprite sheets', () => {
// For some reason ESLint wants to fail in CI here, but works locally.
/* eslint-disable */
it('Should properly grab an SVG and render it from bootstrap icons', async () => {
@@ -175,8 +175,8 @@ describe('<sl-icon>', () => {
expect(svg).to.be.instanceof(SVGElement);
expect(use).to.be.instanceof(SVGUseElement);
// This is kind of hacky...but with no way to check "load", we just do a timeout :shrug:
await aTimeout(200);
// This is kind of hacky...but with no way to check "load", we just use a timeout
await aTimeout(1000);
// Theres no way to really test that the icon rendered properly. We just gotta trust the browser to do it's thing :)
// However, we can check the <use> size. It should be greater than 0x0 if loaded properly.
@@ -192,7 +192,7 @@ describe('<sl-icon>', () => {
spriteSheet: true
});
const el = await fixture<SlIcon>(html`<sl-icon name="non-existant" library="sprite"></sl-icon>`);
const el = await fixture<SlIcon>(html`<sl-icon name="non-existent" library="sprite"></sl-icon>`);
await elementUpdated(el);

View File

@@ -1,190 +1,4 @@
import { customElement, property, state } from 'lit/decorators.js';
import { getIconLibrary, type IconLibrary, unwatchIcon, watchIcon } from './library.js';
import { html } from 'lit';
import { isTemplateResult } from 'lit/directive-helpers.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './icon.styles.js';
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
const CACHEABLE_ERROR = Symbol();
const RETRYABLE_ERROR = Symbol();
type SVGResult = HTMLTemplateResult | SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR;
let parser: DOMParser;
const iconCache = new Map<string, Promise<SVGResult>>();
/**
* @summary Icons are symbols that can be used to represent various options within an application.
* @documentation https://shoelace.style/components/icon
* @status stable
* @since 2.0
*
* @event sl-load - Emitted when the icon has loaded. When using `spriteSheet: true` this will not emit.
* @event sl-error - Emitted when the icon fails to load due to an error. When using `spriteSheet: true` this will not emit.
*
* @csspart svg - The internal SVG element.
* @csspart use - The <use> element generated when using `spriteSheet: true`
*/
@customElement('sl-icon')
export default class SlIcon extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private initialRender = false;
/** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */
private async resolveIcon(url: string, library?: IconLibrary): Promise<SVGResult> {
let fileData: Response;
if (library?.spriteSheet) {
return html`<svg part="svg">
<use part="use" href="${url}"></use>
</svg>`;
}
try {
fileData = await fetch(url, { mode: 'cors' });
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
} catch {
return RETRYABLE_ERROR;
}
try {
const div = document.createElement('div');
div.innerHTML = await fileData.text();
const svg = div.firstElementChild;
if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR;
if (!parser) parser = new DOMParser();
const doc = parser.parseFromString(svg.outerHTML, 'text/html');
const svgEl = doc.body.querySelector('svg');
if (!svgEl) return CACHEABLE_ERROR;
svgEl.part.add('svg');
return document.adoptNode(svgEl);
} catch {
return CACHEABLE_ERROR;
}
}
@state() private svg: SVGElement | HTMLTemplateResult | null = null;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property({ reflect: true }) name?: string;
/**
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
* can result in XSS attacks.
*/
@property() src?: string;
/**
* An alternate description to use for assistive devices. If omitted, the icon will be considered presentational and
* ignored by assistive devices.
*/
@property() label = '';
/** The name of a registered custom icon library. */
@property({ reflect: true }) library = 'default';
connectedCallback() {
super.connectedCallback();
watchIcon(this);
}
firstUpdated() {
this.initialRender = true;
this.setIcon();
}
disconnectedCallback() {
super.disconnectedCallback();
unwatchIcon(this);
}
private getUrl() {
const library = getIconLibrary(this.library);
if (this.name && library) {
return library.resolver(this.name);
}
return this.src;
}
@watch('label')
handleLabelChange() {
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
if (hasLabel) {
this.setAttribute('role', 'img');
this.setAttribute('aria-label', this.label);
this.removeAttribute('aria-hidden');
} else {
this.removeAttribute('role');
this.removeAttribute('aria-label');
this.setAttribute('aria-hidden', 'true');
}
}
@watch(['name', 'src', 'library'])
async setIcon() {
const library = getIconLibrary(this.library);
const url = this.getUrl();
if (!url) {
this.svg = null;
return;
}
let iconResolver = iconCache.get(url);
if (!iconResolver) {
iconResolver = this.resolveIcon(url, library);
iconCache.set(url, iconResolver);
}
// If we haven't rendered yet, exit early. This avoids unnecessary work due to watching multiple props.
if (!this.initialRender) {
return;
}
const svg = await iconResolver;
if (svg === RETRYABLE_ERROR) {
iconCache.delete(url);
}
if (url !== this.getUrl()) {
// If the url has changed while fetching the icon, ignore this request
return;
}
if (isTemplateResult(svg)) {
this.svg = svg;
return;
}
switch (svg) {
case RETRYABLE_ERROR:
case CACHEABLE_ERROR:
this.svg = null;
this.emit('sl-error');
break;
default:
this.svg = svg.cloneNode(true) as SVGElement;
library?.mutator?.(this.svg);
this.emit('sl-load');
}
}
render() {
return this.svg;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-icon': SlIcon;
}
}
import SlIcon from './icon.component.js';
export * from './icon.component.js';
export default SlIcon;
SlIcon.define('sl-icon');

View File

@@ -0,0 +1,159 @@
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { drag } from '../../internal/drag.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './image-comparer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Compare visual differences between similar photos with a sliding panel.
* @documentation https://shoelace.style/components/image-comparer
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot before - The before image, an `<img>` or `<svg>` element.
* @slot after - The after image, an `<img>` or `<svg>` element.
* @slot handle - The icon used inside the handle.
*
* @event sl-change - Emitted when the position changes.
*
* @csspart base - The component's base wrapper.
* @csspart before - The container that wraps the before image.
* @csspart after - The container that wraps the after image.
* @csspart divider - The divider that separates the images.
* @csspart handle - The handle that the user drags to expose the after image.
*
* @cssproperty --divider-width - The width of the dividing line.
* @cssproperty --handle-size - The size of the compare handle.
*/
export default class SlImageComparer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static scopedElement = { 'sl-icon': SlIcon };
private readonly localize = new LocalizeController(this);
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
/** The position of the divider as a percentage. */
@property({ type: Number, reflect: true }) position = 50;
private handleDrag(event: PointerEvent) {
const { width } = this.base.getBoundingClientRect();
const isRtl = this.localize.dir() === 'rtl';
event.preventDefault();
drag(this.base, {
onMove: x => {
this.position = parseFloat(clamp((x / width) * 100, 0, 100).toFixed(2));
if (isRtl) this.position = 100 - this.position;
},
initialEvent: event
});
}
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
const incr = event.shiftKey ? 10 : 1;
let newPosition = this.position;
event.preventDefault();
if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
newPosition -= incr;
}
if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
newPosition += incr;
}
if (event.key === 'Home') {
newPosition = 0;
}
if (event.key === 'End') {
newPosition = 100;
}
newPosition = clamp(newPosition, 0, 100);
this.position = newPosition;
}
}
@watch('position', { waitUntilFirstUpdate: true })
handlePositionChange() {
this.emit('sl-change');
}
render() {
const isRtl = this.localize.dir() === 'rtl';
return html`
<div
part="base"
id="image-comparer"
class=${classMap({
'image-comparer': true,
'image-comparer--rtl': isRtl
})}
@keydown=${this.handleKeyDown}
>
<div class="image-comparer__image">
<div part="before" class="image-comparer__before">
<slot name="before"></slot>
</div>
<div
part="after"
class="image-comparer__after"
style=${styleMap({
clipPath: isRtl ? `inset(0 0 0 ${100 - this.position}%)` : `inset(0 ${100 - this.position}% 0 0)`
})}
>
<slot name="after"></slot>
</div>
</div>
<div
part="divider"
class="image-comparer__divider"
style=${styleMap({
left: isRtl ? `${100 - this.position}%` : `${this.position}%`
})}
@mousedown=${this.handleDrag}
@touchstart=${this.handleDrag}
>
<div
part="handle"
class="image-comparer__handle"
role="scrollbar"
aria-valuenow=${this.position}
aria-valuemin="0"
aria-valuemax="100"
aria-controls="image-comparer"
tabindex="0"
>
<slot name="handle">
<sl-icon library="system" name="grip-vertical"></sl-icon>
</slot>
</div>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-image-comparer': SlImageComparer;
}
}

View File

@@ -1,155 +1,4 @@
import '../icon/icon.js';
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { drag } from '../../internal/drag.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './image-comparer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Compare visual differences between similar photos with a sliding panel.
* @documentation https://shoelace.style/components/image-comparer
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot before - The before image, an `<img>` or `<svg>` element.
* @slot after - The after image, an `<img>` or `<svg>` element.
* @slot handle - The icon used inside the handle.
*
* @event sl-change - Emitted when the position changes.
*
* @csspart base - The component's base wrapper.
* @csspart before - The container that wraps the before image.
* @csspart after - The container that wraps the after image.
* @csspart divider - The divider that separates the images.
* @csspart handle - The handle that the user drags to expose the after image.
*
* @cssproperty --divider-width - The width of the dividing line.
* @cssproperty --handle-size - The size of the compare handle.
*/
@customElement('sl-image-comparer')
export default class SlImageComparer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
/** The position of the divider as a percentage. */
@property({ type: Number, reflect: true }) position = 50;
private handleDrag(event: PointerEvent) {
const { width } = this.base.getBoundingClientRect();
const isRtl = this.localize.dir() === 'rtl';
event.preventDefault();
drag(this.base, {
onMove: x => {
this.position = parseFloat(clamp((x / width) * 100, 0, 100).toFixed(2));
if (isRtl) this.position = 100 - this.position;
},
initialEvent: event
});
}
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
const incr = event.shiftKey ? 10 : 1;
let newPosition = this.position;
event.preventDefault();
if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
newPosition -= incr;
}
if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
newPosition += incr;
}
if (event.key === 'Home') {
newPosition = 0;
}
if (event.key === 'End') {
newPosition = 100;
}
newPosition = clamp(newPosition, 0, 100);
this.position = newPosition;
}
}
@watch('position', { waitUntilFirstUpdate: true })
handlePositionChange() {
this.emit('sl-change');
}
render() {
const isRtl = this.localize.dir() === 'rtl';
return html`
<div
part="base"
id="image-comparer"
class=${classMap({
'image-comparer': true,
'image-comparer--rtl': isRtl
})}
@keydown=${this.handleKeyDown}
>
<div class="image-comparer__image">
<slot name="before" part="before" class="image-comparer__before"></slot>
<slot
name="after"
part="after"
class="image-comparer__after"
style=${styleMap({
clipPath: isRtl ? `inset(0 0 0 ${100 - this.position}%)` : `inset(0 ${100 - this.position}% 0 0)`
})}
></slot>
</div>
<div
part="divider"
class="image-comparer__divider"
style=${styleMap({
left: isRtl ? `${100 - this.position}%` : `${this.position}%`
})}
@mousedown=${this.handleDrag}
@touchstart=${this.handleDrag}
>
<slot
name="handle"
part="handle"
class="image-comparer__handle"
role="scrollbar"
aria-valuenow=${this.position}
aria-valuemin="0"
aria-valuemax="100"
aria-controls="image-comparer"
tabindex="0"
>
<sl-icon library="system" name="grip-vertical"></sl-icon>
</slot>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-image-comparer': SlImageComparer;
}
}
import SlImageComparer from './image-comparer.component.js';
export * from './image-comparer.component.js';
export default SlImageComparer;
SlImageComparer.define('sl-image-comparer');

View File

@@ -0,0 +1,81 @@
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { requestInclude } from './request.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './include.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Includes give you the power to embed external HTML files into the page.
* @documentation https://shoelace.style/components/include
* @status stable
* @since 2.0
*
* @event sl-load - Emitted when the included file is loaded.
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
*/
export default class SlInclude extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/**
* The location of the HTML file to include. Be sure you trust the content you are including as it will be executed as
* code and can result in XSS attacks.
*/
@property() src: string;
/** The fetch mode to use. */
@property() mode: 'cors' | 'no-cors' | 'same-origin' = 'cors';
/**
* Allows included scripts to be executed. Be sure you trust the content you are including as it will be executed as
* code and can result in XSS attacks.
*/
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
private executeScript(script: HTMLScriptElement) {
// Create a copy of the script and swap it out so the browser executes it
const newScript = document.createElement('script');
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.textContent = script.textContent;
script.parentNode!.replaceChild(newScript, script);
}
@watch('src')
async handleSrcChange() {
try {
const src = this.src;
const file = await requestInclude(src, this.mode);
// If the src changed since the request started do nothing, otherwise we risk overwriting a subsequent response
if (src !== this.src) {
return;
}
if (!file.ok) {
this.emit('sl-error', { detail: { status: file.status } });
return;
}
this.innerHTML = file.html;
if (this.allowScripts) {
[...this.querySelectorAll('script')].forEach(script => this.executeScript(script));
}
this.emit('sl-load');
} catch {
this.emit('sl-error', { detail: { status: -1 } });
}
}
render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-include': SlInclude;
}
}

View File

@@ -1,82 +1,4 @@
import { customElement, property } from 'lit/decorators.js';
import { html } from 'lit';
import { requestInclude } from './request.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './include.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Includes give you the power to embed external HTML files into the page.
* @documentation https://shoelace.style/components/include
* @status stable
* @since 2.0
*
* @event sl-load - Emitted when the included file is loaded.
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
*/
@customElement('sl-include')
export default class SlInclude extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/**
* The location of the HTML file to include. Be sure you trust the content you are including as it will be executed as
* code and can result in XSS attacks.
*/
@property() src: string;
/** The fetch mode to use. */
@property() mode: 'cors' | 'no-cors' | 'same-origin' = 'cors';
/**
* Allows included scripts to be executed. Be sure you trust the content you are including as it will be executed as
* code and can result in XSS attacks.
*/
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
private executeScript(script: HTMLScriptElement) {
// Create a copy of the script and swap it out so the browser executes it
const newScript = document.createElement('script');
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.textContent = script.textContent;
script.parentNode!.replaceChild(newScript, script);
}
@watch('src')
async handleSrcChange() {
try {
const src = this.src;
const file = await requestInclude(src, this.mode);
// If the src changed since the request started do nothing, otherwise we risk overwriting a subsequent response
if (src !== this.src) {
return;
}
if (!file.ok) {
this.emit('sl-error', { detail: { status: file.status } });
return;
}
this.innerHTML = file.html;
if (this.allowScripts) {
[...this.querySelectorAll('script')].forEach(script => this.executeScript(script));
}
this.emit('sl-load');
} catch {
this.emit('sl-error', { detail: { status: -1 } });
}
}
render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-include': SlInclude;
}
}
import SlInclude from './include.component.js';
export * from './include.component.js';
export default SlInclude;
SlInclude.define('sl-include');

View File

@@ -0,0 +1,556 @@
import { classMap } from 'lit/directives/class-map.js';
import { defaultValue } from '../../internal/default-value.js';
import { FormControlController } from '../../internal/form.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { 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 styles from './input.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
/**
* @summary Inputs collect data from the user.
* @documentation https://shoelace.style/components/input
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
* @slot prefix - Used to prepend a presentational icon or similar element to the input.
* @slot suffix - Used to append a presentational icon or similar element to the input.
* @slot clear-icon - An icon to use in lieu of the default clear icon.
* @slot show-password-icon - An icon to use in lieu of the default show password icon.
* @slot hide-password-icon - An icon to use in lieu of the default hide password icon.
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
*
* @event sl-blur - Emitted when the control loses focus.
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
* @event sl-clear - Emitted when the clear button is activated.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
* @csspart form-control-input - The input's wrapper.
* @csspart form-control-help-text - The help text's wrapper.
* @csspart base - The component's base wrapper.
* @csspart input - The internal `<input>` control.
* @csspart prefix - The container that wraps the prefix.
* @csspart clear-button - The clear button.
* @csspart password-toggle-button - The password toggle button.
* @csspart suffix - The container that wraps the suffix.
*/
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
private readonly formControlController = new FormControlController(this, {
assumeInteractionOn: ['sl-blur', 'sl-input']
});
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
@query('.input__control') input: HTMLInputElement;
@state() private hasFocus = false;
@property() title = ''; // make reactive to pass through
private __numberInput = Object.assign(document.createElement('input'), { type: 'number' });
private __dateInput = Object.assign(document.createElement('input'), { type: 'date' });
/**
* The type of input. Works the same as a native `<input>` element, but only a subset of types are supported. Defaults
* to `text`.
*/
@property({ reflect: true }) type:
| 'date'
| 'datetime-local'
| 'email'
| 'number'
| 'password'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url' = 'text';
/** The name of the input, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the input, submitted as a name/value pair with form data. */
@property() value = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
/** The input's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws a filled input. */
@property({ type: Boolean, reflect: true }) filled = false;
/** Draws a pill-style input with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/** The input's label. If you need to display HTML, use the `label` slot instead. */
@property() label = '';
/** The input's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/** Adds a clear button when the input is not empty. */
@property({ type: Boolean }) clearable = false;
/** Disables the input. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Placeholder text to show as a hint when the input is empty. */
@property() placeholder = '';
/** Makes the input readonly. */
@property({ type: Boolean, reflect: true }) readonly = false;
/** Adds a button to toggle the password's visibility. Only applies to password types. */
@property({ attribute: 'password-toggle', type: Boolean }) passwordToggle = false;
/** Determines whether or not the password is currently visible. Only applies to password input types. */
@property({ attribute: 'password-visible', type: Boolean }) passwordVisible = false;
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the input a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** A regular expression pattern to validate input against. */
@property() pattern: string;
/** The minimum length of input that will be considered valid. */
@property({ type: Number }) minlength: number;
/** The maximum length of input that will be considered valid. */
@property({ type: Number }) maxlength: number;
/** The input's minimum value. Only applies to date and number input types. */
@property() min: number | string;
/** The input's maximum value. Only applies to date and number input types. */
@property() max: number | string;
/**
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
* implied, allowing any numeric value. Only applies to date and number input types.
*/
@property() step: number | 'any';
/** Controls whether and how text input is automatically capitalized as it is entered by the user. */
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
/** Indicates whether the browser's autocorrect feature is on or off. */
@property() autocorrect: 'off' | 'on';
/**
* Specifies what permission the browser has to provide assistance in filling out form field values. Refer to
* [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values.
*/
@property() autocomplete: string;
/** Indicates that the input should receive focus on page load. */
@property({ type: Boolean }) autofocus: boolean;
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
@property() enterkeyhint: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
/** Enables spell checking on the input. */
@property({
type: Boolean,
converter: {
// Allow "true|false" attribute values but keep the property boolean
fromAttribute: value => (!value || value === 'false' ? false : true),
toAttribute: value => (value ? 'true' : 'false')
}
})
spellcheck = true;
/**
* Tells the browser what type of data will be entered by the user, allowing it to display the appropriate virtual
* keyboard on supportive devices.
*/
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
//
// NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
// can be set before the component is rendered.
//
/**
* Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. This will use the native `<input type="{{type}}">` implementation and may result in an error.
*/
get valueAsDate() {
this.__dateInput.type = this.type;
this.__dateInput.value = this.value;
return this.input?.valueAsDate || this.__dateInput.valueAsDate;
}
set valueAsDate(newValue: Date | null) {
this.__dateInput.type = this.type;
this.__dateInput.valueAsDate = newValue;
this.value = this.__dateInput.value;
}
/** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */
get valueAsNumber() {
this.__numberInput.value = this.value;
return this.input?.valueAsNumber || this.__numberInput.valueAsNumber;
}
set valueAsNumber(newValue: number) {
this.__numberInput.valueAsNumber = newValue;
this.value = this.__numberInput.value;
}
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleChange() {
this.value = this.input.value;
this.emit('sl-change');
}
private handleClearClick(event: MouseEvent) {
this.value = '';
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
this.input.focus();
event.stopPropagation();
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleInput() {
this.value = this.input.value;
this.formControlController.updateValidity();
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleKeyDown(event: KeyboardEvent) {
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
// submitting to allow users to cancel the keydown event if they need to
if (event.key === 'Enter' && !hasModifier) {
setTimeout(() => {
//
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
// to check for this is to look at event.isComposing, which will be true when the IME is open.
//
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
this.formControlController.submit();
}
});
}
}
private handlePasswordToggle() {
this.passwordVisible = !this.passwordVisible;
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch('step', { waitUntilFirstUpdate: true })
handleStepChange() {
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
// imperatively so we don't have to wait for the next render to report the updated validity.
this.input.step = String(this.step);
this.formControlController.updateValidity();
}
@watch('value', { waitUntilFirstUpdate: true })
async handleValueChange() {
await this.updateComplete;
this.formControlController.updateValidity();
}
/** Sets focus on the input. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the input. */
blur() {
this.input.blur();
}
/** Selects all the text in the input. */
select() {
this.input.select();
}
/** Sets the start and end positions of the text selection (0-based). */
setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection: 'forward' | 'backward' | 'none' = 'none'
) {
this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
}
/** Replaces a range of text with a new string. */
setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: 'select' | 'start' | 'end' | 'preserve'
) {
// @ts-expect-error - start, end, and selectMode are optional
this.input.setRangeText(replacement, start, end, selectMode);
if (this.value !== this.input.value) {
this.value = this.input.value;
}
}
/** Displays the browser picker for an input element (only works if the browser supports it for the input type). */
showPicker() {
if ('showPicker' in HTMLInputElement.prototype) {
this.input.showPicker();
}
}
/** Increments the value of a numeric input type by the value of the step attribute. */
stepUp() {
this.input.stepUp();
if (this.value !== this.input.value) {
this.value = this.input.value;
}
}
/** Decrements the value of a numeric input type by the value of the step attribute. */
stepDown() {
this.input.stepDown();
if (this.value !== this.input.value) {
this.value = this.input.value;
}
}
/** 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. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const hasClearIcon =
this.clearable && !this.disabled && !this.readonly && (typeof this.value === 'number' || this.value.length > 0);
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--small': this.size === 'small',
'form-control--medium': this.size === 'medium',
'form-control--large': this.size === 'large',
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
>
<label
part="form-control-label"
class="form-control__label"
for="input"
aria-hidden=${hasLabel ? 'false' : 'true'}
>
<slot name="label">${this.label}</slot>
</label>
<div part="form-control-input" class="form-control-input">
<div
part="base"
class=${classMap({
input: true,
// Sizes
'input--small': this.size === 'small',
'input--medium': this.size === 'medium',
'input--large': this.size === 'large',
// States
'input--pill': this.pill,
'input--standard': !this.filled,
'input--filled': this.filled,
'input--disabled': this.disabled,
'input--focused': this.hasFocus,
'input--empty': !this.value,
'input--no-spin-buttons': this.noSpinButtons
})}
>
<span part="prefix" class="input__prefix">
<slot name="prefix"></slot>
</span>
<input
part="input"
id="input"
class="input__control"
type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type}
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
name=${ifDefined(this.name)}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
placeholder=${ifDefined(this.placeholder)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step as number)}
.value=${live(this.value)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${this.spellcheck}
pattern=${ifDefined(this.pattern)}
enterkeyhint=${ifDefined(this.enterkeyhint)}
inputmode=${ifDefined(this.inputmode)}
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
${hasClearIcon
? html`
<button
part="clear-button"
class="input__clear"
type="button"
aria-label=${this.localize.term('clearEntry')}
@click=${this.handleClearClick}
tabindex="-1"
>
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''}
${this.passwordToggle && !this.disabled
? html`
<button
part="password-toggle-button"
class="input__password-toggle"
type="button"
aria-label=${this.localize.term(this.passwordVisible ? 'hidePassword' : 'showPassword')}
@click=${this.handlePasswordToggle}
tabindex="-1"
>
${this.passwordVisible
? html`
<slot name="show-password-icon">
<sl-icon name="eye-slash" library="system"></sl-icon>
</slot>
`
: html`
<slot name="hide-password-icon">
<sl-icon name="eye" library="system"></sl-icon>
</slot>
`}
</button>
`
: ''}
<span part="suffix" class="input__suffix">
<slot name="suffix"></slot>
</span>
</div>
</div>
<div
part="form-control-help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
<slot name="help-text">${this.helpText}</slot>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-input': SlInput;
}
}

View File

@@ -147,8 +147,8 @@ export default css`
cursor: default;
}
.input__prefix::slotted(sl-icon),
.input__suffix::slotted(sl-icon) {
.input__prefix ::slotted(sl-icon),
.input__suffix ::slotted(sl-icon) {
color: var(--sl-input-icon-color);
}
@@ -172,11 +172,11 @@ export default css`
width: calc(1em + var(--sl-input-spacing-small) * 2);
}
.input--small .input__prefix::slotted(*) {
.input--small .input__prefix ::slotted(*) {
margin-inline-start: var(--sl-input-spacing-small);
}
.input--small .input__suffix::slotted(*) {
.input--small .input__suffix ::slotted(*) {
margin-inline-end: var(--sl-input-spacing-small);
}
@@ -196,11 +196,11 @@ export default css`
width: calc(1em + var(--sl-input-spacing-medium) * 2);
}
.input--medium .input__prefix::slotted(*) {
.input--medium .input__prefix ::slotted(*) {
margin-inline-start: var(--sl-input-spacing-medium);
}
.input--medium .input__suffix::slotted(*) {
.input--medium .input__suffix ::slotted(*) {
margin-inline-end: var(--sl-input-spacing-medium);
}
@@ -220,11 +220,11 @@ export default css`
width: calc(1em + var(--sl-input-spacing-large) * 2);
}
.input--large .input__prefix::slotted(*) {
.input--large .input__prefix ::slotted(*) {
margin-inline-start: var(--sl-input-spacing-large);
}
.input--large .input__suffix::slotted(*) {
.input--large .input__suffix ::slotted(*) {
margin-inline-end: var(--sl-input-spacing-large);
}

View File

@@ -1,553 +1,4 @@
import '../icon/icon.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { defaultValue } from '../../internal/default-value.js';
import { FormControlController } from '../../internal/form.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { LocalizeController } from '../../utilities/localize.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './input.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
/**
* @summary Inputs collect data from the user.
* @documentation https://shoelace.style/components/input
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
* @slot prefix - Used to prepend a presentational icon or similar element to the input.
* @slot suffix - Used to append a presentational icon or similar element to the input.
* @slot clear-icon - An icon to use in lieu of the default clear icon.
* @slot show-password-icon - An icon to use in lieu of the default show password icon.
* @slot hide-password-icon - An icon to use in lieu of the default hide password icon.
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
*
* @event sl-blur - Emitted when the control loses focus.
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
* @event sl-clear - Emitted when the clear button is activated.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
* @csspart form-control-input - The input's wrapper.
* @csspart form-control-help-text - The help text's wrapper.
* @csspart base - The component's base wrapper.
* @csspart input - The internal `<input>` control.
* @csspart prefix - The container that wraps the prefix.
* @csspart clear-button - The clear button.
* @csspart password-toggle-button - The password toggle button.
* @csspart suffix - The container that wraps the suffix.
*/
@customElement('sl-input')
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this, {
assumeInteractionOn: ['sl-blur', 'sl-input']
});
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
@query('.input__control') input: HTMLInputElement;
@state() private hasFocus = false;
@property() title = ''; // make reactive to pass through
private __numberInput = Object.assign(document.createElement('input'), { type: 'number' });
private __dateInput = Object.assign(document.createElement('input'), { type: 'date' });
/**
* The type of input. Works the same as a native `<input>` element, but only a subset of types are supported. Defaults
* to `text`.
*/
@property({ reflect: true }) type:
| 'date'
| 'datetime-local'
| 'email'
| 'number'
| 'password'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url' = 'text';
/** The name of the input, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the input, submitted as a name/value pair with form data. */
@property() value = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
/** The input's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws a filled input. */
@property({ type: Boolean, reflect: true }) filled = false;
/** Draws a pill-style input with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/** The input's label. If you need to display HTML, use the `label` slot instead. */
@property() label = '';
/** The input's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/** Adds a clear button when the input is not empty. */
@property({ type: Boolean }) clearable = false;
/** Disables the input. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Placeholder text to show as a hint when the input is empty. */
@property() placeholder = '';
/** Makes the input readonly. */
@property({ type: Boolean, reflect: true }) readonly = false;
/** Adds a button to toggle the password's visibility. Only applies to password types. */
@property({ attribute: 'password-toggle', type: Boolean }) passwordToggle = false;
/** Determines whether or not the password is currently visible. Only applies to password input types. */
@property({ attribute: 'password-visible', type: Boolean }) passwordVisible = false;
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the input a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** A regular expression pattern to validate input against. */
@property() pattern: string;
/** The minimum length of input that will be considered valid. */
@property({ type: Number }) minlength: number;
/** The maximum length of input that will be considered valid. */
@property({ type: Number }) maxlength: number;
/** The input's minimum value. Only applies to date and number input types. */
@property() min: number | string;
/** The input's maximum value. Only applies to date and number input types. */
@property() max: number | string;
/**
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
* implied, allowing any numeric value. Only applies to date and number input types.
*/
@property() step: number | 'any';
/** Controls whether and how text input is automatically capitalized as it is entered by the user. */
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
/** Indicates whether the browser's autocorrect feature is on or off. */
@property() autocorrect: 'off' | 'on';
/**
* Specifies what permission the browser has to provide assistance in filling out form field values. Refer to
* [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values.
*/
@property() autocomplete: string;
/** Indicates that the input should receive focus on page load. */
@property({ type: Boolean }) autofocus: boolean;
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
@property() enterkeyhint: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
/** Enables spell checking on the input. */
@property({
type: Boolean,
converter: {
// Allow "true|false" attribute values but keep the property boolean
fromAttribute: value => (!value || value === 'false' ? false : true),
toAttribute: value => (value ? 'true' : 'false')
}
})
spellcheck = true;
/**
* Tells the browser what type of data will be entered by the user, allowing it to display the appropriate virtual
* keyboard on supportive devices.
*/
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
//
// NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
// can be set before the component is rendered.
//
/** Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. */
get valueAsDate() {
this.__dateInput.value = this.value;
return this.input?.valueAsDate || this.__dateInput.valueAsDate;
}
set valueAsDate(newValue: Date | null) {
this.__dateInput.valueAsDate = newValue;
this.value = this.__dateInput.value;
}
/** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */
get valueAsNumber() {
this.__numberInput.value = this.value;
return this.input?.valueAsNumber || this.__numberInput.valueAsNumber;
}
set valueAsNumber(newValue: number) {
this.__numberInput.valueAsNumber = newValue;
this.value = this.__numberInput.value;
}
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleChange() {
this.value = this.input.value;
this.emit('sl-change');
}
private handleClearClick(event: MouseEvent) {
this.value = '';
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
this.input.focus();
event.stopPropagation();
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleInput() {
this.value = this.input.value;
this.formControlController.updateValidity();
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleKeyDown(event: KeyboardEvent) {
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
// submitting to allow users to cancel the keydown event if they need to
if (event.key === 'Enter' && !hasModifier) {
setTimeout(() => {
//
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
// to check for this is to look at event.isComposing, which will be true when the IME is open.
//
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
this.formControlController.submit();
}
});
}
}
private handlePasswordToggle() {
this.passwordVisible = !this.passwordVisible;
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch('step', { waitUntilFirstUpdate: true })
handleStepChange() {
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
// imperatively so we don't have to wait for the next render to report the updated validity.
this.input.step = String(this.step);
this.formControlController.updateValidity();
}
@watch('value', { waitUntilFirstUpdate: true })
async handleValueChange() {
await this.updateComplete;
this.formControlController.updateValidity();
}
/** Sets focus on the input. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the input. */
blur() {
this.input.blur();
}
/** Selects all the text in the input. */
select() {
this.input.select();
}
/** Sets the start and end positions of the text selection (0-based). */
setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection: 'forward' | 'backward' | 'none' = 'none'
) {
this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
}
/** Replaces a range of text with a new string. */
setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: 'select' | 'start' | 'end' | 'preserve'
) {
// @ts-expect-error - start, end, and selectMode are optional
this.input.setRangeText(replacement, start, end, selectMode);
if (this.value !== this.input.value) {
this.value = this.input.value;
}
}
/** Displays the browser picker for an input element (only works if the browser supports it for the input type). */
showPicker() {
if ('showPicker' in HTMLInputElement.prototype) {
this.input.showPicker();
}
}
/** Increments the value of a numeric input type by the value of the step attribute. */
stepUp() {
this.input.stepUp();
if (this.value !== this.input.value) {
this.value = this.input.value;
}
}
/** Decrements the value of a numeric input type by the value of the step attribute. */
stepDown() {
this.input.stepDown();
if (this.value !== this.input.value) {
this.value = this.input.value;
}
}
/** 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. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const hasClearIcon =
this.clearable && !this.disabled && !this.readonly && (typeof this.value === 'number' || this.value.length > 0);
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--small': this.size === 'small',
'form-control--medium': this.size === 'medium',
'form-control--large': this.size === 'large',
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
>
<label
part="form-control-label"
class="form-control__label"
for="input"
aria-hidden=${hasLabel ? 'false' : 'true'}
>
<slot name="label">${this.label}</slot>
</label>
<div part="form-control-input" class="form-control-input">
<div
part="base"
class=${classMap({
input: true,
// Sizes
'input--small': this.size === 'small',
'input--medium': this.size === 'medium',
'input--large': this.size === 'large',
// States
'input--pill': this.pill,
'input--standard': !this.filled,
'input--filled': this.filled,
'input--disabled': this.disabled,
'input--focused': this.hasFocus,
'input--empty': !this.value,
'input--no-spin-buttons': this.noSpinButtons
})}
>
<slot name="prefix" part="prefix" class="input__prefix"></slot>
<input
part="input"
id="input"
class="input__control"
type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type}
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
name=${ifDefined(this.name)}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
placeholder=${ifDefined(this.placeholder)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step as number)}
.value=${live(this.value)}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}
?autofocus=${this.autofocus}
spellcheck=${this.spellcheck}
pattern=${ifDefined(this.pattern)}
enterkeyhint=${ifDefined(this.enterkeyhint)}
inputmode=${ifDefined(this.inputmode)}
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
${
hasClearIcon
? html`
<button
part="clear-button"
class="input__clear"
type="button"
aria-label=${this.localize.term('clearEntry')}
@click=${this.handleClearClick}
tabindex="-1"
>
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''
}
${
this.passwordToggle && !this.disabled
? html`
<button
part="password-toggle-button"
class="input__password-toggle"
type="button"
aria-label=${this.localize.term(this.passwordVisible ? 'hidePassword' : 'showPassword')}
@click=${this.handlePasswordToggle}
tabindex="-1"
>
${this.passwordVisible
? html`
<slot name="show-password-icon">
<sl-icon name="eye-slash" library="system"></sl-icon>
</slot>
`
: html`
<slot name="hide-password-icon">
<sl-icon name="eye" library="system"></sl-icon>
</slot>
`}
</button>
`
: ''
}
<slot name="suffix" part="suffix" class="input__suffix"></slot>
</div>
</div>
<slot
name="help-text"
part="form-control-help-text"
id="help-text"
class="form-control__help-text"
aria-hidden=${hasHelpText ? 'false' : 'true'}
>
${this.helpText}
</slot>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-input': SlInput;
}
}
import SlInput from './input.component.js';
export * from './input.component.js';
export default SlInput;
SlInput.define('sl-input');

View File

@@ -0,0 +1,138 @@
import { classMap } from 'lit/directives/class-map.js';
import { getTextContent } from '../../internal/slot.js';
import { html } from 'lit';
import { property, query } 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 './menu-item.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Menu items provide options for the user to pick from in a menu.
* @documentation https://shoelace.style/components/menu-item
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The menu item's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
*
* @csspart base - The component's base wrapper.
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
* @csspart prefix - The prefix container.
* @csspart label - The menu item label.
* @csspart suffix - The suffix container.
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
*/
export default class SlMenuItem extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
private cachedTextLabel: string;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.menu-item') menuItem: HTMLElement;
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
@property() type: 'normal' | 'checkbox' = 'normal';
/** Draws the item in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
@property() value = '';
/** Draws the menu item in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
}
@watch('checked')
handleCheckedChange() {
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
if (this.checked && this.type !== 'checkbox') {
this.checked = false;
console.error('The checked attribute can only be used on menu items with type="checkbox"', this);
return;
}
// Only checkbox types can receive the aria-checked attribute
if (this.type === 'checkbox') {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.removeAttribute('aria-checked');
}
}
@watch('disabled')
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
@watch('type')
handleTypeChange() {
if (this.type === 'checkbox') {
this.setAttribute('role', 'menuitemcheckbox');
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.setAttribute('role', 'menuitem');
this.removeAttribute('aria-checked');
}
}
/** Returns a text label based on the contents of the menu item's default slot. */
getTextLabel() {
return getTextContent(this.defaultSlot);
}
render() {
return html`
<div
part="base"
class=${classMap({
'menu-item': true,
'menu-item--checked': this.checked,
'menu-item--disabled': this.disabled,
'menu-item--has-submenu': false // reserved for future use
})}
>
<span part="checked-icon" class="menu-item__check">
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
</span>
<slot name="prefix" part="prefix" class="menu-item__prefix"></slot>
<slot part="label" class="menu-item__label" @slotchange=${this.handleDefaultSlotChange}></slot>
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
<span part="submenu-icon" class="menu-item__chevron">
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
</span>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-menu-item': SlMenuItem;
}
}

View File

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

View File

@@ -1,151 +1,4 @@
import '../icon/icon.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getTextContent } from '../../internal/slot.js';
import { html } from 'lit';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './menu-item.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Menu items provide options for the user to pick from in a menu.
* @documentation https://shoelace.style/components/menu-item
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The menu item's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
*
* @csspart base - The component's base wrapper.
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
* @csspart prefix - The prefix container.
* @csspart label - The menu item label.
* @csspart suffix - The suffix container.
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
*/
@customElement('sl-menu-item')
export default class SlMenuItem extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private cachedTextLabel: string;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.menu-item') menuItem: HTMLElement;
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
@property() type: 'normal' | 'checkbox' = 'normal';
/** Draws the item in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
@property() value = '';
/** Draws the menu item in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
constructor() {
super();
this.addEventListener('click', this.handleHostClick);
}
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
}
private handleHostClick = (event: MouseEvent) => {
// Prevent the click event from being emitted when the button is disabled or loading
if (this.disabled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
@watch('checked')
handleCheckedChange() {
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
if (this.checked && this.type !== 'checkbox') {
this.checked = false;
console.error('The checked attribute can only be used on menu items with type="checkbox"', this);
return;
}
// Only checkbox types can receive the aria-checked attribute
if (this.type === 'checkbox') {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.removeAttribute('aria-checked');
}
}
@watch('disabled')
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
@watch('type')
handleTypeChange() {
if (this.type === 'checkbox') {
this.setAttribute('role', 'menuitemcheckbox');
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.setAttribute('role', 'menuitem');
this.removeAttribute('aria-checked');
}
}
/** Returns a text label based on the contents of the menu item's default slot. */
getTextLabel() {
return getTextContent(this.defaultSlot);
}
render() {
return html`
<div
part="base"
class=${classMap({
'menu-item': true,
'menu-item--checked': this.checked,
'menu-item--disabled': this.disabled,
'menu-item--has-submenu': false // reserved for future use
})}
>
<span part="checked-icon" class="menu-item__check">
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
</span>
<slot name="prefix" part="prefix" class="menu-item__prefix"></slot>
<slot part="label" class="menu-item__label" @slotchange=${this.handleDefaultSlotChange}></slot>
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
<span part="submenu-icon" class="menu-item__chevron">
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
</span>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-menu-item': SlMenuItem;
}
}
import SlMenuItem from './menu-item.component.js';
export * from './menu-item.component.js';
export default SlMenuItem;
SlMenuItem.define('sl-menu-item');

View File

@@ -0,0 +1,28 @@
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './menu-label.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Menu labels are used to describe a group of menu items.
* @documentation https://shoelace.style/components/menu-label
* @status stable
* @since 2.0
*
* @slot - The menu label's content.
*
* @csspart base - The component's base wrapper.
*/
export default class SlMenuLabel extends ShoelaceElement {
static styles: CSSResultGroup = styles;
render() {
return html` <slot part="base" class="menu-label"></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-menu-label': SlMenuLabel;
}
}

View File

@@ -1,30 +1,4 @@
import { customElement } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './menu-label.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Menu labels are used to describe a group of menu items.
* @documentation https://shoelace.style/components/menu-label
* @status stable
* @since 2.0
*
* @slot - The menu label's content.
*
* @csspart base - The component's base wrapper.
*/
@customElement('sl-menu-label')
export default class SlMenuLabel extends ShoelaceElement {
static styles: CSSResultGroup = styles;
render() {
return html` <slot part="base" class="menu-label"></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-menu-label': SlMenuLabel;
}
}
import SlMenuLabel from './menu-label.component.js';
export * from './menu-label.component.js';
export default SlMenuLabel;
SlMenuLabel.define('sl-menu-label');

View File

@@ -0,0 +1,159 @@
import { html } from 'lit';
import { query } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './menu.styles.js';
import type { CSSResultGroup } from 'lit';
import type SlMenuItem from '../menu-item/menu-item.js';
export interface MenuSelectEventDetail {
item: SlMenuItem;
}
/**
* @summary Menus provide a list of options for the user to choose from.
* @documentation https://shoelace.style/components/menu
* @status stable
* @since 2.0
*
* @slot - The menu's content, including menu items, menu labels, and dividers.
*
* @event {{ item: SlMenuItem }} sl-select - Emitted when a menu item is selected.
*/
export default class SlMenu extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('slot') defaultSlot: HTMLSlotElement;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'menu');
}
private handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const item = target.closest('sl-menu-item');
if (!item || item.disabled || item.inert) {
return;
}
if (item.type === 'checkbox') {
item.checked = !item.checked;
}
this.emit('sl-select', { detail: { item } });
}
private handleKeyDown(event: KeyboardEvent) {
// Make a selection when pressing enter or space
if (event.key === 'Enter' || event.key === ' ') {
const item = this.getCurrentItem();
event.preventDefault();
// Simulate a click to support @click handlers on menu items that also work with the keyboard
item?.click();
}
// Move the selection when pressing down or up
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
const items = this.getAllItems();
const activeItem = this.getCurrentItem();
let index = activeItem ? items.indexOf(activeItem) : 0;
if (items.length > 0) {
event.preventDefault();
if (event.key === 'ArrowDown') {
index++;
} else if (event.key === 'ArrowUp') {
index--;
} else if (event.key === 'Home') {
index = 0;
} else if (event.key === 'End') {
index = items.length - 1;
}
if (index < 0) {
index = items.length - 1;
}
if (index > items.length - 1) {
index = 0;
}
this.setCurrentItem(items[index]);
items[index].focus();
}
}
}
private handleMouseDown(event: MouseEvent) {
const target = event.target as HTMLElement;
if (this.isMenuItem(target)) {
this.setCurrentItem(target as SlMenuItem);
}
}
private handleSlotChange() {
const items = this.getAllItems();
// Reset the roving tab index when the slotted items change
if (items.length > 0) {
this.setCurrentItem(items[0]);
}
}
private isMenuItem(item: HTMLElement) {
return (
item.tagName.toLowerCase() === 'sl-menu-item' ||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
);
}
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
getAllItems() {
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
if (el.inert || !this.isMenuItem(el)) {
return false;
}
return true;
}) as SlMenuItem[];
}
/**
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
*/
getCurrentItem() {
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
}
/**
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
*/
setCurrentItem(item: SlMenuItem) {
const items = this.getAllItems();
// Update tab indexes
items.forEach(i => {
i.setAttribute('tabindex', i === item ? '0' : '-1');
});
}
render() {
return html`
<slot
@slotchange=${this.handleSlotChange}
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mousedown=${this.handleMouseDown}
></slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-menu': SlMenu;
}
}

View File

@@ -1,160 +1,4 @@
import { customElement, query } from 'lit/decorators.js';
import { html } from 'lit';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './menu.styles.js';
import type { CSSResultGroup } from 'lit';
import type SlMenuItem from '../menu-item/menu-item.js';
export interface MenuSelectEventDetail {
item: SlMenuItem;
}
/**
* @summary Menus provide a list of options for the user to choose from.
* @documentation https://shoelace.style/components/menu
* @status stable
* @since 2.0
*
* @slot - The menu's content, including menu items, menu labels, and dividers.
*
* @event {{ item: SlMenuItem }} sl-select - Emitted when a menu item is selected.
*/
@customElement('sl-menu')
export default class SlMenu extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('slot') defaultSlot: HTMLSlotElement;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'menu');
}
private handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const item = target.closest('sl-menu-item');
if (!item || item.disabled || item.inert) {
return;
}
if (item.type === 'checkbox') {
item.checked = !item.checked;
}
this.emit('sl-select', { detail: { item } });
}
private handleKeyDown(event: KeyboardEvent) {
// Make a selection when pressing enter or space
if (event.key === 'Enter' || event.key === ' ') {
const item = this.getCurrentItem();
event.preventDefault();
// Simulate a click to support @click handlers on menu items that also work with the keyboard
item?.click();
}
// Move the selection when pressing down or up
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
const items = this.getAllItems();
const activeItem = this.getCurrentItem();
let index = activeItem ? items.indexOf(activeItem) : 0;
if (items.length > 0) {
event.preventDefault();
if (event.key === 'ArrowDown') {
index++;
} else if (event.key === 'ArrowUp') {
index--;
} else if (event.key === 'Home') {
index = 0;
} else if (event.key === 'End') {
index = items.length - 1;
}
if (index < 0) {
index = items.length - 1;
}
if (index > items.length - 1) {
index = 0;
}
this.setCurrentItem(items[index]);
items[index].focus();
}
}
}
private handleMouseDown(event: MouseEvent) {
const target = event.target as HTMLElement;
if (this.isMenuItem(target)) {
this.setCurrentItem(target as SlMenuItem);
}
}
private handleSlotChange() {
const items = this.getAllItems();
// Reset the roving tab index when the slotted items change
if (items.length > 0) {
this.setCurrentItem(items[0]);
}
}
private isMenuItem(item: HTMLElement) {
return (
item.tagName.toLowerCase() === 'sl-menu-item' ||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
);
}
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
getAllItems() {
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
if (el.inert || !this.isMenuItem(el)) {
return false;
}
return true;
}) as SlMenuItem[];
}
/**
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
*/
getCurrentItem() {
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
}
/**
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
*/
setCurrentItem(item: SlMenuItem) {
const items = this.getAllItems();
// Update tab indexes
items.forEach(i => {
i.setAttribute('tabindex', i === item ? '0' : '-1');
});
}
render() {
return html`
<slot
@slotchange=${this.handleSlotChange}
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mousedown=${this.handleMouseDown}
></slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-menu': SlMenu;
}
}
import SlMenu from './menu.component.js';
export * from './menu.component.js';
export default SlMenu;
SlMenu.define('sl-menu');

View File

@@ -0,0 +1,119 @@
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './mutation-observer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary The Mutation Observer component offers a thin, declarative interface to the [`MutationObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
* @documentation https://shoelace.style/components/mutation-observer
* @status stable
* @since 2.0
*
* @event {{ mutationList: MutationRecord[] }} sl-mutation - Emitted when a mutation occurs.
*
* @slot - The content to watch for mutations.
*/
export default class SlMutationObserver extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private mutationObserver: MutationObserver;
/**
* Watches for changes to attributes. To watch only specific attributes, separate them by a space, e.g.
* `attr="class id title"`. To watch all attributes, use `*`.
*/
@property({ reflect: true }) attr: string;
/** Indicates whether or not the attribute's previous value should be recorded when monitoring changes. */
@property({ attribute: 'attr-old-value', type: Boolean, reflect: true }) attrOldValue = false;
/** Watches for changes to the character data contained within the node. */
@property({ attribute: 'char-data', type: Boolean, reflect: true }) charData = false;
/** Indicates whether or not the previous value of the node's text should be recorded. */
@property({ attribute: 'char-data-old-value', type: Boolean, reflect: true }) charDataOldValue = false;
/** Watches for the addition or removal of new child nodes. */
@property({ attribute: 'child-list', type: Boolean, reflect: true }) childList = false;
/** Disables the observer. */
@property({ type: Boolean, reflect: true }) disabled = false;
connectedCallback() {
super.connectedCallback();
this.mutationObserver = new MutationObserver(this.handleMutation);
if (!this.disabled) {
this.startObserver();
}
}
disconnectedCallback() {
this.stopObserver();
}
private handleMutation = (mutationList: MutationRecord[]) => {
this.emit('sl-mutation', {
detail: { mutationList }
});
};
private startObserver() {
const observeAttributes = typeof this.attr === 'string' && this.attr.length > 0;
const attributeFilter = observeAttributes && this.attr !== '*' ? this.attr.split(' ') : undefined;
try {
this.mutationObserver.observe(this, {
subtree: true,
childList: this.childList,
attributes: observeAttributes,
attributeFilter,
attributeOldValue: this.attrOldValue,
characterData: this.charData,
characterDataOldValue: this.charDataOldValue
});
} catch {
//
// A mutation observer was created without one of the required attributes: attr, char-data, or child-list. The
// browser will normally throw an error, but we'll suppress that so it doesn't appear as attributes are added
// and removed.
//
}
}
private stopObserver() {
this.mutationObserver.disconnect();
}
@watch('disabled')
handleDisabledChange() {
if (this.disabled) {
this.stopObserver();
} else {
this.startObserver();
}
}
@watch('attr', { waitUntilFirstUpdate: true })
@watch('attr-old-value', { waitUntilFirstUpdate: true })
@watch('char-data', { waitUntilFirstUpdate: true })
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
@watch('childList', { waitUntilFirstUpdate: true })
handleChange() {
this.stopObserver();
this.startObserver();
}
render() {
return html` <slot></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-mutation-observer': SlMutationObserver;
}
}

View File

@@ -1,120 +1,4 @@
import { customElement, property } from 'lit/decorators.js';
import { html } from 'lit';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './mutation-observer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary The Mutation Observer component offers a thin, declarative interface to the [`MutationObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
* @documentation https://shoelace.style/components/mutation-observer
* @status stable
* @since 2.0
*
* @event {{ mutationList: MutationRecord[] }} sl-mutation - Emitted when a mutation occurs.
*
* @slot - The content to watch for mutations.
*/
@customElement('sl-mutation-observer')
export default class SlMutationObserver extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private mutationObserver: MutationObserver;
/**
* Watches for changes to attributes. To watch only specific attributes, separate them by a space, e.g.
* `attr="class id title"`. To watch all attributes, use `*`.
*/
@property({ reflect: true }) attr: string;
/** Indicates whether or not the attribute's previous value should be recorded when monitoring changes. */
@property({ attribute: 'attr-old-value', type: Boolean, reflect: true }) attrOldValue = false;
/** Watches for changes to the character data contained within the node. */
@property({ attribute: 'char-data', type: Boolean, reflect: true }) charData = false;
/** Indicates whether or not the previous value of the node's text should be recorded. */
@property({ attribute: 'char-data-old-value', type: Boolean, reflect: true }) charDataOldValue = false;
/** Watches for the addition or removal of new child nodes. */
@property({ attribute: 'child-list', type: Boolean, reflect: true }) childList = false;
/** Disables the observer. */
@property({ type: Boolean, reflect: true }) disabled = false;
connectedCallback() {
super.connectedCallback();
this.mutationObserver = new MutationObserver(this.handleMutation);
if (!this.disabled) {
this.startObserver();
}
}
disconnectedCallback() {
this.stopObserver();
}
private handleMutation = (mutationList: MutationRecord[]) => {
this.emit('sl-mutation', {
detail: { mutationList }
});
};
private startObserver() {
const observeAttributes = typeof this.attr === 'string' && this.attr.length > 0;
const attributeFilter = observeAttributes && this.attr !== '*' ? this.attr.split(' ') : undefined;
try {
this.mutationObserver.observe(this, {
subtree: true,
childList: this.childList,
attributes: observeAttributes,
attributeFilter,
attributeOldValue: this.attrOldValue,
characterData: this.charData,
characterDataOldValue: this.charDataOldValue
});
} catch {
//
// A mutation observer was created without one of the required attributes: attr, char-data, or child-list. The
// browser will normally throw an error, but we'll suppress that so it doesn't appear as attributes are added
// and removed.
//
}
}
private stopObserver() {
this.mutationObserver.disconnect();
}
@watch('disabled')
handleDisabledChange() {
if (this.disabled) {
this.stopObserver();
} else {
this.startObserver();
}
}
@watch('attr', { waitUntilFirstUpdate: true })
@watch('attr-old-value', { waitUntilFirstUpdate: true })
@watch('char-data', { waitUntilFirstUpdate: true })
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
@watch('childList', { waitUntilFirstUpdate: true })
handleChange() {
this.stopObserver();
this.startObserver();
}
render() {
return html` <slot></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-mutation-observer': SlMutationObserver;
}
}
import SlMutationObserver from './mutation-observer.component.js';
export * from './mutation-observer.component.js';
export default SlMutationObserver;
SlMutationObserver.define('sl-mutation-observer');

View File

@@ -0,0 +1,139 @@
import { classMap } from 'lit/directives/class-map.js';
import { html } from 'lit';
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 styles from './option.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Options define the selectable items within various form controls such as [select](/components/select).
* @documentation https://shoelace.style/components/option
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The option's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
*
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
* @csspart base - The component's base wrapper.
* @csspart label - The option's label.
* @csspart prefix - The container that wraps the prefix.
* @csspart suffix - The container that wraps the suffix.
*/
export default class SlOption extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
private cachedTextLabel: string;
// @ts-expect-error - Controller is currently unused
private readonly localize = new LocalizeController(this);
@query('.option__label') defaultSlot: HTMLSlotElement;
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
@state() selected = false; // the option is selected and has aria-selected="true"
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
/**
* The option's value. When selected, the containing form control will receive this value. The value must be unique
* from other options in the same group. Values may not contain spaces, as spaces are used as delimiters when listing
* multiple values.
*/
@property({ reflect: true }) value = '';
/** Draws the option in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'option');
this.setAttribute('aria-selected', 'false');
}
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
}
private handleMouseEnter() {
this.hasHover = true;
}
private handleMouseLeave() {
this.hasHover = false;
}
@watch('disabled')
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
@watch('selected')
handleSelectedChange() {
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
}
@watch('value')
handleValueChange() {
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
// instead of requiring them to cast the value to a string.
if (typeof this.value !== 'string') {
this.value = String(this.value);
}
if (this.value.includes(' ')) {
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
this.value = this.value.replace(/ /g, '_');
}
}
/** Returns a plain text label based on the option's content. */
getTextLabel() {
return (this.textContent ?? '').trim();
}
render() {
return html`
<div
part="base"
class=${classMap({
option: true,
'option--current': this.current,
'option--disabled': this.disabled,
'option--selected': this.selected,
'option--hover': this.hasHover
})}
@mouseenter=${this.handleMouseEnter}
@mouseleave=${this.handleMouseLeave}
>
<sl-icon part="checked-icon" class="option__check" name="check" library="system" aria-hidden="true"></sl-icon>
<slot part="prefix" name="prefix" class="option__prefix"></slot>
<slot part="label" class="option__label" @slotchange=${this.handleDefaultSlotChange}></slot>
<slot part="suffix" name="suffix" class="option__suffix"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-option': SlOption;
}
}

View File

@@ -1,139 +1,4 @@
import '../icon/icon.js';
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.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './option.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Options define the selectable items within various form controls such as [select](/components/select).
* @documentation https://shoelace.style/components/option
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The option's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
*
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
* @csspart base - The component's base wrapper.
* @csspart label - The option's label.
* @csspart prefix - The container that wraps the prefix.
* @csspart suffix - The container that wraps the suffix.
*/
@customElement('sl-option')
export default class SlOption extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private cachedTextLabel: string;
// @ts-expect-error - Controller is currently unused
private readonly localize = new LocalizeController(this);
@query('.option__label') defaultSlot: HTMLSlotElement;
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
@state() selected = false; // the option is selected and has aria-selected="true"
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
/**
* The option's value. When selected, the containing form control will receive this value. The value must be unique
* from other options in the same group. Values may not contain spaces, as spaces are used as delimiters when listing
* multiple values.
*/
@property({ reflect: true }) value = '';
/** Draws the option in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'option');
this.setAttribute('aria-selected', 'false');
}
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
}
private handleMouseEnter() {
this.hasHover = true;
}
private handleMouseLeave() {
this.hasHover = false;
}
@watch('disabled')
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
@watch('selected')
handleSelectedChange() {
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
}
@watch('value')
handleValueChange() {
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
// instead of requiring them to cast the value to a string.
if (typeof this.value !== 'string') {
this.value = String(this.value);
}
if (this.value.includes(' ')) {
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
this.value = this.value.replace(/ /g, '_');
}
}
/** Returns a plain text label based on the option's content. */
getTextLabel() {
return (this.textContent ?? '').trim();
}
render() {
return html`
<div
part="base"
class=${classMap({
option: true,
'option--current': this.current,
'option--disabled': this.disabled,
'option--selected': this.selected,
'option--hover': this.hasHover
})}
@mouseenter=${this.handleMouseEnter}
@mouseleave=${this.handleMouseLeave}
>
<sl-icon part="checked-icon" class="option__check" name="check" library="system" aria-hidden="true"></sl-icon>
<slot part="prefix" name="prefix" class="option__prefix"></slot>
<slot part="label" class="option__label" @slotchange=${this.handleDefaultSlotChange}></slot>
<slot part="suffix" name="suffix" class="option__suffix"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-option': SlOption;
}
}
import SlOption from './option.component.js';
export * from './option.component.js';
export default SlOption;
SlOption.define('sl-option');

View File

@@ -0,0 +1,479 @@
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
import { classMap } from 'lit/directives/class-map.js';
import { html } from 'lit';
import { offsetParent } from 'composed-offset-position';
import { property, query } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './popup.styles.js';
import type { CSSResultGroup } from 'lit';
export interface VirtualElement {
getBoundingClientRect: () => DOMRect;
}
function isVirtualElement(e: unknown): e is VirtualElement {
return e !== null && typeof e === 'object' && 'getBoundingClientRect' in e;
}
/**
* @summary Popup is a utility that lets you declaratively anchor "popup" containers to another element.
* @documentation https://shoelace.style/components/popup
* @status stable
* @since 2.0
*
* @event sl-reposition - Emitted when the popup is repositioned. This event can fire a lot, so avoid putting expensive
* operations in your listener or consider debouncing it.
*
* @slot - The popup's content.
* @slot anchor - The element the popup will be anchored to. If the anchor lives outside of the popup, you can use the
* `anchor` attribute or property instead.
*
* @csspart arrow - The arrow's container. Avoid setting `top|bottom|left|right` properties, as these values are
* assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and
* maybe a border or box shadow.
* @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc.
*
* @cssproperty [--arrow-size=6px] - The size of the arrow. Note that an arrow won't be shown unless the `arrow`
* attribute is used.
* @cssproperty [--arrow-color=var(--sl-color-neutral-0)] - The color of the arrow.
* @cssproperty [--auto-size-available-width] - A read-only custom property that determines the amount of width the
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
* available when using `auto-size`.
* @cssproperty [--auto-size-available-height] - A read-only custom property that determines the amount of height the
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
* available when using `auto-size`.
*/
export default class SlPopup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private anchorEl: Element | VirtualElement | null;
private cleanup: ReturnType<typeof autoUpdate> | undefined;
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@query('.popup') popup: HTMLElement;
@query('.popup__arrow') private arrowEl: HTMLElement;
/**
* The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide the anchor
* element `id`, a DOM element reference, or a `VirtualElement`. If the anchor lives inside the popup, use the
* `anchor` slot instead.
*/
@property() anchor: Element | string | VirtualElement;
/**
* Activates the positioning logic and shows the popup. When this attribute is removed, the positioning logic is torn
* down and the popup will be hidden.
*/
@property({ type: Boolean, reflect: true }) active = false;
/**
* The preferred placement of the popup. Note that the actual placement will vary as configured to keep the
* panel inside of the viewport.
*/
@property({ reflect: true }) placement:
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'right'
| 'right-start'
| 'right-end'
| 'left'
| 'left-start'
| 'left-end' = 'top';
/**
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if overflow is
* clipped, using a `fixed` position strategy can often workaround it.
*/
@property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
/** The distance in pixels from which to offset the panel away from its anchor. */
@property({ type: Number }) distance = 0;
/** The distance in pixels from which to offset the panel along its anchor. */
@property({ type: Number }) skidding = 0;
/**
* Attaches an arrow to the popup. The arrow's size and color can be customized using the `--arrow-size` and
* `--arrow-color` custom properties. For additional customizations, you can also target the arrow using
* `::part(arrow)` in your stylesheet.
*/
@property({ type: Boolean }) arrow = false;
/**
* The placement of the arrow. The default is `anchor`, which will align the arrow as close to the center of the
* anchor as possible, considering available space and `arrow-padding`. A value of `start`, `end`, or `center` will
* align the arrow to the start, end, or center of the popover instead.
*/
@property({ attribute: 'arrow-placement' }) arrowPlacement: 'start' | 'end' | 'center' | 'anchor' = 'anchor';
/**
* The amount of padding between the arrow and the edges of the popup. If the popup has a border-radius, for example,
* this will prevent it from overflowing the corners.
*/
@property({ attribute: 'arrow-padding', type: Number }) arrowPadding = 10;
/**
* When set, placement of the popup will flip to the opposite site to keep it in view. You can use
* `flipFallbackPlacements` to further configure how the fallback placement is determined.
*/
@property({ type: Boolean }) flip = false;
/**
* If the preferred placement doesn't fit, popup will be tested in these fallback placements until one fits. Must be a
* string of any number of placements separated by a space, e.g. "top bottom left". If no placement fits, the flip
* fallback strategy will be used instead.
* */
@property({
attribute: 'flip-fallback-placements',
converter: {
fromAttribute: (value: string) => {
return value
.split(' ')
.map(p => p.trim())
.filter(p => p !== '');
},
toAttribute: (value: []) => {
return value.join(' ');
}
}
})
flipFallbackPlacements = '';
/**
* When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether
* the popup should be positioned using the best available fit based on available space or as it was initially
* preferred.
*/
@property({ attribute: 'flip-fallback-strategy' }) flipFallbackStrategy: 'best-fit' | 'initial' = 'best-fit';
/**
* The flip boundary describes clipping element(s) that overflow will be checked relative to when flipping. By
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
* change the boundary by passing a reference to one or more elements to this property.
*/
@property({ type: Object }) flipBoundary: Element | Element[];
/** The amount of padding, in pixels, to exceed before the flip behavior will occur. */
@property({ attribute: 'flip-padding', type: Number }) flipPadding = 0;
/** Moves the popup along the axis to keep it in view when clipped. */
@property({ type: Boolean }) shift = false;
/**
* The shift boundary describes clipping element(s) that overflow will be checked relative to when shifting. By
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
* change the boundary by passing a reference to one or more elements to this property.
*/
@property({ type: Object }) shiftBoundary: Element | Element[];
/** The amount of padding, in pixels, to exceed before the shift behavior will occur. */
@property({ attribute: 'shift-padding', type: Number }) shiftPadding = 0;
/** When set, this will cause the popup to automatically resize itself to prevent it from overflowing. */
@property({ attribute: 'auto-size' }) autoSize: 'horizontal' | 'vertical' | 'both';
/** Syncs the popup's width or height to that of the anchor element. */
@property() sync: 'width' | 'height' | 'both';
/**
* The auto-size boundary describes clipping element(s) that overflow will be checked relative to when resizing. By
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
* change the boundary by passing a reference to one or more elements to this property.
*/
@property({ type: Object }) autoSizeBoundary: Element | Element[];
/** The amount of padding, in pixels, to exceed before the auto-size behavior will occur. */
@property({ attribute: 'auto-size-padding', type: Number }) autoSizePadding = 0;
async connectedCallback() {
super.connectedCallback();
// Start the positioner after the first update
await this.updateComplete;
this.start();
}
disconnectedCallback() {
this.stop();
}
async updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);
// Start or stop the positioner when active changes
if (changedProps.has('active')) {
if (this.active) {
this.start();
} else {
this.stop();
}
}
// Update the anchor when anchor changes
if (changedProps.has('anchor')) {
this.handleAnchorChange();
}
// All other properties will trigger a reposition when active
if (this.active) {
await this.updateComplete;
this.reposition();
}
}
private async handleAnchorChange() {
await this.stop();
if (this.anchor && typeof this.anchor === 'string') {
// Locate the anchor by id
const root = this.getRootNode() as Document | ShadowRoot;
this.anchorEl = root.getElementById(this.anchor);
} else if (this.anchor instanceof Element || isVirtualElement(this.anchor)) {
// Use the anchor's reference
this.anchorEl = this.anchor;
} else {
// Look for a slotted anchor
this.anchorEl = this.querySelector<HTMLElement>('[slot="anchor"]');
}
// If the anchor is a <slot>, we'll use the first assigned element as the target since slots use `display: contents`
// and positioning can't be calculated on them
if (this.anchorEl instanceof HTMLSlotElement) {
this.anchorEl = this.anchorEl.assignedElements({ flatten: true })[0] as HTMLElement;
}
if (!this.anchorEl) {
throw new Error(
'Invalid anchor element: no anchor could be found using the anchor slot or the anchor attribute.'
);
}
this.start();
}
private start() {
// We can't start the positioner without an anchor
if (!this.anchorEl) {
return;
}
this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {
this.reposition();
});
}
private async stop(): Promise<void> {
return new Promise(resolve => {
if (this.cleanup) {
this.cleanup();
this.cleanup = undefined;
this.removeAttribute('data-current-placement');
this.style.removeProperty('--auto-size-available-width');
this.style.removeProperty('--auto-size-available-height');
requestAnimationFrame(() => resolve());
} else {
resolve();
}
});
}
/** Forces the popup to recalculate and reposition itself. */
reposition() {
// Nothing to do if the popup is inactive or the anchor doesn't exist
if (!this.active || !this.anchorEl) {
return;
}
//
// NOTE: Floating UI middlewares are order dependent: https://floating-ui.com/docs/middleware
//
const middleware = [
// The offset middleware goes first
offset({ mainAxis: this.distance, crossAxis: this.skidding })
];
// First we sync width/height
if (this.sync) {
middleware.push(
size({
apply: ({ rects }) => {
const syncWidth = this.sync === 'width' || this.sync === 'both';
const syncHeight = this.sync === 'height' || this.sync === 'both';
this.popup.style.width = syncWidth ? `${rects.reference.width}px` : '';
this.popup.style.height = syncHeight ? `${rects.reference.height}px` : '';
}
})
);
} else {
// Cleanup styles if we're not matching width/height
this.popup.style.width = '';
this.popup.style.height = '';
}
// Then we flip
if (this.flip) {
middleware.push(
flip({
boundary: this.flipBoundary,
// @ts-expect-error - We're converting a string attribute to an array here
fallbackPlacements: this.flipFallbackPlacements,
fallbackStrategy: this.flipFallbackStrategy === 'best-fit' ? 'bestFit' : 'initialPlacement',
padding: this.flipPadding
})
);
}
// Then we shift
if (this.shift) {
middleware.push(
shift({
boundary: this.shiftBoundary,
padding: this.shiftPadding
})
);
}
// Now we adjust the size as needed
if (this.autoSize) {
middleware.push(
size({
boundary: this.autoSizeBoundary,
padding: this.autoSizePadding,
apply: ({ availableWidth, availableHeight }) => {
if (this.autoSize === 'vertical' || this.autoSize === 'both') {
this.style.setProperty('--auto-size-available-height', `${availableHeight}px`);
} else {
this.style.removeProperty('--auto-size-available-height');
}
if (this.autoSize === 'horizontal' || this.autoSize === 'both') {
this.style.setProperty('--auto-size-available-width', `${availableWidth}px`);
} else {
this.style.removeProperty('--auto-size-available-width');
}
}
})
);
} else {
// Cleanup styles if we're no longer using auto-size
this.style.removeProperty('--auto-size-available-width');
this.style.removeProperty('--auto-size-available-height');
}
// Finally, we add an arrow
if (this.arrow) {
middleware.push(
arrow({
element: this.arrowEl,
padding: this.arrowPadding
})
);
}
//
// Use custom positioning logic if the strategy is absolute. Otherwise, fall back to the default logic.
//
// More info: https://github.com/shoelace-style/shoelace/issues/1135
//
const getOffsetParent =
this.strategy === 'absolute'
? (element: Element) => platform.getOffsetParent(element, offsetParent)
: platform.getOffsetParent;
computePosition(this.anchorEl, this.popup, {
placement: this.placement,
middleware,
strategy: this.strategy,
platform: {
...platform,
getOffsetParent
}
}).then(({ x, y, middlewareData, placement }) => {
//
// Even though we have our own localization utility, it uses different heuristics to determine RTL. Because of
// that, we'll use the same approach that Floating UI uses.
//
// Source: https://github.com/floating-ui/floating-ui/blob/cb3b6ab07f95275730d3e6e46c702f8d4908b55c/packages/dom/src/utils/getDocumentRect.ts#L31
//
const isRtl = getComputedStyle(this).direction === 'rtl';
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
this.setAttribute('data-current-placement', placement);
Object.assign(this.popup.style, {
left: `${x}px`,
top: `${y}px`
});
if (this.arrow) {
const arrowX = middlewareData.arrow!.x;
const arrowY = middlewareData.arrow!.y;
let top = '';
let right = '';
let bottom = '';
let left = '';
if (this.arrowPlacement === 'start') {
// Start
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
top = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
right = isRtl ? value : '';
left = isRtl ? '' : value;
} else if (this.arrowPlacement === 'end') {
// End
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
right = isRtl ? '' : value;
left = isRtl ? value : '';
bottom = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
} else if (this.arrowPlacement === 'center') {
// Center
left = typeof arrowX === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
top = typeof arrowY === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
} else {
// Anchor (default)
left = typeof arrowX === 'number' ? `${arrowX}px` : '';
top = typeof arrowY === 'number' ? `${arrowY}px` : '';
}
Object.assign(this.arrowEl.style, {
top,
right,
bottom,
left,
[staticSide]: 'calc(var(--arrow-size-diagonal) * -1)'
});
}
});
this.emit('sl-reposition');
}
render() {
return html`
<slot name="anchor" @slotchange=${this.handleAnchorChange}></slot>
<div
part="popup"
class=${classMap({
popup: true,
'popup--active': this.active,
'popup--fixed': this.strategy === 'fixed',
'popup--has-arrow': this.arrow
})}
>
<slot></slot>
${this.arrow ? html`<div part="arrow" class="popup__arrow" role="presentation"></div>` : ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-popup': SlPopup;
}
}

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