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
This commit is contained in:
Konnor Rogers
2023-07-24 13:00:07 -04:00
committed by GitHub
parent 95f4f87eb8
commit 3a61d20d93
129 changed files with 13228 additions and 12668 deletions

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) {

View File

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

@@ -14,10 +14,17 @@ New versions of Shoelace are released as-needed and generally occur when a criti
## Next
- 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]
- 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]
@@ -27,6 +34,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti
- 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]
- Updated ESLint and related plugins to the latest versions
- Changed the default entrypoint for jsDelivr to point to the autoloader. [#1450]
## 2.5.2

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 {
// ...
}

50
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"del": "^7.0.0",
"download": "^8.0.0",
"esbuild": "^0.18.2",
"esbuild-plugin-replace": "^1.4.0",
"eslint": "^8.44.0",
"eslint-plugin-chai-expect": "^3.0.0",
"eslint-plugin-chai-friendly": "^0.7.2",
@@ -6877,6 +6878,15 @@
"@esbuild/win32-x64": "0.18.2"
}
},
"node_modules/esbuild-plugin-replace": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/esbuild-plugin-replace/-/esbuild-plugin-replace-1.4.0.tgz",
"integrity": "sha512-lP3ZAyzyRa5JXoOd59lJbRKNObtK8pJ/RO7o6vdjwLi71GfbL32NR22ZuS7/cLZkr10/L1lutoLma8E4DLngYg==",
"dev": true,
"dependencies": {
"magic-string": "^0.25.7"
}
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -11243,6 +11253,15 @@
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
"dev": true,
"dependencies": {
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -15494,6 +15513,13 @@
"node": ">=0.10.0"
}
},
"node_modules/sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
"dev": true
},
"node_modules/spawn-please": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/spawn-please/-/spawn-please-2.0.1.tgz",
@@ -22393,6 +22419,15 @@
"@esbuild/win32-x64": "0.18.2"
}
},
"esbuild-plugin-replace": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/esbuild-plugin-replace/-/esbuild-plugin-replace-1.4.0.tgz",
"integrity": "sha512-lP3ZAyzyRa5JXoOd59lJbRKNObtK8pJ/RO7o6vdjwLi71GfbL32NR22ZuS7/cLZkr10/L1lutoLma8E4DLngYg==",
"dev": true,
"requires": {
"magic-string": "^0.25.7"
}
},
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -25661,6 +25696,15 @@
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==",
"dev": true
},
"magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
"dev": true,
"requires": {
"sourcemap-codec": "^1.4.8"
}
},
"make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -28964,6 +29008,12 @@
}
}
},
"sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"dev": true
},
"spawn-please": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/spawn-please/-/spawn-please-2.0.1.tgz",

View File

@@ -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/*",
@@ -96,6 +108,7 @@
"del": "^7.0.0",
"download": "^8.0.0",
"esbuild": "^0.18.2",
"esbuild-plugin-replace": "^1.4.0",
"eslint": "^8.44.0",
"eslint-plugin-chai-expect": "^3.0.0",
"eslint-plugin-chai-friendly": "^0.7.2",

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

View File

@@ -36,6 +36,8 @@ components.map(component => {
fs.mkdirSync(componentDir, { recursive: true });
const jsDoc = component.jsDoc || '';
const source = prettier.format(
`
import * as React from 'react';
@@ -45,14 +47,32 @@ components.map(component => {
${eventNameImport}
${eventImports}
export default createComponent({
tagName: '${component.tagName}',
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,248 +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}
>
<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;
}
}
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,102 +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`
<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;
}
}
import SlAvatar from './avatar.component.js';
export * from './avatar.component.js';
export default SlAvatar;
SlAvatar.define('sl-avatar');

View File

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

View File

@@ -1,57 +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`
<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;
}
}
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,96 +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')
})}
>
<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;
}
}
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,106 +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>
<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;
}
}
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,98 +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`
<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;
}
}
import SlButtonGroup from './button-group.component.js';
export * from './button-group.component.js';
export default SlButtonGroup;
SlButtonGroup.define('sl-button-group');

View File

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

View File

@@ -1,333 +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.
* @csspart spinner - The spinner that shows when the button is in the loading state.
*/
@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 '';
}
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;
}
}
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,479 +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.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;
}
}
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,228 @@
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: 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;
}
}

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,345 +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();
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;
}
}
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,467 +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) => {
// 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;
}
}
import SlDrawer from './drawer.component.js';
export * from './drawer.component.js';
export default SlDrawer;
SlDrawer.define('sl-drawer');

View File

@@ -0,0 +1,441 @@
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();
}
}
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;
}
}

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

@@ -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,159 +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">
<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;
}
}
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

@@ -1,556 +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. 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;
}
}
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,138 +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;
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;
}
}
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;
}
}

View File

@@ -1,480 +1,4 @@
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { offsetParent } from 'composed-offset-position';
import ShoelaceElement from '../../internal/shoelace-element.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`.
*/
@customElement('sl-popup')
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;
}
}
import SlPopup from './popup.component.js';
export * from './popup.component.js';
export default SlPopup;
SlPopup.define('sl-popup');

View File

@@ -0,0 +1,69 @@
import { classMap } from 'lit/directives/class-map.js';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize.js';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './progress-bar.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Progress bars are used to show the status of an ongoing operation.
* @documentation https://shoelace.style/components/progress-bar
* @status stable
* @since 2.0
*
* @slot - A label to show inside the progress indicator.
*
* @csspart base - The component's base wrapper.
* @csspart indicator - The progress bar's indicator.
* @csspart label - The progress bar's label.
*
* @cssproperty --height - The progress bar's height.
* @cssproperty --track-color - The color of the track.
* @cssproperty --indicator-color - The color of the indicator.
* @cssproperty --label-color - The color of the label.
*/
export default class SlProgressBar extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
/** The current progress as a percentage, 0 to 100. */
@property({ type: Number, reflect: true }) value = 0;
/** When true, percentage is ignored, the label is hidden, and the progress bar is drawn in an indeterminate state. */
@property({ type: Boolean, reflect: true }) indeterminate = false;
/** A custom label for assistive devices. */
@property() label = '';
render() {
return html`
<div
part="base"
class=${classMap({
'progress-bar': true,
'progress-bar--indeterminate': this.indeterminate,
'progress-bar--rtl': this.localize.dir() === 'rtl'
})}
role="progressbar"
title=${ifDefined(this.title)}
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${this.indeterminate ? 0 : this.value}
>
<div part="indicator" class="progress-bar__indicator" style=${styleMap({ width: `${this.value}%` })}>
${!this.indeterminate ? html` <slot part="label" class="progress-bar__label"></slot> ` : ''}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-progress-bar': SlProgressBar;
}
}

View File

@@ -1,70 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property } from 'lit/decorators.js';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize.js';
import { styleMap } from 'lit/directives/style-map.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './progress-bar.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Progress bars are used to show the status of an ongoing operation.
* @documentation https://shoelace.style/components/progress-bar
* @status stable
* @since 2.0
*
* @slot - A label to show inside the progress indicator.
*
* @csspart base - The component's base wrapper.
* @csspart indicator - The progress bar's indicator.
* @csspart label - The progress bar's label.
*
* @cssproperty --height - The progress bar's height.
* @cssproperty --track-color - The color of the track.
* @cssproperty --indicator-color - The color of the indicator.
* @cssproperty --label-color - The color of the label.
*/
@customElement('sl-progress-bar')
export default class SlProgressBar extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
/** The current progress as a percentage, 0 to 100. */
@property({ type: Number, reflect: true }) value = 0;
/** When true, percentage is ignored, the label is hidden, and the progress bar is drawn in an indeterminate state. */
@property({ type: Boolean, reflect: true }) indeterminate = false;
/** A custom label for assistive devices. */
@property() label = '';
render() {
return html`
<div
part="base"
class=${classMap({
'progress-bar': true,
'progress-bar--indeterminate': this.indeterminate,
'progress-bar--rtl': this.localize.dir() === 'rtl'
})}
role="progressbar"
title=${ifDefined(this.title)}
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${this.indeterminate ? 0 : this.value}
>
<div part="indicator" class="progress-bar__indicator" style=${styleMap({ width: `${this.value}%` })}>
${!this.indeterminate ? html` <slot part="label" class="progress-bar__label"></slot> ` : ''}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-progress-bar': SlProgressBar;
}
}
import SlProgressBar from './progress-bar.component.js';
export * from './progress-bar.component.js';
export default SlProgressBar;
SlProgressBar.define('sl-progress-bar');

View File

@@ -0,0 +1,86 @@
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query, state } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './progress-ring.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Progress rings are used to show the progress of a determinate operation in a circular fashion.
* @documentation https://shoelace.style/components/progress-ring
* @status stable
* @since 2.0
*
* @slot - A label to show inside the ring.
*
* @csspart base - The component's base wrapper.
* @csspart label - The progress ring label.
*
* @cssproperty --size - The diameter of the progress ring (cannot be a percentage).
* @cssproperty --track-width - The width of the track.
* @cssproperty --track-color - The color of the track.
* @cssproperty --indicator-width - The width of the indicator. Defaults to the track width.
* @cssproperty --indicator-color - The color of the indicator.
* @cssproperty --indicator-transition-duration - The duration of the indicator's transition when the value changes.
*/
export default class SlProgressRing extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.progress-ring__indicator') indicator: SVGCircleElement;
@state() indicatorOffset: string;
/** The current progress as a percentage, 0 to 100. */
@property({ type: Number, reflect: true }) value = 0;
/** A custom label for assistive devices. */
@property() label = '';
updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);
//
// This block is only required for Safari because it doesn't transition the circle when the custom properties
// change, possibly because of a mix of pixel + unit-less values in the calc() function. It seems like a Safari bug,
// but I couldn't pinpoint it so this works around the problem.
//
if (changedProps.has('value')) {
const radius = parseFloat(getComputedStyle(this.indicator).getPropertyValue('r'));
const circumference = 2 * Math.PI * radius;
const offset = circumference - (this.value / 100) * circumference;
this.indicatorOffset = `${offset}px`;
}
}
render() {
return html`
<div
part="base"
class="progress-ring"
role="progressbar"
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
aria-describedby="label"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${this.value}"
style="--percentage: ${this.value / 100}"
>
<svg class="progress-ring__image">
<circle class="progress-ring__track"></circle>
<circle class="progress-ring__indicator" style="stroke-dashoffset: ${this.indicatorOffset}"></circle>
</svg>
<slot id="label" part="label" class="progress-ring__label"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-progress-ring': SlProgressRing;
}
}

View File

@@ -1,87 +1,4 @@
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './progress-ring.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Progress rings are used to show the progress of a determinate operation in a circular fashion.
* @documentation https://shoelace.style/components/progress-ring
* @status stable
* @since 2.0
*
* @slot - A label to show inside the ring.
*
* @csspart base - The component's base wrapper.
* @csspart label - The progress ring label.
*
* @cssproperty --size - The diameter of the progress ring (cannot be a percentage).
* @cssproperty --track-width - The width of the track.
* @cssproperty --track-color - The color of the track.
* @cssproperty --indicator-width - The width of the indicator. Defaults to the track width.
* @cssproperty --indicator-color - The color of the indicator.
* @cssproperty --indicator-transition-duration - The duration of the indicator's transition when the value changes.
*/
@customElement('sl-progress-ring')
export default class SlProgressRing extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.progress-ring__indicator') indicator: SVGCircleElement;
@state() indicatorOffset: string;
/** The current progress as a percentage, 0 to 100. */
@property({ type: Number, reflect: true }) value = 0;
/** A custom label for assistive devices. */
@property() label = '';
updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);
//
// This block is only required for Safari because it doesn't transition the circle when the custom properties
// change, possibly because of a mix of pixel + unit-less values in the calc() function. It seems like a Safari bug,
// but I couldn't pinpoint it so this works around the problem.
//
if (changedProps.has('value')) {
const radius = parseFloat(getComputedStyle(this.indicator).getPropertyValue('r'));
const circumference = 2 * Math.PI * radius;
const offset = circumference - (this.value / 100) * circumference;
this.indicatorOffset = `${offset}px`;
}
}
render() {
return html`
<div
part="base"
class="progress-ring"
role="progressbar"
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
aria-describedby="label"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${this.value}"
style="--percentage: ${this.value / 100}"
>
<svg class="progress-ring__image">
<circle class="progress-ring__track"></circle>
<circle class="progress-ring__indicator" style="stroke-dashoffset: ${this.indicatorOffset}"></circle>
</svg>
<slot id="label" part="label" class="progress-ring__label"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-progress-ring': SlProgressRing;
}
}
import SlProgressRing from './progress-ring.component.js';
export * from './progress-ring.component.js';
export default SlProgressRing;
SlProgressRing.define('sl-progress-ring');

View File

@@ -0,0 +1,88 @@
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch.js';
import QrCreator from 'qr-creator';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './qr-code.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Generates a [QR code](https://www.qrcode.com/) and renders it using the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
* @documentation https://shoelace.style/components/qr-code
* @status stable
* @since 2.0
*
* @csspart base - The component's base wrapper.
*/
export default class SlQrCode extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('canvas') canvas: HTMLElement;
/** The QR code's value. */
@property() value = '';
/** The label for assistive devices to announce. If unspecified, the value will be used instead. */
@property() label = '';
/** The size of the QR code, in pixels. */
@property({ type: Number }) size = 128;
/** The fill color. This can be any valid CSS color, but not a CSS custom property. */
@property() fill = 'black';
/** The background color. This can be any valid CSS color or `transparent`. It cannot be a CSS custom property. */
@property() background = 'white';
/** The edge radius of each module. Must be between 0 and 0.5. */
@property({ type: Number }) radius = 0;
/** The level of error correction to use. [Learn more](https://www.qrcode.com/en/about/error_correction.html) */
@property({ attribute: 'error-correction' }) errorCorrection: 'L' | 'M' | 'Q' | 'H' = 'H';
firstUpdated() {
this.generate();
}
@watch(['background', 'errorCorrection', 'fill', 'radius', 'size', 'value'])
generate() {
if (!this.hasUpdated) {
return;
}
QrCreator.render(
{
text: this.value,
radius: this.radius,
ecLevel: this.errorCorrection,
fill: this.fill,
background: this.background,
// We draw the canvas larger and scale its container down to avoid blurring on high-density displays
size: this.size * 2
},
this.canvas
);
}
render() {
return html`
<canvas
part="base"
class="qr-code"
role="img"
aria-label=${this.label?.length > 0 ? this.label : this.value}
style=${styleMap({
width: `${this.size}px`,
height: `${this.size}px`
})}
></canvas>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-qr-code': SlQrCode;
}
}

View File

@@ -1,89 +1,4 @@
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch.js';
import QrCreator from 'qr-creator';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './qr-code.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Generates a [QR code](https://www.qrcode.com/) and renders it using the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
* @documentation https://shoelace.style/components/qr-code
* @status stable
* @since 2.0
*
* @csspart base - The component's base wrapper.
*/
@customElement('sl-qr-code')
export default class SlQrCode extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('canvas') canvas: HTMLElement;
/** The QR code's value. */
@property() value = '';
/** The label for assistive devices to announce. If unspecified, the value will be used instead. */
@property() label = '';
/** The size of the QR code, in pixels. */
@property({ type: Number }) size = 128;
/** The fill color. This can be any valid CSS color, but not a CSS custom property. */
@property() fill = 'black';
/** The background color. This can be any valid CSS color or `transparent`. It cannot be a CSS custom property. */
@property() background = 'white';
/** The edge radius of each module. Must be between 0 and 0.5. */
@property({ type: Number }) radius = 0;
/** The level of error correction to use. [Learn more](https://www.qrcode.com/en/about/error_correction.html) */
@property({ attribute: 'error-correction' }) errorCorrection: 'L' | 'M' | 'Q' | 'H' = 'H';
firstUpdated() {
this.generate();
}
@watch(['background', 'errorCorrection', 'fill', 'radius', 'size', 'value'])
generate() {
if (!this.hasUpdated) {
return;
}
QrCreator.render(
{
text: this.value,
radius: this.radius,
ecLevel: this.errorCorrection,
fill: this.fill,
background: this.background,
// We draw the canvas larger and scale its container down to avoid blurring on high-density displays
size: this.size * 2
},
this.canvas
);
}
render() {
return html`
<canvas
part="base"
class="qr-code"
role="img"
aria-label=${this.label?.length > 0 ? this.label : this.value}
style=${styleMap({
width: `${this.size}px`,
height: `${this.size}px`
})}
></canvas>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-qr-code': SlQrCode;
}
}
import SlQrCode from './qr-code.component.js';
export * from './qr-code.component.js';
export default SlQrCode;
SlQrCode.define('sl-qr-code');

View File

@@ -0,0 +1,145 @@
import { classMap } from 'lit/directives/class-map.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { property, query, state } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './radio-button.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Radios buttons allow the user to select a single option from a group using a button-like control.
* @documentation https://shoelace.style/components/radio-button
* @status stable
* @since 2.0
*
* @slot - The radio button's label.
* @slot prefix - A presentational prefix icon or similar element.
* @slot suffix - A presentational suffix icon or similar element.
*
* @event sl-blur - Emitted when the button loses focus.
* @event sl-focus - Emitted when the button gains focus.
*
* @csspart base - The component's base wrapper.
* @csspart button - The internal `<button>` element.
* @csspart button--checked - The internal button element when the radio button is checked.
* @csspart prefix - The container that wraps the prefix.
* @csspart label - The container that wraps the radio button's label.
* @csspart suffix - The container that wraps the suffix.
*/
export default class SlRadioButton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@query('.button') input: HTMLInputElement;
@query('.hidden-input') hiddenInput: HTMLInputElement;
@state() protected hasFocus = false;
/**
* @internal The radio button's checked state. This is exposed as an "internal" attribute so we can reflect it, making
* it easier to style in button groups.
*/
@property({ type: Boolean, reflect: true }) checked = false;
/** The radio's value. When selected, the radio group will receive this value. */
@property() value: string;
/** Disables the radio button. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* The radio button's size. When used inside a radio group, the size will be determined by the radio group's size so
* this attribute can typically be omitted.
*/
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws a pill-style radio button with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'presentation');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleClick(e: MouseEvent) {
if (this.disabled) {
e.preventDefault();
e.stopPropagation();
return;
}
this.checked = true;
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
/** Sets focus on the radio button. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the radio button. */
blur() {
this.input.blur();
}
render() {
return html`
<div part="base" role="presentation">
<button
part="${`button${this.checked ? ' button--checked' : ''}`}"
role="radio"
aria-checked="${this.checked}"
class=${classMap({
button: true,
'button--default': true,
'button--small': this.size === 'small',
'button--medium': this.size === 'medium',
'button--large': this.size === 'large',
'button--checked': this.checked,
'button--disabled': this.disabled,
'button--focused': this.hasFocus,
'button--outline': true,
'button--pill': this.pill,
'button--has-label': this.hasSlotController.test('[default]'),
'button--has-prefix': this.hasSlotController.test('prefix'),
'button--has-suffix': this.hasSlotController.test('suffix')
})}
aria-disabled=${this.disabled}
type="button"
value=${ifDefined(this.value)}
tabindex="${this.checked ? '0' : '-1'}"
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@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>
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-radio-button': SlRadioButton;
}
}

View File

@@ -1,146 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './radio-button.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Radios buttons allow the user to select a single option from a group using a button-like control.
* @documentation https://shoelace.style/components/radio-button
* @status stable
* @since 2.0
*
* @slot - The radio button's label.
* @slot prefix - A presentational prefix icon or similar element.
* @slot suffix - A presentational suffix icon or similar element.
*
* @event sl-blur - Emitted when the button loses focus.
* @event sl-focus - Emitted when the button gains focus.
*
* @csspart base - The component's base wrapper.
* @csspart button - The internal `<button>` element.
* @csspart button--checked - The internal button element when the radio button is checked.
* @csspart prefix - The container that wraps the prefix.
* @csspart label - The container that wraps the radio button's label.
* @csspart suffix - The container that wraps the suffix.
*/
@customElement('sl-radio-button')
export default class SlRadioButton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@query('.button') input: HTMLInputElement;
@query('.hidden-input') hiddenInput: HTMLInputElement;
@state() protected hasFocus = false;
/**
* @internal The radio button's checked state. This is exposed as an "internal" attribute so we can reflect it, making
* it easier to style in button groups.
*/
@property({ type: Boolean, reflect: true }) checked = false;
/** The radio's value. When selected, the radio group will receive this value. */
@property() value: string;
/** Disables the radio button. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* The radio button's size. When used inside a radio group, the size will be determined by the radio group's size so
* this attribute can typically be omitted.
*/
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws a pill-style radio button with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'presentation');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleClick(e: MouseEvent) {
if (this.disabled) {
e.preventDefault();
e.stopPropagation();
return;
}
this.checked = true;
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
/** Sets focus on the radio button. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the radio button. */
blur() {
this.input.blur();
}
render() {
return html`
<div part="base" role="presentation">
<button
part="${`button${this.checked ? ' button--checked' : ''}`}"
role="radio"
aria-checked="${this.checked}"
class=${classMap({
button: true,
'button--default': true,
'button--small': this.size === 'small',
'button--medium': this.size === 'medium',
'button--large': this.size === 'large',
'button--checked': this.checked,
'button--disabled': this.disabled,
'button--focused': this.hasFocus,
'button--outline': true,
'button--pill': this.pill,
'button--has-label': this.hasSlotController.test('[default]'),
'button--has-prefix': this.hasSlotController.test('prefix'),
'button--has-suffix': this.hasSlotController.test('suffix')
})}
aria-disabled=${this.disabled}
type="button"
value=${ifDefined(this.value)}
tabindex="${this.checked ? '0' : '-1'}"
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@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>
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-radio-button': SlRadioButton;
}
}
import SlRadioButton from './radio-button.component.js';
export * from './radio-button.component.js';
export default SlRadioButton;
SlRadioButton.define('sl-radio-button');

View File

@@ -0,0 +1,406 @@
import { classMap } from 'lit/directives/class-map.js';
import {
customErrorValidityState,
FormControlController,
validValidityState,
valueMissingValidityState
} from '../../internal/form.js';
import { HasSlotController } from '../../internal/slot.js';
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 SlButtonGroup from '../button-group/button-group.component.js';
import styles from './radio-group.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
import type SlRadio from '../radio/radio.js';
import type SlRadioButton from '../radio-button/radio-button.js';
/**
* @summary Radio groups are used to group multiple [radios](/components/radio) or [radio buttons](/components/radio-button) so they function as a single form control.
* @documentation https://shoelace.style/components/radio-group
* @status stable
* @since 2.0
*
* @dependency sl-button-group
*
* @slot - The default slot where `<sl-radio>` or `<sl-radio-button>` elements are placed.
* @slot label - The radio group's label. Required for proper accessibility. Alternatively, you can use the `label`
* attribute.
*
* @event sl-change - Emitted when the radio group's selected value changes.
* @event sl-input - Emitted when the radio group receives user 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 button-group - The button group that wraps radio buttons.
* @csspart button-group__base - The button group's `base` part.
*/
export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-button-group': SlButtonGroup };
protected readonly formControlController = new FormControlController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private customValidityMessage = '';
private validationTimeout: number;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.radio-group__validation-input') validationInput: HTMLInputElement;
@state() private hasButtonGroup = false;
@state() private errorMessage = '';
@state() defaultValue = '';
/**
* The radio group's label. Required for proper accessibility. If you need to display HTML, use the `label` slot
* instead.
*/
@property() label = '';
/** The radio groups's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/** The name of the radio group, submitted as a name/value pair with form data. */
@property() name = 'option';
/** The current value of the radio group, submitted as a name/value pair with form data. */
@property({ reflect: true }) value = '';
/** The radio group's size. This size will be applied to all child radios and radio buttons. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/**
* 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 = '';
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return customErrorValidityState;
} else if (isRequiredAndEmpty) {
return valueMissingValidityState;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return this.customValidityMessage;
} else if (isRequiredAndEmpty) {
return this.validationInput.validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.defaultValue = this.value;
}
firstUpdated() {
this.formControlController.updateValidity();
}
private getAllRadios() {
return [...this.querySelectorAll<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')];
}
private handleRadioClick(event: MouseEvent) {
const target = (event.target as HTMLElement).closest<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')!;
const radios = this.getAllRadios();
const oldValue = this.value;
if (target.disabled) {
return;
}
this.value = target.value;
radios.forEach(radio => (radio.checked = radio === target));
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
}
private handleKeyDown(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
return;
}
const radios = this.getAllRadios().filter(radio => !radio.disabled);
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
const oldValue = this.value;
let index = radios.indexOf(checkedRadio) + incr;
if (index < 0) {
index = radios.length - 1;
}
if (index > radios.length - 1) {
index = 0;
}
this.getAllRadios().forEach(radio => {
radio.checked = false;
if (!this.hasButtonGroup) {
radio.tabIndex = -1;
}
});
this.value = radios[index].value;
radios[index].checked = true;
if (!this.hasButtonGroup) {
radios[index].tabIndex = 0;
radios[index].focus();
} else {
radios[index].shadowRoot!.querySelector('button')!.focus();
}
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
event.preventDefault();
}
private handleLabelClick() {
const radios = this.getAllRadios();
const checked = radios.find(radio => radio.checked);
const radioToFocus = checked || radios[0];
// Move focus to the checked radio (or the first one if none are checked) when clicking the label
if (radioToFocus) {
radioToFocus.focus();
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private async syncRadioElements() {
const radios = this.getAllRadios();
await Promise.all(
// Sync the checked state and size
radios.map(async radio => {
await radio.updateComplete;
radio.checked = radio.value === this.value;
radio.size = this.size;
})
);
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot?.querySelector('button');
if (buttonRadio) {
buttonRadio.tabIndex = 0;
}
} else {
radios[0].tabIndex = 0;
}
}
if (this.hasButtonGroup) {
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (buttonGroup) {
buttonGroup.disableRole = true;
}
}
}
private syncRadios() {
if (customElements.get('sl-radio') && customElements.get('sl-radio-button')) {
this.syncRadioElements();
return;
}
if (customElements.get('sl-radio')) {
this.syncRadioElements();
} else {
customElements.whenDefined('sl-radio').then(() => this.syncRadios());
}
if (customElements.get('sl-radio-button')) {
this.syncRadioElements();
} else {
// Rerun this handler when <sl-radio> or <sl-radio-button> is registered
customElements.whenDefined('sl-radio-button').then(() => this.syncRadios());
}
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.formControlController.setValidity(this.validity.valid);
}
@watch('size', { waitUntilFirstUpdate: true })
handleSizeChange() {
this.syncRadios();
}
@watch('value')
handleValueChange() {
if (this.hasUpdated) {
this.updateCheckedRadio();
}
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (isRequiredAndEmpty || hasCustomValidityMessage) {
this.formControlController.emitInvalidEvent();
return false;
}
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(): boolean {
const isValid = this.validity.valid;
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
this.formControlController.setValidity(isValid);
this.validationInput.hidden = true;
clearTimeout(this.validationTimeout);
if (!isValid) {
// Show the browser's constraint validation message
this.validationInput.hidden = false;
this.validationInput.reportValidity();
this.validationTimeout = setTimeout(() => (this.validationInput.hidden = true), 10000) as unknown as number;
}
return isValid;
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message = '') {
this.customValidityMessage = message;
this.errorMessage = message;
this.validationInput.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 defaultSlot = html`
<span @click=${this.handleRadioClick} @keydown=${this.handleKeyDown} role="presentation">
<slot @slotchange=${this.syncRadios}></slot>
</span>
`;
return html`
<fieldset
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--radio-group': true,
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
role="radiogroup"
aria-labelledby="label"
aria-describedby="help-text"
aria-errormessage="error-message"
>
<label
part="form-control-label"
id="label"
class="form-control__label"
aria-hidden=${hasLabel ? 'false' : 'true'}
@click=${this.handleLabelClick}
>
<slot name="label">${this.label}</slot>
</label>
<div part="form-control-input" class="form-control-input">
<div class="visually-hidden">
<div id="error-message" aria-live="assertive">${this.errorMessage}</div>
<label class="radio-group__validation">
<input
type="text"
class="radio-group__validation-input"
?required=${this.required}
tabindex="-1"
hidden
@invalid=${this.handleInvalid}
/>
</label>
</div>
${this.hasButtonGroup
? html`
<sl-button-group part="button-group" exportparts="base:button-group__base">
${defaultSlot}
</sl-button-group>
`
: defaultSlot}
</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>
</fieldset>
`;
/* eslint-enable lit-a11y/click-events-have-key-events */
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-radio-group': SlRadioGroup;
}
}

View File

@@ -1,406 +1,4 @@
import '../button-group/button-group.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import {
customErrorValidityState,
FormControlController,
validValidityState,
valueMissingValidityState
} from '../../internal/form.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './radio-group.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
import type SlRadio from '../radio/radio.js';
import type SlRadioButton from '../radio-button/radio-button.js';
/**
* @summary Radio groups are used to group multiple [radios](/components/radio) or [radio buttons](/components/radio-button) so they function as a single form control.
* @documentation https://shoelace.style/components/radio-group
* @status stable
* @since 2.0
*
* @dependency sl-button-group
*
* @slot - The default slot where `<sl-radio>` or `<sl-radio-button>` elements are placed.
* @slot label - The radio group's label. Required for proper accessibility. Alternatively, you can use the `label`
* attribute.
*
* @event sl-change - Emitted when the radio group's selected value changes.
* @event sl-input - Emitted when the radio group receives user 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 button-group - The button group that wraps radio buttons.
* @csspart button-group__base - The button group's `base` part.
*/
@customElement('sl-radio-group')
export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
protected readonly formControlController = new FormControlController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private customValidityMessage = '';
private validationTimeout: number;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.radio-group__validation-input') validationInput: HTMLInputElement;
@state() private hasButtonGroup = false;
@state() private errorMessage = '';
@state() defaultValue = '';
/**
* The radio group's label. Required for proper accessibility. If you need to display HTML, use the `label` slot
* instead.
*/
@property() label = '';
/** The radio groups's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/** The name of the radio group, submitted as a name/value pair with form data. */
@property() name = 'option';
/** The current value of the radio group, submitted as a name/value pair with form data. */
@property({ reflect: true }) value = '';
/** The radio group's size. This size will be applied to all child radios and radio buttons. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/**
* 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 = '';
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return customErrorValidityState;
} else if (isRequiredAndEmpty) {
return valueMissingValidityState;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return this.customValidityMessage;
} else if (isRequiredAndEmpty) {
return this.validationInput.validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.defaultValue = this.value;
}
firstUpdated() {
this.formControlController.updateValidity();
}
private getAllRadios() {
return [...this.querySelectorAll<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')];
}
private handleRadioClick(event: MouseEvent) {
const target = (event.target as HTMLElement).closest<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')!;
const radios = this.getAllRadios();
const oldValue = this.value;
if (target.disabled) {
return;
}
this.value = target.value;
radios.forEach(radio => (radio.checked = radio === target));
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
}
private handleKeyDown(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
return;
}
const radios = this.getAllRadios().filter(radio => !radio.disabled);
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
const oldValue = this.value;
let index = radios.indexOf(checkedRadio) + incr;
if (index < 0) {
index = radios.length - 1;
}
if (index > radios.length - 1) {
index = 0;
}
this.getAllRadios().forEach(radio => {
radio.checked = false;
if (!this.hasButtonGroup) {
radio.tabIndex = -1;
}
});
this.value = radios[index].value;
radios[index].checked = true;
if (!this.hasButtonGroup) {
radios[index].tabIndex = 0;
radios[index].focus();
} else {
radios[index].shadowRoot!.querySelector('button')!.focus();
}
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
event.preventDefault();
}
private handleLabelClick() {
const radios = this.getAllRadios();
const checked = radios.find(radio => radio.checked);
const radioToFocus = checked || radios[0];
// Move focus to the checked radio (or the first one if none are checked) when clicking the label
if (radioToFocus) {
radioToFocus.focus();
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private async syncRadioElements() {
const radios = this.getAllRadios();
await Promise.all(
// Sync the checked state and size
radios.map(async radio => {
await radio.updateComplete;
radio.checked = radio.value === this.value;
radio.size = this.size;
})
);
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot?.querySelector('button');
if (buttonRadio) {
buttonRadio.tabIndex = 0;
}
} else {
radios[0].tabIndex = 0;
}
}
if (this.hasButtonGroup) {
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (buttonGroup) {
buttonGroup.disableRole = true;
}
}
}
private syncRadios() {
if (customElements.get('sl-radio') && customElements.get('sl-radio-button')) {
this.syncRadioElements();
return;
}
if (customElements.get('sl-radio')) {
this.syncRadioElements();
} else {
customElements.whenDefined('sl-radio').then(() => this.syncRadios());
}
if (customElements.get('sl-radio-button')) {
this.syncRadioElements();
} else {
// Rerun this handler when <sl-radio> or <sl-radio-button> is registered
customElements.whenDefined('sl-radio-button').then(() => this.syncRadios());
}
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.formControlController.setValidity(this.validity.valid);
}
@watch('size', { waitUntilFirstUpdate: true })
handleSizeChange() {
this.syncRadios();
}
@watch('value')
handleValueChange() {
if (this.hasUpdated) {
this.updateCheckedRadio();
}
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (isRequiredAndEmpty || hasCustomValidityMessage) {
this.formControlController.emitInvalidEvent();
return false;
}
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(): boolean {
const isValid = this.validity.valid;
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
this.formControlController.setValidity(isValid);
this.validationInput.hidden = true;
clearTimeout(this.validationTimeout);
if (!isValid) {
// Show the browser's constraint validation message
this.validationInput.hidden = false;
this.validationInput.reportValidity();
this.validationTimeout = setTimeout(() => (this.validationInput.hidden = true), 10000) as unknown as number;
}
return isValid;
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message = '') {
this.customValidityMessage = message;
this.errorMessage = message;
this.validationInput.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 defaultSlot = html`
<span @click=${this.handleRadioClick} @keydown=${this.handleKeyDown} role="presentation">
<slot @slotchange=${this.syncRadios}></slot>
</span>
`;
return html`
<fieldset
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--radio-group': true,
'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText
})}
role="radiogroup"
aria-labelledby="label"
aria-describedby="help-text"
aria-errormessage="error-message"
>
<label
part="form-control-label"
id="label"
class="form-control__label"
aria-hidden=${hasLabel ? 'false' : 'true'}
@click=${this.handleLabelClick}
>
<slot name="label">${this.label}</slot>
</label>
<div part="form-control-input" class="form-control-input">
<div class="visually-hidden">
<div id="error-message" aria-live="assertive">${this.errorMessage}</div>
<label class="radio-group__validation">
<input
type="text"
class="radio-group__validation-input"
?required=${this.required}
tabindex="-1"
hidden
@invalid=${this.handleInvalid}
/>
</label>
</div>
${this.hasButtonGroup
? html`
<sl-button-group part="button-group" exportparts="base:button-group__base">
${defaultSlot}
</sl-button-group>
`
: defaultSlot}
</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>
</fieldset>
`;
/* eslint-enable lit-a11y/click-events-have-key-events */
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-radio-group': SlRadioGroup;
}
}
import SlRadioGroup from './radio-group.component.js';
export * from './radio-group.component.js';
export default SlRadioGroup;
SlRadioGroup.define('sl-radio-group');

View File

@@ -0,0 +1,123 @@
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 './radio.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Radios allow the user to select a single option from a group.
* @documentation https://shoelace.style/components/radio
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The radio's label.
*
* @event sl-blur - Emitted when the control loses focus.
* @event sl-focus - Emitted when the control gains focus.
*
* @csspart base - The component's base wrapper.
* @csspart control - The circular container that wraps the radio's checked state.
* @csspart control--checked - The radio control when the radio is checked.
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
* @csspart label - The container that wraps the radio's label.
*/
export default class SlRadio extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
@state() checked = false;
@state() protected hasFocus = false;
/** The radio's value. When selected, the radio group will receive this value. */
@property() value: string;
/**
* The radio's size. When used inside a radio group, the size will be determined by the radio group's size so this
* attribute can typically be omitted.
*/
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Disables the radio. */
@property({ type: Boolean, reflect: true }) disabled = false;
constructor() {
super();
this.addEventListener('blur', this.handleBlur);
this.addEventListener('click', this.handleClick);
this.addEventListener('focus', this.handleFocus);
}
connectedCallback() {
super.connectedCallback();
this.setInitialAttributes();
}
private handleBlur = () => {
this.hasFocus = false;
this.emit('sl-blur');
};
private handleClick = () => {
if (!this.disabled) {
this.checked = true;
}
};
private handleFocus = () => {
this.hasFocus = true;
this.emit('sl-focus');
};
private setInitialAttributes() {
this.setAttribute('role', 'radio');
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
this.setAttribute('tabindex', this.checked ? '0' : '-1');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
render() {
return html`
<span
part="base"
class=${classMap({
radio: true,
'radio--checked': this.checked,
'radio--disabled': this.disabled,
'radio--focused': this.hasFocus,
'radio--small': this.size === 'small',
'radio--medium': this.size === 'medium',
'radio--large': this.size === 'large'
})}
>
<span part="${`control${this.checked ? ' control--checked' : ''}`}" class="radio__control">
${this.checked
? html` <sl-icon part="checked-icon" class="radio__checked-icon" library="system" name="radio"></sl-icon> `
: ''}
</span>
<slot part="label" class="radio__label"></slot>
</span>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-radio': SlRadio;
}
}

View File

@@ -1,123 +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 './radio.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Radios allow the user to select a single option from a group.
* @documentation https://shoelace.style/components/radio
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @slot - The radio's label.
*
* @event sl-blur - Emitted when the control loses focus.
* @event sl-focus - Emitted when the control gains focus.
*
* @csspart base - The component's base wrapper.
* @csspart control - The circular container that wraps the radio's checked state.
* @csspart control--checked - The radio control when the radio is checked.
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
* @csspart label - The container that wraps the radio's label.
*/
@customElement('sl-radio')
export default class SlRadio extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@state() checked = false;
@state() protected hasFocus = false;
/** The radio's value. When selected, the radio group will receive this value. */
@property() value: string;
/**
* The radio's size. When used inside a radio group, the size will be determined by the radio group's size so this
* attribute can typically be omitted.
*/
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Disables the radio. */
@property({ type: Boolean, reflect: true }) disabled = false;
constructor() {
super();
this.addEventListener('blur', this.handleBlur);
this.addEventListener('click', this.handleClick);
this.addEventListener('focus', this.handleFocus);
}
connectedCallback() {
super.connectedCallback();
this.setInitialAttributes();
}
private handleBlur = () => {
this.hasFocus = false;
this.emit('sl-blur');
};
private handleClick = () => {
if (!this.disabled) {
this.checked = true;
}
};
private handleFocus = () => {
this.hasFocus = true;
this.emit('sl-focus');
};
private setInitialAttributes() {
this.setAttribute('role', 'radio');
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
this.setAttribute('tabindex', this.checked ? '0' : '-1');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
render() {
return html`
<span
part="base"
class=${classMap({
radio: true,
'radio--checked': this.checked,
'radio--disabled': this.disabled,
'radio--focused': this.hasFocus,
'radio--small': this.size === 'small',
'radio--medium': this.size === 'medium',
'radio--large': this.size === 'large'
})}
>
<span part="${`control${this.checked ? ' control--checked' : ''}`}" class="radio__control">
${this.checked
? html` <sl-icon part="checked-icon" class="radio__checked-icon" library="system" name="radio"></sl-icon> `
: ''}
</span>
<slot part="label" class="radio__label"></slot>
</span>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-radio': SlRadio;
}
}
import SlRadio from './radio.component.js';
export * from './radio.component.js';
export default SlRadio;
SlRadio.define('sl-radio');

View File

@@ -0,0 +1,362 @@
import { classMap } from 'lit/directives/class-map.js';
import { defaultValue } from '../../internal/default-value.js';
import { eventOptions, property, query, state } from 'lit/decorators.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 './range.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
/**
* @summary Ranges allow the user to select a single value within a given range using a slider.
* @documentation https://shoelace.style/components/range
* @status stable
* @since 2.0
*
* @slot label - The range's label. Alternatively, you can use the `label` attribute.
* @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-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 range's wrapper.
* @csspart form-control-help-text - The help text's wrapper.
* @csspart base - The component's base wrapper.
* @csspart input - The internal `<input>` element.
* @csspart tooltip - The range's tooltip.
*
* @cssproperty --thumb-size - The size of the thumb.
* @cssproperty --tooltip-offset - The vertical distance the tooltip is offset from the track.
* @cssproperty --track-color-active - The color of the portion of the track that represents the current value.
* @cssproperty --track-color-inactive - The of the portion of the track that represents the remaining value.
* @cssproperty --track-height - The height of the track.
* @cssproperty --track-active-offset - The point of origin of the active track.
*/
export default class SlRange extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
private resizeObserver: ResizeObserver;
@query('.range__control') input: HTMLInputElement;
@query('.range__tooltip') output: HTMLOutputElement | null;
@state() private hasFocus = false;
@state() private hasTooltip = false;
@property() title = ''; // make reactive to pass through
/** The name of the range, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the range, submitted as a name/value pair with form data. */
@property({ type: Number }) value = 0;
/** The range's label. If you need to display HTML, use the `label` slot instead. */
@property() label = '';
/** The range's help text. If you need to display HTML, use the help-text slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/** Disables the range. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** The minimum acceptable value of the range. */
@property({ type: Number }) min = 0;
/** The maximum acceptable value of the range. */
@property({ type: Number }) max = 100;
/** The interval at which the range will increase and decrease. */
@property({ type: Number }) step = 1;
/** The preferred placement of the range's tooltip. */
@property() tooltip: 'top' | 'bottom' | 'none' = 'top';
/**
* A function used to format the tooltip's value. The range's value is passed as the first and only argument. The
* function should return a string to display in the tooltip.
*/
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
/**
* 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 = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = 0;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());
if (this.value < this.min) {
this.value = this.min;
}
if (this.value > this.max) {
this.value = this.max;
}
this.updateComplete.then(() => {
this.syncRange();
this.resizeObserver.observe(this.input);
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver.unobserve(this.input);
}
private handleChange() {
this.emit('sl-change');
}
private handleInput() {
this.value = parseFloat(this.input.value);
this.emit('sl-input');
this.syncRange();
}
private handleBlur() {
this.hasFocus = false;
this.hasTooltip = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.hasTooltip = true;
this.emit('sl-focus');
}
@eventOptions({ passive: true })
private handleThumbDragStart() {
this.hasTooltip = true;
}
private handleThumbDragEnd() {
this.hasTooltip = false;
}
private syncProgress(percent: number) {
this.input.style.setProperty('--percent', `${percent * 100}%`);
}
private syncTooltip(percent: number) {
if (this.output !== null) {
const inputWidth = this.input.offsetWidth;
const tooltipWidth = this.output.offsetWidth;
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
const isRtl = this.localize.dir() === 'rtl';
const percentAsWidth = inputWidth * percent;
// The calculations are used to "guess" where the thumb is located. Since we're using the native range control
// under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two
// off depending on the size of the control, thumb, and tooltip dimensions.
if (isRtl) {
const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`;
this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`;
} else {
const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`;
this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`;
}
}
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.formControlController.updateValidity();
// The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to
// min, max, and step properly
this.input.value = this.value.toString();
this.value = parseFloat(this.input.value);
this.syncRange();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch('hasTooltip', { waitUntilFirstUpdate: true })
syncRange() {
const percent = Math.max(0, (this.value - this.min) / (this.max - this.min));
this.syncProgress(percent);
if (this.tooltip !== 'none') {
this.syncTooltip(percent);
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
/** Sets focus on the range. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the range. */
blur() {
this.input.blur();
}
/** Increments the value of the range by the value of the step attribute. */
stepUp() {
this.input.stepUp();
if (this.value !== Number(this.input.value)) {
this.value = Number(this.input.value);
}
}
/** Decrements the value of the range by the value of the step attribute. */
stepDown() {
this.input.stepDown();
if (this.value !== Number(this.input.value)) {
this.value = Number(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;
// NOTE - always bind value after min/max, otherwise it will be clamped
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--medium': true, // range only has one size
'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({
range: true,
'range--disabled': this.disabled,
'range--focused': this.hasFocus,
'range--rtl': this.localize.dir() === 'rtl',
'range--tooltip-visible': this.hasTooltip,
'range--tooltip-top': this.tooltip === 'top',
'range--tooltip-bottom': this.tooltip === 'bottom'
})}
@mousedown=${this.handleThumbDragStart}
@mouseup=${this.handleThumbDragEnd}
@touchstart=${this.handleThumbDragStart}
@touchend=${this.handleThumbDragEnd}
>
<input
part="input"
id="input"
class="range__control"
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
type="range"
name=${ifDefined(this.name)}
?disabled=${this.disabled}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${live(this.value.toString())}
aria-describedby="help-text"
@change=${this.handleChange}
@focus=${this.handleFocus}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
/>
${this.tooltip !== 'none' && !this.disabled
? html`
<output part="tooltip" class="range__tooltip">
${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value}
</output>
`
: ''}
</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-range': SlRange;
}
}

View File

@@ -1,363 +1,4 @@
import { classMap } from 'lit/directives/class-map.js';
import { customElement, eventOptions, 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 './range.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
/**
* @summary Ranges allow the user to select a single value within a given range using a slider.
* @documentation https://shoelace.style/components/range
* @status stable
* @since 2.0
*
* @slot label - The range's label. Alternatively, you can use the `label` attribute.
* @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-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 range's wrapper.
* @csspart form-control-help-text - The help text's wrapper.
* @csspart base - The component's base wrapper.
* @csspart input - The internal `<input>` element.
* @csspart tooltip - The range's tooltip.
*
* @cssproperty --thumb-size - The size of the thumb.
* @cssproperty --tooltip-offset - The vertical distance the tooltip is offset from the track.
* @cssproperty --track-color-active - The color of the portion of the track that represents the current value.
* @cssproperty --track-color-inactive - The of the portion of the track that represents the remaining value.
* @cssproperty --track-height - The height of the track.
* @cssproperty --track-active-offset - The point of origin of the active track.
*/
@customElement('sl-range')
export default class SlRange extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
private resizeObserver: ResizeObserver;
@query('.range__control') input: HTMLInputElement;
@query('.range__tooltip') output: HTMLOutputElement | null;
@state() private hasFocus = false;
@state() private hasTooltip = false;
@property() title = ''; // make reactive to pass through
/** The name of the range, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the range, submitted as a name/value pair with form data. */
@property({ type: Number }) value = 0;
/** The range's label. If you need to display HTML, use the `label` slot instead. */
@property() label = '';
/** The range's help text. If you need to display HTML, use the help-text slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/** Disables the range. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** The minimum acceptable value of the range. */
@property({ type: Number }) min = 0;
/** The maximum acceptable value of the range. */
@property({ type: Number }) max = 100;
/** The interval at which the range will increase and decrease. */
@property({ type: Number }) step = 1;
/** The preferred placement of the range's tooltip. */
@property() tooltip: 'top' | 'bottom' | 'none' = 'top';
/**
* A function used to format the tooltip's value. The range's value is passed as the first and only argument. The
* function should return a string to display in the tooltip.
*/
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
/**
* 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 = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = 0;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());
if (this.value < this.min) {
this.value = this.min;
}
if (this.value > this.max) {
this.value = this.max;
}
this.updateComplete.then(() => {
this.syncRange();
this.resizeObserver.observe(this.input);
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver.unobserve(this.input);
}
private handleChange() {
this.emit('sl-change');
}
private handleInput() {
this.value = parseFloat(this.input.value);
this.emit('sl-input');
this.syncRange();
}
private handleBlur() {
this.hasFocus = false;
this.hasTooltip = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.hasTooltip = true;
this.emit('sl-focus');
}
@eventOptions({ passive: true })
private handleThumbDragStart() {
this.hasTooltip = true;
}
private handleThumbDragEnd() {
this.hasTooltip = false;
}
private syncProgress(percent: number) {
this.input.style.setProperty('--percent', `${percent * 100}%`);
}
private syncTooltip(percent: number) {
if (this.output !== null) {
const inputWidth = this.input.offsetWidth;
const tooltipWidth = this.output.offsetWidth;
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
const isRtl = this.localize.dir() === 'rtl';
const percentAsWidth = inputWidth * percent;
// The calculations are used to "guess" where the thumb is located. Since we're using the native range control
// under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two
// off depending on the size of the control, thumb, and tooltip dimensions.
if (isRtl) {
const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`;
this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`;
} else {
const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`;
this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`;
}
}
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.formControlController.updateValidity();
// The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to
// min, max, and step properly
this.input.value = this.value.toString();
this.value = parseFloat(this.input.value);
this.syncRange();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch('hasTooltip', { waitUntilFirstUpdate: true })
syncRange() {
const percent = Math.max(0, (this.value - this.min) / (this.max - this.min));
this.syncProgress(percent);
if (this.tooltip !== 'none') {
this.syncTooltip(percent);
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
/** Sets focus on the range. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the range. */
blur() {
this.input.blur();
}
/** Increments the value of the range by the value of the step attribute. */
stepUp() {
this.input.stepUp();
if (this.value !== Number(this.input.value)) {
this.value = Number(this.input.value);
}
}
/** Decrements the value of the range by the value of the step attribute. */
stepDown() {
this.input.stepDown();
if (this.value !== Number(this.input.value)) {
this.value = Number(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;
// NOTE - always bind value after min/max, otherwise it will be clamped
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--medium': true, // range only has one size
'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({
range: true,
'range--disabled': this.disabled,
'range--focused': this.hasFocus,
'range--rtl': this.localize.dir() === 'rtl',
'range--tooltip-visible': this.hasTooltip,
'range--tooltip-top': this.tooltip === 'top',
'range--tooltip-bottom': this.tooltip === 'bottom'
})}
@mousedown=${this.handleThumbDragStart}
@mouseup=${this.handleThumbDragEnd}
@touchstart=${this.handleThumbDragStart}
@touchend=${this.handleThumbDragEnd}
>
<input
part="input"
id="input"
class="range__control"
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
type="range"
name=${ifDefined(this.name)}
?disabled=${this.disabled}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${live(this.value.toString())}
aria-describedby="help-text"
@change=${this.handleChange}
@focus=${this.handleFocus}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
/>
${this.tooltip !== 'none' && !this.disabled
? html`
<output part="tooltip" class="range__tooltip">
${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value}
</output>
`
: ''}
</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-range': SlRange;
}
}
import SlRange from './range.component.js';
export * from './range.component.js';
export default SlRange;
SlRange.define('sl-range');

View File

@@ -0,0 +1,315 @@
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import styles from './rating.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Ratings give users a way to quickly view and provide feedback.
* @documentation https://shoelace.style/components/rating
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @event sl-change - Emitted when the rating's value changes.
* @event {{ phase: 'start' | 'move' | 'end', value: number }} sl-hover - Emitted when the user hovers over a value. The
* `phase` property indicates when hovering starts, moves to a new value, or ends. The `value` property tells what the
* rating's value would be if the user were to commit to the hovered value.
*
* @csspart base - The component's base wrapper.
*
* @cssproperty --symbol-color - The inactive color for symbols.
* @cssproperty --symbol-color-active - The active color for symbols.
* @cssproperty --symbol-size - The size of symbols.
* @cssproperty --symbol-spacing - The spacing to use around symbols.
*/
export default class SlRating extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
private readonly localize = new LocalizeController(this);
@query('.rating') rating: HTMLElement;
@state() private hoverValue = 0;
@state() private isHovering = false;
/** A label that describes the rating to assistive devices. */
@property() label = '';
/** The current rating. */
@property({ type: Number }) value = 0;
/** The highest rating to show. */
@property({ type: Number }) max = 5;
/**
* The precision at which the rating will increase and decrease. For example, to allow half-star ratings, set this
* attribute to `0.5`.
*/
@property({ type: Number }) precision = 1;
/** Makes the rating readonly. */
@property({ type: Boolean, reflect: true }) readonly = false;
/** Disables the rating. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* A function that customizes the symbol to be rendered. The first and only argument is the rating's current value.
* The function should return a string containing trusted HTML of the symbol to render at the specified value. Works
* well with `<sl-icon>` elements.
*/
@property() getSymbol: (value: number) => string = () => '<sl-icon name="star-fill" library="system"></sl-icon>';
private getValueFromMousePosition(event: MouseEvent) {
return this.getValueFromXCoordinate(event.clientX);
}
private getValueFromTouchPosition(event: TouchEvent) {
return this.getValueFromXCoordinate(event.touches[0].clientX);
}
private getValueFromXCoordinate(coordinate: number) {
const isRtl = this.localize.dir() === 'rtl';
const { left, right, width } = this.rating.getBoundingClientRect();
const value = isRtl
? this.roundToPrecision(((right - coordinate) / width) * this.max, this.precision)
: this.roundToPrecision(((coordinate - left) / width) * this.max, this.precision);
return clamp(value, 0, this.max);
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
return;
}
this.setValue(this.getValueFromMousePosition(event));
this.emit('sl-change');
}
private setValue(newValue: number) {
if (this.disabled || this.readonly) {
return;
}
this.value = newValue === this.value ? 0 : newValue;
this.isHovering = false;
}
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';
const oldValue = this.value;
if (this.disabled || this.readonly) {
return;
}
if (event.key === 'ArrowDown' || (isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
const decrement = event.shiftKey ? 1 : this.precision;
this.value = Math.max(0, this.value - decrement);
event.preventDefault();
}
if (event.key === 'ArrowUp' || (isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
const increment = event.shiftKey ? 1 : this.precision;
this.value = Math.min(this.max, this.value + increment);
event.preventDefault();
}
if (event.key === 'Home') {
this.value = 0;
event.preventDefault();
}
if (event.key === 'End') {
this.value = this.max;
event.preventDefault();
}
if (this.value !== oldValue) {
this.emit('sl-change');
}
}
private handleMouseEnter(event: MouseEvent) {
this.isHovering = true;
this.hoverValue = this.getValueFromMousePosition(event);
}
private handleMouseMove(event: MouseEvent) {
this.hoverValue = this.getValueFromMousePosition(event);
}
private handleMouseLeave() {
this.isHovering = false;
}
private handleTouchStart(event: TouchEvent) {
this.isHovering = true;
this.hoverValue = this.getValueFromTouchPosition(event);
// Prevent scrolling when touch is initiated
event.preventDefault();
}
@eventOptions({ passive: true })
private handleTouchMove(event: TouchEvent) {
this.hoverValue = this.getValueFromTouchPosition(event);
}
private handleTouchEnd(event: TouchEvent) {
this.isHovering = false;
this.setValue(this.hoverValue);
this.emit('sl-change');
// Prevent click on mobile devices
event.preventDefault();
}
private roundToPrecision(numberToRound: number, precision = 0.5) {
const multiplier = 1 / precision;
return Math.ceil(numberToRound * multiplier) / multiplier;
}
@watch('hoverValue')
handleHoverValueChange() {
this.emit('sl-hover', {
detail: {
phase: 'move',
value: this.hoverValue
}
});
}
@watch('isHovering')
handleIsHoveringChange() {
this.emit('sl-hover', {
detail: {
phase: this.isHovering ? 'start' : 'end',
value: this.hoverValue
}
});
}
/** Sets focus on the rating. */
focus(options?: FocusOptions) {
this.rating.focus(options);
}
/** Removes focus from the rating. */
blur() {
this.rating.blur();
}
render() {
const isRtl = this.localize.dir() === 'rtl';
const counter = Array.from(Array(this.max).keys());
let displayValue = 0;
if (this.disabled || this.readonly) {
displayValue = this.value;
} else {
displayValue = this.isHovering ? this.hoverValue : this.value;
}
return html`
<div
part="base"
class=${classMap({
rating: true,
'rating--readonly': this.readonly,
'rating--disabled': this.disabled,
'rating--rtl': isRtl
})}
role="slider"
aria-label=${this.label}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-readonly=${this.readonly ? 'true' : 'false'}
aria-valuenow=${this.value}
aria-valuemin=${0}
aria-valuemax=${this.max}
tabindex=${this.disabled ? '-1' : '0'}
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mouseenter=${this.handleMouseEnter}
@touchstart=${this.handleTouchStart}
@mouseleave=${this.handleMouseLeave}
@touchend=${this.handleTouchEnd}
@mousemove=${this.handleMouseMove}
@touchmove=${this.handleTouchMove}
>
<span class="rating__symbols">
${counter.map(index => {
if (displayValue > index && displayValue < index + 1) {
// Users can click the current value to clear the rating. When this happens, we set this.isHovering to
// false to prevent the hover state from confusing them as they move the mouse out of the control. This
// extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol.
return html`
<span
class=${classMap({
rating__symbol: true,
'rating__partial-symbol-container': true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
})}
role="presentation"
@mouseenter=${this.handleMouseEnter}
>
<div
style=${styleMap({
clipPath: isRtl
? `inset(0 ${(displayValue - index) * 100}% 0 0)`
: `inset(0 0 0 ${(displayValue - index) * 100}%)`
})}
>
${unsafeHTML(this.getSymbol(index + 1))}
</div>
<div
class="rating__partial--filled"
style=${styleMap({
clipPath: isRtl
? `inset(0 0 0 ${100 - (displayValue - index) * 100}%)`
: `inset(0 ${100 - (displayValue - index) * 100}% 0 0)`
})}
>
${unsafeHTML(this.getSymbol(index + 1))}
</div>
</span>
`;
}
return html`
<span
class=${classMap({
rating__symbol: true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1,
'rating__symbol--active': displayValue >= index + 1
})}
role="presentation"
@mouseenter=${this.handleMouseEnter}
>
${unsafeHTML(this.getSymbol(index + 1))}
</span>
`;
})}
</span>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-rating': SlRating;
}
}

View File

@@ -1,315 +1,4 @@
import '../icon/icon.js';
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './rating.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Ratings give users a way to quickly view and provide feedback.
* @documentation https://shoelace.style/components/rating
* @status stable
* @since 2.0
*
* @dependency sl-icon
*
* @event sl-change - Emitted when the rating's value changes.
* @event {{ phase: 'start' | 'move' | 'end', value: number }} sl-hover - Emitted when the user hovers over a value. The
* `phase` property indicates when hovering starts, moves to a new value, or ends. The `value` property tells what the
* rating's value would be if the user were to commit to the hovered value.
*
* @csspart base - The component's base wrapper.
*
* @cssproperty --symbol-color - The inactive color for symbols.
* @cssproperty --symbol-color-active - The active color for symbols.
* @cssproperty --symbol-size - The size of symbols.
* @cssproperty --symbol-spacing - The spacing to use around symbols.
*/
@customElement('sl-rating')
export default class SlRating extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.rating') rating: HTMLElement;
@state() private hoverValue = 0;
@state() private isHovering = false;
/** A label that describes the rating to assistive devices. */
@property() label = '';
/** The current rating. */
@property({ type: Number }) value = 0;
/** The highest rating to show. */
@property({ type: Number }) max = 5;
/**
* The precision at which the rating will increase and decrease. For example, to allow half-star ratings, set this
* attribute to `0.5`.
*/
@property({ type: Number }) precision = 1;
/** Makes the rating readonly. */
@property({ type: Boolean, reflect: true }) readonly = false;
/** Disables the rating. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* A function that customizes the symbol to be rendered. The first and only argument is the rating's current value.
* The function should return a string containing trusted HTML of the symbol to render at the specified value. Works
* well with `<sl-icon>` elements.
*/
@property() getSymbol: (value: number) => string = () => '<sl-icon name="star-fill" library="system"></sl-icon>';
private getValueFromMousePosition(event: MouseEvent) {
return this.getValueFromXCoordinate(event.clientX);
}
private getValueFromTouchPosition(event: TouchEvent) {
return this.getValueFromXCoordinate(event.touches[0].clientX);
}
private getValueFromXCoordinate(coordinate: number) {
const isRtl = this.localize.dir() === 'rtl';
const { left, right, width } = this.rating.getBoundingClientRect();
const value = isRtl
? this.roundToPrecision(((right - coordinate) / width) * this.max, this.precision)
: this.roundToPrecision(((coordinate - left) / width) * this.max, this.precision);
return clamp(value, 0, this.max);
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
return;
}
this.setValue(this.getValueFromMousePosition(event));
this.emit('sl-change');
}
private setValue(newValue: number) {
if (this.disabled || this.readonly) {
return;
}
this.value = newValue === this.value ? 0 : newValue;
this.isHovering = false;
}
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';
const oldValue = this.value;
if (this.disabled || this.readonly) {
return;
}
if (event.key === 'ArrowDown' || (isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
const decrement = event.shiftKey ? 1 : this.precision;
this.value = Math.max(0, this.value - decrement);
event.preventDefault();
}
if (event.key === 'ArrowUp' || (isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
const increment = event.shiftKey ? 1 : this.precision;
this.value = Math.min(this.max, this.value + increment);
event.preventDefault();
}
if (event.key === 'Home') {
this.value = 0;
event.preventDefault();
}
if (event.key === 'End') {
this.value = this.max;
event.preventDefault();
}
if (this.value !== oldValue) {
this.emit('sl-change');
}
}
private handleMouseEnter(event: MouseEvent) {
this.isHovering = true;
this.hoverValue = this.getValueFromMousePosition(event);
}
private handleMouseMove(event: MouseEvent) {
this.hoverValue = this.getValueFromMousePosition(event);
}
private handleMouseLeave() {
this.isHovering = false;
}
private handleTouchStart(event: TouchEvent) {
this.isHovering = true;
this.hoverValue = this.getValueFromTouchPosition(event);
// Prevent scrolling when touch is initiated
event.preventDefault();
}
@eventOptions({ passive: true })
private handleTouchMove(event: TouchEvent) {
this.hoverValue = this.getValueFromTouchPosition(event);
}
private handleTouchEnd(event: TouchEvent) {
this.isHovering = false;
this.setValue(this.hoverValue);
this.emit('sl-change');
// Prevent click on mobile devices
event.preventDefault();
}
private roundToPrecision(numberToRound: number, precision = 0.5) {
const multiplier = 1 / precision;
return Math.ceil(numberToRound * multiplier) / multiplier;
}
@watch('hoverValue')
handleHoverValueChange() {
this.emit('sl-hover', {
detail: {
phase: 'move',
value: this.hoverValue
}
});
}
@watch('isHovering')
handleIsHoveringChange() {
this.emit('sl-hover', {
detail: {
phase: this.isHovering ? 'start' : 'end',
value: this.hoverValue
}
});
}
/** Sets focus on the rating. */
focus(options?: FocusOptions) {
this.rating.focus(options);
}
/** Removes focus from the rating. */
blur() {
this.rating.blur();
}
render() {
const isRtl = this.localize.dir() === 'rtl';
const counter = Array.from(Array(this.max).keys());
let displayValue = 0;
if (this.disabled || this.readonly) {
displayValue = this.value;
} else {
displayValue = this.isHovering ? this.hoverValue : this.value;
}
return html`
<div
part="base"
class=${classMap({
rating: true,
'rating--readonly': this.readonly,
'rating--disabled': this.disabled,
'rating--rtl': isRtl
})}
role="slider"
aria-label=${this.label}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-readonly=${this.readonly ? 'true' : 'false'}
aria-valuenow=${this.value}
aria-valuemin=${0}
aria-valuemax=${this.max}
tabindex=${this.disabled ? '-1' : '0'}
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mouseenter=${this.handleMouseEnter}
@touchstart=${this.handleTouchStart}
@mouseleave=${this.handleMouseLeave}
@touchend=${this.handleTouchEnd}
@mousemove=${this.handleMouseMove}
@touchmove=${this.handleTouchMove}
>
<span class="rating__symbols">
${counter.map(index => {
if (displayValue > index && displayValue < index + 1) {
// Users can click the current value to clear the rating. When this happens, we set this.isHovering to
// false to prevent the hover state from confusing them as they move the mouse out of the control. This
// extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol.
return html`
<span
class=${classMap({
rating__symbol: true,
'rating__partial-symbol-container': true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
})}
role="presentation"
@mouseenter=${this.handleMouseEnter}
>
<div
style=${styleMap({
clipPath: isRtl
? `inset(0 ${(displayValue - index) * 100}% 0 0)`
: `inset(0 0 0 ${(displayValue - index) * 100}%)`
})}
>
${unsafeHTML(this.getSymbol(index + 1))}
</div>
<div
class="rating__partial--filled"
style=${styleMap({
clipPath: isRtl
? `inset(0 0 0 ${100 - (displayValue - index) * 100}%)`
: `inset(0 ${100 - (displayValue - index) * 100}% 0 0)`
})}
>
${unsafeHTML(this.getSymbol(index + 1))}
</div>
</span>
`;
}
return html`
<span
class=${classMap({
rating__symbol: true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1,
'rating__symbol--active': displayValue >= index + 1
})}
role="presentation"
@mouseenter=${this.handleMouseEnter}
>
${unsafeHTML(this.getSymbol(index + 1))}
</span>
`;
})}
</span>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-rating': SlRating;
}
}
import SlRating from './rating.component.js';
export * from './rating.component.js';
export default SlRating;
SlRating.define('sl-rating');

View File

@@ -0,0 +1,127 @@
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, state } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
interface UnitConfig {
max: number;
value: number;
unit: Intl.RelativeTimeFormatUnit;
}
const availableUnits: UnitConfig[] = [
{ max: 2760000, value: 60000, unit: 'minute' }, // max 46 minutes
{ max: 72000000, value: 3600000, unit: 'hour' }, // max 20 hours
{ max: 518400000, value: 86400000, unit: 'day' }, // max 6 days
{ max: 2419200000, value: 604800000, unit: 'week' }, // max 28 days
{ max: 28512000000, value: 2592000000, unit: 'month' }, // max 11 months
{ max: Infinity, value: 31536000000, unit: 'year' }
];
/**
* @summary Outputs a localized time phrase relative to the current date and time.
* @documentation https://shoelace.style/components/relative-time
* @status stable
* @since 2.0
*/
export default class SlRelativeTime extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
private updateTimeout: number;
@state() private isoTime = '';
@state() private relativeTime = '';
@state() private titleTime = '';
/**
* The date from which to calculate time from. 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 formatting style to use. */
@property() format: 'long' | 'short' | 'narrow' = 'long';
/**
* When `auto`, values such as "yesterday" and "tomorrow" will be shown when possible. When `always`, values such as
* "1 day ago" and "in 1 day" will be shown.
*/
@property() numeric: 'always' | 'auto' = 'auto';
/** Keep the displayed value up to date as time passes. */
@property({ type: Boolean }) sync = false;
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this.updateTimeout);
}
render() {
const now = new Date();
const then = new Date(this.date);
// Check for an invalid date
if (isNaN(then.getMilliseconds())) {
this.relativeTime = '';
this.isoTime = '';
return '';
}
const diff = then.getTime() - now.getTime();
const { unit, value } = availableUnits.find(singleUnit => Math.abs(diff) < singleUnit.max)!;
this.isoTime = then.toISOString();
this.titleTime = this.localize.date(then, {
month: 'long',
year: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
});
this.relativeTime = this.localize.relativeTime(Math.round(diff / value), unit, {
numeric: this.numeric,
style: this.format
});
// If sync is enabled, update as time passes
clearTimeout(this.updateTimeout);
if (this.sync) {
let nextInterval: number;
// NOTE: this could be optimized to determine when the next update should actually occur, but the size and cost of
// that logic probably isn't worth the performance benefit
if (unit === 'minute') {
nextInterval = getTimeUntilNextUnit('second');
} else if (unit === 'hour') {
nextInterval = getTimeUntilNextUnit('minute');
} else if (unit === 'day') {
nextInterval = getTimeUntilNextUnit('hour');
} else {
// Cap updates at once per day. It's unlikely a user will reach this value, plus setTimeout has a limit on the
// value it can accept. https://stackoverflow.com/a/3468650/567486
nextInterval = getTimeUntilNextUnit('day'); // next day
}
this.updateTimeout = window.setTimeout(() => this.requestUpdate(), nextInterval);
}
return html` <time datetime=${this.isoTime} title=${this.titleTime}>${this.relativeTime}</time> `;
}
}
// Calculates the number of milliseconds until the next respective unit changes. This ensures that all components
// update at the same time which is less distracting than updating independently.
function getTimeUntilNextUnit(unit: 'second' | 'minute' | 'hour' | 'day') {
const units = { second: 1000, minute: 60000, hour: 3600000, day: 86400000 };
const value = units[unit];
return value - (Date.now() % value);
}
declare global {
interface HTMLElementTagNameMap {
'sl-relative-time': SlRelativeTime;
}
}

View File

@@ -1,128 +1,4 @@
import { customElement, property, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
interface UnitConfig {
max: number;
value: number;
unit: Intl.RelativeTimeFormatUnit;
}
const availableUnits: UnitConfig[] = [
{ max: 2760000, value: 60000, unit: 'minute' }, // max 46 minutes
{ max: 72000000, value: 3600000, unit: 'hour' }, // max 20 hours
{ max: 518400000, value: 86400000, unit: 'day' }, // max 6 days
{ max: 2419200000, value: 604800000, unit: 'week' }, // max 28 days
{ max: 28512000000, value: 2592000000, unit: 'month' }, // max 11 months
{ max: Infinity, value: 31536000000, unit: 'year' }
];
/**
* @summary Outputs a localized time phrase relative to the current date and time.
* @documentation https://shoelace.style/components/relative-time
* @status stable
* @since 2.0
*/
@customElement('sl-relative-time')
export default class SlRelativeTime extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
private updateTimeout: number;
@state() private isoTime = '';
@state() private relativeTime = '';
@state() private titleTime = '';
/**
* The date from which to calculate time from. 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 formatting style to use. */
@property() format: 'long' | 'short' | 'narrow' = 'long';
/**
* When `auto`, values such as "yesterday" and "tomorrow" will be shown when possible. When `always`, values such as
* "1 day ago" and "in 1 day" will be shown.
*/
@property() numeric: 'always' | 'auto' = 'auto';
/** Keep the displayed value up to date as time passes. */
@property({ type: Boolean }) sync = false;
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this.updateTimeout);
}
render() {
const now = new Date();
const then = new Date(this.date);
// Check for an invalid date
if (isNaN(then.getMilliseconds())) {
this.relativeTime = '';
this.isoTime = '';
return '';
}
const diff = then.getTime() - now.getTime();
const { unit, value } = availableUnits.find(singleUnit => Math.abs(diff) < singleUnit.max)!;
this.isoTime = then.toISOString();
this.titleTime = this.localize.date(then, {
month: 'long',
year: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
});
this.relativeTime = this.localize.relativeTime(Math.round(diff / value), unit, {
numeric: this.numeric,
style: this.format
});
// If sync is enabled, update as time passes
clearTimeout(this.updateTimeout);
if (this.sync) {
let nextInterval: number;
// NOTE: this could be optimized to determine when the next update should actually occur, but the size and cost of
// that logic probably isn't worth the performance benefit
if (unit === 'minute') {
nextInterval = getTimeUntilNextUnit('second');
} else if (unit === 'hour') {
nextInterval = getTimeUntilNextUnit('minute');
} else if (unit === 'day') {
nextInterval = getTimeUntilNextUnit('hour');
} else {
// Cap updates at once per day. It's unlikely a user will reach this value, plus setTimeout has a limit on the
// value it can accept. https://stackoverflow.com/a/3468650/567486
nextInterval = getTimeUntilNextUnit('day'); // next day
}
this.updateTimeout = window.setTimeout(() => this.requestUpdate(), nextInterval);
}
return html` <time datetime=${this.isoTime} title=${this.titleTime}>${this.relativeTime}</time> `;
}
}
// Calculates the number of milliseconds until the next respective unit changes. This ensures that all components
// update at the same time which is less distracting than updating independently.
function getTimeUntilNextUnit(unit: 'second' | 'minute' | 'hour' | 'day') {
const units = { second: 1000, minute: 60000, hour: 3600000, day: 86400000 };
const value = units[unit];
return value - (Date.now() % value);
}
declare global {
interface HTMLElementTagNameMap {
'sl-relative-time': SlRelativeTime;
}
}
import SlRelativeTime from './relative-time.component.js';
export * from './relative-time.component.js';
export default SlRelativeTime;
SlRelativeTime.define('sl-relative-time');

View File

@@ -0,0 +1,89 @@
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 './resize-observer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary The Resize Observer component offers a thin, declarative interface to the [`ResizeObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
* @documentation https://shoelace.style/components/resize-observer
* @status stable
* @since 2.0
*
* @slot - One or more elements to watch for resizing.
*
* @event {{ entries: ResizeObserverEntry[] }} sl-resize - Emitted when the element is resized.
*/
export default class SlResizeObserver extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private resizeObserver: ResizeObserver;
private observedElements: HTMLElement[] = [];
/** Disables the observer. */
@property({ type: Boolean, reflect: true }) disabled = false;
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
this.emit('sl-resize', { detail: { entries } });
});
if (!this.disabled) {
this.startObserver();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopObserver();
}
private handleSlotChange() {
if (!this.disabled) {
this.startObserver();
}
}
private startObserver() {
const slot = this.shadowRoot!.querySelector('slot');
if (slot !== null) {
const elements = slot.assignedElements({ flatten: true }) as HTMLElement[];
// Unwatch previous elements
this.observedElements.forEach(el => this.resizeObserver.unobserve(el));
this.observedElements = [];
// Watch new elements
elements.forEach(el => {
this.resizeObserver.observe(el);
this.observedElements.push(el);
});
}
}
private stopObserver() {
this.resizeObserver.disconnect();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
if (this.disabled) {
this.stopObserver();
} else {
this.startObserver();
}
}
render() {
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-resize-observer': SlResizeObserver;
}
}

View File

@@ -1,90 +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 './resize-observer.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary The Resize Observer component offers a thin, declarative interface to the [`ResizeObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
* @documentation https://shoelace.style/components/resize-observer
* @status stable
* @since 2.0
*
* @slot - One or more elements to watch for resizing.
*
* @event {{ entries: ResizeObserverEntry[] }} sl-resize - Emitted when the element is resized.
*/
@customElement('sl-resize-observer')
export default class SlResizeObserver extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private resizeObserver: ResizeObserver;
private observedElements: HTMLElement[] = [];
/** Disables the observer. */
@property({ type: Boolean, reflect: true }) disabled = false;
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
this.emit('sl-resize', { detail: { entries } });
});
if (!this.disabled) {
this.startObserver();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopObserver();
}
private handleSlotChange() {
if (!this.disabled) {
this.startObserver();
}
}
private startObserver() {
const slot = this.shadowRoot!.querySelector('slot');
if (slot !== null) {
const elements = slot.assignedElements({ flatten: true }) as HTMLElement[];
// Unwatch previous elements
this.observedElements.forEach(el => this.resizeObserver.unobserve(el));
this.observedElements = [];
// Watch new elements
elements.forEach(el => {
this.resizeObserver.observe(el);
this.observedElements.push(el);
});
}
}
private stopObserver() {
this.resizeObserver.disconnect();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
if (this.disabled) {
this.stopObserver();
} else {
this.startObserver();
}
}
render() {
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-resize-observer': SlResizeObserver;
}
}
import SlResizeObserver from './resize-observer.component.js';
export * from './resize-observer.component.js';
export default SlResizeObserver;
SlResizeObserver.define('sl-resize-observer');

View File

@@ -0,0 +1,874 @@
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { defaultValue } from '../../internal/default-value.js';
import { FormControlController } from '../../internal/form.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, state } from 'lit/decorators.js';
import { scrollIntoView } from '../../internal/scroll.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 SlPopup from '../popup/popup.component.js';
import SlTag from '../tag/tag.component.js';
import styles from './select.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
import type SlOption from '../option/option.component.js';
import type SlRemoveEvent from '../../events/sl-remove.js';
/**
* @summary Selects allow you to choose items from a menu of predefined options.
* @documentation https://shoelace.style/components/select
* @status stable
* @since 2.0
*
* @dependency sl-icon
* @dependency sl-popup
* @dependency sl-tag
*
* @slot - The listbox options. Must be `<sl-option>` elements. You can use `<sl-divider>` to group items visually.
* @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 combobox.
* @slot clear-icon - An icon to use in lieu of the default clear icon.
* @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close.
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
*
* @event sl-change - Emitted when the control's value changes.
* @event sl-clear - Emitted when the control's value is cleared.
* @event sl-input - Emitted when the control receives input.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-blur - Emitted when the control loses focus.
* @event sl-show - Emitted when the select's menu opens.
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
* @event sl-hide - Emitted when the select's menu closes.
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
* @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 select's wrapper.
* @csspart form-control-help-text - The help text's wrapper.
* @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
* @csspart prefix - The container that wraps the prefix slot.
* @csspart display-input - The element that displays the selected option's label, an `<input>` element.
* @csspart listbox - The listbox container where options are slotted.
* @csspart tags - The container that houses option tags when `multiselect` is used.
* @csspart tag - The individual tags that represent each multiselect option.
* @csspart tag__base - The tag's base part.
* @csspart tag__content - The tag's content part.
* @csspart tag__remove-button - The tag's remove button.
* @csspart tag__remove-button__base - The tag's remove button base part.
* @csspart clear-button - The clear button.
* @csspart expand-icon - The container that wraps the expand icon.
*/
export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
static dependencies = {
'sl-icon': SlIcon,
'sl-popup': SlPopup,
'sl-tag': SlTag
};
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);
private typeToSelectString = '';
private typeToSelectTimeout: number;
@query('.select') popup: SlPopup;
@query('.select__combobox') combobox: HTMLSlotElement;
@query('.select__display-input') displayInput: HTMLInputElement;
@query('.select__value-input') valueInput: HTMLInputElement;
@query('.select__listbox') listbox: HTMLSlotElement;
@state() private hasFocus = false;
@state() displayLabel = '';
@state() currentOption: SlOption;
@state() selectedOptions: SlOption[] = [];
/** The name of the select, submitted as a name/value pair with form data. */
@property() name = '';
/**
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
* value will be a space-delimited list of values based on the options selected.
*/
@property({
converter: {
fromAttribute: (value: string) => value.split(' '),
toAttribute: (value: string[]) => value.join(' ')
}
})
value: string | string[] = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue: string | string[] = '';
/** The select's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Placeholder text to show as a hint when the select is empty. */
@property() placeholder = '';
/** Allows more than one option to be selected. */
@property({ type: Boolean, reflect: true }) multiple = false;
/**
* The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to
* indicate the number of additional items that are selected. Set to 0 to remove the limit.
*/
@property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3;
/** Disables the select control. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Adds a clear button when the select is not empty. */
@property({ type: Boolean }) clearable = false;
/**
* Indicates whether or not the select is open. You can toggle this attribute to show and hide the menu, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the select's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* Enable this option to prevent the listbox 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;
/** Draws a filled select. */
@property({ type: Boolean, reflect: true }) filled = false;
/** Draws a pill-style select with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/** The select's label. If you need to display HTML, use the `label` slot instead. */
@property() label = '';
/**
* The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox
* inside of the viewport.
*/
@property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom';
/** The select's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/**
* 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 = '';
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.valueInput.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.valueInput.validationMessage;
}
connectedCallback() {
super.connectedCallback();
// Because this is a form control, it shouldn't be opened initially
this.open = false;
}
private addOpenListeners() {
document.addEventListener('focusin', this.handleDocumentFocusIn);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
private removeOpenListeners() {
document.removeEventListener('focusin', this.handleDocumentFocusIn);
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
}
private handleFocus() {
this.hasFocus = true;
this.displayInput.setSelectionRange(0, 0);
this.emit('sl-focus');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleDocumentFocusIn = (event: KeyboardEvent) => {
// Close when focusing out of the select
const path = event.composedPath();
if (this && !path.includes(this)) {
this.hide();
}
};
private handleDocumentKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const isClearButton = target.closest('.select__clear') !== null;
const isIconButton = target.closest('sl-icon-button') !== null;
// Ignore presses when the target is an icon button (e.g. the remove button in <sl-tag>)
if (isClearButton || isIconButton) {
return;
}
// Close when pressing escape
if (event.key === 'Escape' && this.open) {
event.preventDefault();
event.stopPropagation();
this.hide();
this.displayInput.focus({ preventScroll: true });
}
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
// buffer we _don't_ close it.
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
event.preventDefault();
event.stopImmediatePropagation();
// If it's not open, open it
if (!this.open) {
this.show();
return;
}
// If it is open, update the value based on the current selection and close it
if (this.currentOption && !this.currentOption.disabled) {
if (this.multiple) {
this.toggleOptionSelection(this.currentOption);
} else {
this.setSelectedOptions(this.currentOption);
}
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
if (!this.multiple) {
this.hide();
this.displayInput.focus({ preventScroll: true });
}
}
return;
}
// Navigate options
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const allOptions = this.getAllOptions();
const currentIndex = allOptions.indexOf(this.currentOption);
let newIndex = Math.max(0, currentIndex);
// Prevent scrolling
event.preventDefault();
// Open it
if (!this.open) {
this.show();
// If an option is already selected, stop here because we want that one to remain highlighted when the listbox
// opens for the first time
if (this.currentOption) {
return;
}
}
if (event.key === 'ArrowDown') {
newIndex = currentIndex + 1;
if (newIndex > allOptions.length - 1) newIndex = 0;
} else if (event.key === 'ArrowUp') {
newIndex = currentIndex - 1;
if (newIndex < 0) newIndex = allOptions.length - 1;
} else if (event.key === 'Home') {
newIndex = 0;
} else if (event.key === 'End') {
newIndex = allOptions.length - 1;
}
this.setCurrentOption(allOptions[newIndex]);
}
// All other "printable" keys trigger type to select
if (event.key.length === 1 || event.key === 'Backspace') {
const allOptions = this.getAllOptions();
// Don't block important key combos like CMD+R
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
// Open, unless the key that triggered is backspace
if (!this.open) {
if (event.key === 'Backspace') {
return;
}
this.show();
}
event.stopPropagation();
event.preventDefault();
clearTimeout(this.typeToSelectTimeout);
this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000);
if (event.key === 'Backspace') {
this.typeToSelectString = this.typeToSelectString.slice(0, -1);
} else {
this.typeToSelectString += event.key.toLowerCase();
}
for (const option of allOptions) {
const label = option.getTextLabel().toLowerCase();
if (label.startsWith(this.typeToSelectString)) {
this.setCurrentOption(option);
break;
}
}
}
};
private handleDocumentMouseDown = (event: MouseEvent) => {
// Close when clicking outside of the select
const path = event.composedPath();
if (this && !path.includes(this)) {
this.hide();
}
};
private handleLabelClick() {
this.displayInput.focus();
}
private handleComboboxMouseDown(event: MouseEvent) {
const path = event.composedPath();
const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'sl-icon-button');
// Ignore disabled controls and clicks on tags (remove buttons)
if (this.disabled || isIconButton) {
return;
}
event.preventDefault();
this.displayInput.focus({ preventScroll: true });
this.open = !this.open;
}
private handleComboboxKeyDown(event: KeyboardEvent) {
event.stopPropagation();
this.handleDocumentKeyDown(event);
}
private handleClearClick(event: MouseEvent) {
event.stopPropagation();
if (this.value !== '') {
this.setSelectedOptions([]);
this.displayInput.focus({ preventScroll: true });
// Emit after update
this.updateComplete.then(() => {
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
});
}
}
private handleClearMouseDown(event: MouseEvent) {
// Don't lose focus or propagate events when clicking the clear button
event.stopPropagation();
event.preventDefault();
}
private handleOptionClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const option = target.closest('sl-option');
const oldValue = this.value;
if (option && !option.disabled) {
if (this.multiple) {
this.toggleOptionSelection(option);
} else {
this.setSelectedOptions(option);
}
// Set focus after updating so the value is announced by screen readers
this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
if (this.value !== oldValue) {
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
if (!this.multiple) {
this.hide();
this.displayInput.focus({ preventScroll: true });
}
}
}
private handleDefaultSlotChange() {
const allOptions = this.getAllOptions();
const value = Array.isArray(this.value) ? this.value : [this.value];
const values: string[] = [];
// Check for duplicate values in menu items
if (customElements.get('sl-option')) {
allOptions.forEach(option => values.push(option.value));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
} else {
// Rerun this handler when <sl-option> is registered
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
}
}
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
event.stopPropagation();
if (!this.disabled) {
this.toggleOptionSelection(option, false);
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
}
// Gets an array of all <sl-option> elements
private getAllOptions() {
return [...this.querySelectorAll<SlOption>('sl-option')];
}
// Gets the first <sl-option> element
private getFirstOption() {
return this.querySelector<SlOption>('sl-option');
}
// Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one
// option may be "current" at a time.
private setCurrentOption(option: SlOption | null) {
const allOptions = this.getAllOptions();
// Clear selection
allOptions.forEach(el => {
el.current = false;
el.tabIndex = -1;
});
// Select the target option
if (option) {
this.currentOption = option;
option.current = true;
option.tabIndex = 0;
option.focus();
}
}
// Sets the selected option(s)
private setSelectedOptions(option: SlOption | SlOption[]) {
const allOptions = this.getAllOptions();
const newSelectedOptions = Array.isArray(option) ? option : [option];
// Clear existing selection
allOptions.forEach(el => (el.selected = false));
// Set the new selection
if (newSelectedOptions.length) {
newSelectedOptions.forEach(el => (el.selected = true));
}
// Update selection, value, and display label
this.selectionChanged();
}
// Toggles an option's selected state
private toggleOptionSelection(option: SlOption, force?: boolean) {
if (force === true || force === false) {
option.selected = force;
} else {
option.selected = !option.selected;
}
this.selectionChanged();
}
// This method must be called whenever the selection changes. It will update the selected options cache, the current
// value, and the display value
private selectionChanged() {
// Update selected options cache
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
// Update the value and display label
if (this.multiple) {
this.value = this.selectedOptions.map(el => el.value);
if (this.placeholder && this.value.length === 0) {
// When no items are selected, keep the value empty so the placeholder shows
this.displayLabel = '';
} else {
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
}
} else {
this.value = this.selectedOptions[0]?.value ?? '';
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
}
// Update validity
this.updateComplete.then(() => {
this.formControlController.updateValidity();
});
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Close the listbox when the control is disabled
if (this.disabled) {
this.open = false;
this.handleOpenChange();
}
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
const allOptions = this.getAllOptions();
const value = Array.isArray(this.value) ? this.value : [this.value];
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open && !this.disabled) {
// Reset the current option
this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
// Show
this.emit('sl-show');
this.addOpenListeners();
await stopAnimations(this);
this.listbox.hidden = false;
this.popup.active = true;
// Select the appropriate option based on value after the listbox opens
requestAnimationFrame(() => {
this.setCurrentOption(this.currentOption);
});
const { keyframes, options } = getAnimation(this, 'select.show', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
// Make sure the current option is scrolled into view (required for Safari)
if (this.currentOption) {
scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto');
}
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
this.removeOpenListeners();
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'select.hide', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.listbox.hidden = true;
this.popup.active = false;
this.emit('sl-after-hide');
}
}
/** Shows the listbox. */
async show() {
if (this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the listbox. */
async hide() {
if (!this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.valueInput.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.valueInput.reportValidity();
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
this.valueInput.setCustomValidity(message);
this.formControlController.updateValidity();
}
/** Sets focus on the control. */
focus(options?: FocusOptions) {
this.displayInput.focus(options);
}
/** Removes focus from the control. */
blur() {
this.displayInput.blur();
}
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.value.length > 0;
const isPlaceholderVisible = this.placeholder && 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
id="label"
part="form-control-label"
class="form-control__label"
aria-hidden=${hasLabel ? 'false' : 'true'}
@click=${this.handleLabelClick}
>
<slot name="label">${this.label}</slot>
</label>
<div part="form-control-input" class="form-control-input">
<sl-popup
class=${classMap({
select: true,
'select--standard': true,
'select--filled': this.filled,
'select--pill': this.pill,
'select--open': this.open,
'select--disabled': this.disabled,
'select--multiple': this.multiple,
'select--focused': this.hasFocus,
'select--placeholder-visible': isPlaceholderVisible,
'select--top': this.placement === 'top',
'select--bottom': this.placement === 'bottom',
'select--small': this.size === 'small',
'select--medium': this.size === 'medium',
'select--large': this.size === 'large'
})}
placement=${this.placement}
strategy=${this.hoist ? 'fixed' : 'absolute'}
flip
shift
sync="width"
auto-size="vertical"
auto-size-padding="10"
>
<div
part="combobox"
class="select__combobox"
slot="anchor"
@keydown=${this.handleComboboxKeyDown}
@mousedown=${this.handleComboboxMouseDown}
>
<slot part="prefix" name="prefix" class="select__prefix"></slot>
<input
part="display-input"
class="select__display-input"
type="text"
placeholder=${this.placeholder}
.disabled=${this.disabled}
.value=${this.displayLabel}
autocomplete="off"
spellcheck="false"
autocapitalize="off"
readonly
aria-controls="listbox"
aria-expanded=${this.open ? 'true' : 'false'}
aria-haspopup="listbox"
aria-labelledby="label"
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-describedby="help-text"
role="combobox"
tabindex="0"
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
${this.multiple
? html`
<div part="tags" class="select__tags">
${this.selectedOptions.map((option, index) => {
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
return html`
<sl-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base
"
?pill=${this.pill}
size=${this.size}
removable
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
>
${option.getTextLabel()}
</sl-tag>
`;
} else if (index === this.maxOptionsVisible) {
return html` <sl-tag size=${this.size}> +${this.selectedOptions.length - index} </sl-tag> `;
} else {
return null;
}
})}
</div>
`
: ''}
<input
class="select__value-input"
type="text"
?disabled=${this.disabled}
?required=${this.required}
.value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
tabindex="-1"
aria-hidden="true"
@focus=${() => this.focus()}
@invalid=${this.handleInvalid}
/>
${hasClearIcon
? html`
<button
part="clear-button"
class="select__clear"
type="button"
aria-label=${this.localize.term('clearEntry')}
@mousedown=${this.handleClearMouseDown}
@click=${this.handleClearClick}
tabindex="-1"
>
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''}
<slot name="expand-icon" part="expand-icon" class="select__expand-icon">
<sl-icon library="system" name="chevron-down"></sl-icon>
</slot>
</div>
<div
id="listbox"
role="listbox"
aria-expanded=${this.open ? 'true' : 'false'}
aria-multiselectable=${this.multiple ? 'true' : 'false'}
aria-labelledby="label"
part="listbox"
class="select__listbox"
tabindex="-1"
@mouseup=${this.handleOptionClick}
@slotchange=${this.handleDefaultSlotChange}
>
<slot></slot>
</div>
</sl-popup>
</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>
`;
}
}
setDefaultAnimation('select.show', {
keyframes: [
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1 }
],
options: { duration: 100, easing: 'ease' }
});
setDefaultAnimation('select.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.9 }
],
options: { duration: 100, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-select': SlSelect;
}
}

View File

@@ -1,871 +1,4 @@
import '../icon/icon.js';
import '../popup/popup.js';
import '../tag/tag.js';
import { animateTo, stopAnimations } from '../../internal/animate.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 { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { scrollIntoView } from '../../internal/scroll.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './select.styles.js';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
import type SlOption from '../option/option.js';
import type SlPopup from '../popup/popup.js';
import type SlRemoveEvent from '../../events/sl-remove.js';
/**
* @summary Selects allow you to choose items from a menu of predefined options.
* @documentation https://shoelace.style/components/select
* @status stable
* @since 2.0
*
* @dependency sl-icon
* @dependency sl-popup
* @dependency sl-tag
*
* @slot - The listbox options. Must be `<sl-option>` elements. You can use `<sl-divider>` to group items visually.
* @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 combobox.
* @slot clear-icon - An icon to use in lieu of the default clear icon.
* @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close.
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
*
* @event sl-change - Emitted when the control's value changes.
* @event sl-clear - Emitted when the control's value is cleared.
* @event sl-input - Emitted when the control receives input.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-blur - Emitted when the control loses focus.
* @event sl-show - Emitted when the select's menu opens.
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
* @event sl-hide - Emitted when the select's menu closes.
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
* @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 select's wrapper.
* @csspart form-control-help-text - The help text's wrapper.
* @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
* @csspart prefix - The container that wraps the prefix slot.
* @csspart display-input - The element that displays the selected option's label, an `<input>` element.
* @csspart listbox - The listbox container where options are slotted.
* @csspart tags - The container that houses option tags when `multiselect` is used.
* @csspart tag - The individual tags that represent each multiselect option.
* @csspart tag__base - The tag's base part.
* @csspart tag__content - The tag's content part.
* @csspart tag__remove-button - The tag's remove button.
* @csspart tag__remove-button__base - The tag's remove button base part.
* @csspart clear-button - The clear button.
* @csspart expand-icon - The container that wraps the expand icon.
*/
@customElement('sl-select')
export default class SlSelect 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);
private typeToSelectString = '';
private typeToSelectTimeout: number;
@query('.select') popup: SlPopup;
@query('.select__combobox') combobox: HTMLSlotElement;
@query('.select__display-input') displayInput: HTMLInputElement;
@query('.select__value-input') valueInput: HTMLInputElement;
@query('.select__listbox') listbox: HTMLSlotElement;
@state() private hasFocus = false;
@state() displayLabel = '';
@state() currentOption: SlOption;
@state() selectedOptions: SlOption[] = [];
/** The name of the select, submitted as a name/value pair with form data. */
@property() name = '';
/**
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
* value will be a space-delimited list of values based on the options selected.
*/
@property({
converter: {
fromAttribute: (value: string) => value.split(' '),
toAttribute: (value: string[]) => value.join(' ')
}
})
value: string | string[] = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue: string | string[] = '';
/** The select's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Placeholder text to show as a hint when the select is empty. */
@property() placeholder = '';
/** Allows more than one option to be selected. */
@property({ type: Boolean, reflect: true }) multiple = false;
/**
* The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to
* indicate the number of additional items that are selected. Set to 0 to remove the limit.
*/
@property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3;
/** Disables the select control. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Adds a clear button when the select is not empty. */
@property({ type: Boolean }) clearable = false;
/**
* Indicates whether or not the select is open. You can toggle this attribute to show and hide the menu, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the select's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* Enable this option to prevent the listbox 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;
/** Draws a filled select. */
@property({ type: Boolean, reflect: true }) filled = false;
/** Draws a pill-style select with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/** The select's label. If you need to display HTML, use the `label` slot instead. */
@property() label = '';
/**
* The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox
* inside of the viewport.
*/
@property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom';
/** The select's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/**
* 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 = '';
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.valueInput.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.valueInput.validationMessage;
}
connectedCallback() {
super.connectedCallback();
// Because this is a form control, it shouldn't be opened initially
this.open = false;
}
private addOpenListeners() {
document.addEventListener('focusin', this.handleDocumentFocusIn);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
private removeOpenListeners() {
document.removeEventListener('focusin', this.handleDocumentFocusIn);
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
}
private handleFocus() {
this.hasFocus = true;
this.displayInput.setSelectionRange(0, 0);
this.emit('sl-focus');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleDocumentFocusIn = (event: KeyboardEvent) => {
// Close when focusing out of the select
const path = event.composedPath();
if (this && !path.includes(this)) {
this.hide();
}
};
private handleDocumentKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const isClearButton = target.closest('.select__clear') !== null;
const isIconButton = target.closest('sl-icon-button') !== null;
// Ignore presses when the target is an icon button (e.g. the remove button in <sl-tag>)
if (isClearButton || isIconButton) {
return;
}
// Close when pressing escape
if (event.key === 'Escape' && this.open) {
event.preventDefault();
event.stopPropagation();
this.hide();
this.displayInput.focus({ preventScroll: true });
}
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
// buffer we _don't_ close it.
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
event.preventDefault();
event.stopImmediatePropagation();
// If it's not open, open it
if (!this.open) {
this.show();
return;
}
// If it is open, update the value based on the current selection and close it
if (this.currentOption && !this.currentOption.disabled) {
if (this.multiple) {
this.toggleOptionSelection(this.currentOption);
} else {
this.setSelectedOptions(this.currentOption);
}
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
if (!this.multiple) {
this.hide();
this.displayInput.focus({ preventScroll: true });
}
}
return;
}
// Navigate options
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const allOptions = this.getAllOptions();
const currentIndex = allOptions.indexOf(this.currentOption);
let newIndex = Math.max(0, currentIndex);
// Prevent scrolling
event.preventDefault();
// Open it
if (!this.open) {
this.show();
// If an option is already selected, stop here because we want that one to remain highlighted when the listbox
// opens for the first time
if (this.currentOption) {
return;
}
}
if (event.key === 'ArrowDown') {
newIndex = currentIndex + 1;
if (newIndex > allOptions.length - 1) newIndex = 0;
} else if (event.key === 'ArrowUp') {
newIndex = currentIndex - 1;
if (newIndex < 0) newIndex = allOptions.length - 1;
} else if (event.key === 'Home') {
newIndex = 0;
} else if (event.key === 'End') {
newIndex = allOptions.length - 1;
}
this.setCurrentOption(allOptions[newIndex]);
}
// All other "printable" keys trigger type to select
if (event.key.length === 1 || event.key === 'Backspace') {
const allOptions = this.getAllOptions();
// Don't block important key combos like CMD+R
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
// Open, unless the key that triggered is backspace
if (!this.open) {
if (event.key === 'Backspace') {
return;
}
this.show();
}
event.stopPropagation();
event.preventDefault();
clearTimeout(this.typeToSelectTimeout);
this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000);
if (event.key === 'Backspace') {
this.typeToSelectString = this.typeToSelectString.slice(0, -1);
} else {
this.typeToSelectString += event.key.toLowerCase();
}
for (const option of allOptions) {
const label = option.getTextLabel().toLowerCase();
if (label.startsWith(this.typeToSelectString)) {
this.setCurrentOption(option);
break;
}
}
}
};
private handleDocumentMouseDown = (event: MouseEvent) => {
// Close when clicking outside of the select
const path = event.composedPath();
if (this && !path.includes(this)) {
this.hide();
}
};
private handleLabelClick() {
this.displayInput.focus();
}
private handleComboboxMouseDown(event: MouseEvent) {
const path = event.composedPath();
const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'sl-icon-button');
// Ignore disabled controls and clicks on tags (remove buttons)
if (this.disabled || isIconButton) {
return;
}
event.preventDefault();
this.displayInput.focus({ preventScroll: true });
this.open = !this.open;
}
private handleComboboxKeyDown(event: KeyboardEvent) {
event.stopPropagation();
this.handleDocumentKeyDown(event);
}
private handleClearClick(event: MouseEvent) {
event.stopPropagation();
if (this.value !== '') {
this.setSelectedOptions([]);
this.displayInput.focus({ preventScroll: true });
// Emit after update
this.updateComplete.then(() => {
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
});
}
}
private handleClearMouseDown(event: MouseEvent) {
// Don't lose focus or propagate events when clicking the clear button
event.stopPropagation();
event.preventDefault();
}
private handleOptionClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const option = target.closest('sl-option');
const oldValue = this.value;
if (option && !option.disabled) {
if (this.multiple) {
this.toggleOptionSelection(option);
} else {
this.setSelectedOptions(option);
}
// Set focus after updating so the value is announced by screen readers
this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
if (this.value !== oldValue) {
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
if (!this.multiple) {
this.hide();
this.displayInput.focus({ preventScroll: true });
}
}
}
private handleDefaultSlotChange() {
const allOptions = this.getAllOptions();
const value = Array.isArray(this.value) ? this.value : [this.value];
const values: string[] = [];
// Check for duplicate values in menu items
if (customElements.get('sl-option')) {
allOptions.forEach(option => values.push(option.value));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
} else {
// Rerun this handler when <sl-option> is registered
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
}
}
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
event.stopPropagation();
if (!this.disabled) {
this.toggleOptionSelection(option, false);
// Emit after updating
this.updateComplete.then(() => {
this.emit('sl-input');
this.emit('sl-change');
});
}
}
// Gets an array of all <sl-option> elements
private getAllOptions() {
return [...this.querySelectorAll<SlOption>('sl-option')];
}
// Gets the first <sl-option> element
private getFirstOption() {
return this.querySelector<SlOption>('sl-option');
}
// Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one
// option may be "current" at a time.
private setCurrentOption(option: SlOption | null) {
const allOptions = this.getAllOptions();
// Clear selection
allOptions.forEach(el => {
el.current = false;
el.tabIndex = -1;
});
// Select the target option
if (option) {
this.currentOption = option;
option.current = true;
option.tabIndex = 0;
option.focus();
}
}
// Sets the selected option(s)
private setSelectedOptions(option: SlOption | SlOption[]) {
const allOptions = this.getAllOptions();
const newSelectedOptions = Array.isArray(option) ? option : [option];
// Clear existing selection
allOptions.forEach(el => (el.selected = false));
// Set the new selection
if (newSelectedOptions.length) {
newSelectedOptions.forEach(el => (el.selected = true));
}
// Update selection, value, and display label
this.selectionChanged();
}
// Toggles an option's selected state
private toggleOptionSelection(option: SlOption, force?: boolean) {
if (force === true || force === false) {
option.selected = force;
} else {
option.selected = !option.selected;
}
this.selectionChanged();
}
// This method must be called whenever the selection changes. It will update the selected options cache, the current
// value, and the display value
private selectionChanged() {
// Update selected options cache
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
// Update the value and display label
if (this.multiple) {
this.value = this.selectedOptions.map(el => el.value);
if (this.placeholder && this.value.length === 0) {
// When no items are selected, keep the value empty so the placeholder shows
this.displayLabel = '';
} else {
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
}
} else {
this.value = this.selectedOptions[0]?.value ?? '';
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
}
// Update validity
this.updateComplete.then(() => {
this.formControlController.updateValidity();
});
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Close the listbox when the control is disabled
if (this.disabled) {
this.open = false;
this.handleOpenChange();
}
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
const allOptions = this.getAllOptions();
const value = Array.isArray(this.value) ? this.value : [this.value];
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open && !this.disabled) {
// Reset the current option
this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
// Show
this.emit('sl-show');
this.addOpenListeners();
await stopAnimations(this);
this.listbox.hidden = false;
this.popup.active = true;
// Select the appropriate option based on value after the listbox opens
requestAnimationFrame(() => {
this.setCurrentOption(this.currentOption);
});
const { keyframes, options } = getAnimation(this, 'select.show', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
// Make sure the current option is scrolled into view (required for Safari)
if (this.currentOption) {
scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto');
}
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
this.removeOpenListeners();
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'select.hide', { dir: this.localize.dir() });
await animateTo(this.popup.popup, keyframes, options);
this.listbox.hidden = true;
this.popup.active = false;
this.emit('sl-after-hide');
}
}
/** Shows the listbox. */
async show() {
if (this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the listbox. */
async hide() {
if (!this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.valueInput.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.valueInput.reportValidity();
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
this.valueInput.setCustomValidity(message);
this.formControlController.updateValidity();
}
/** Sets focus on the control. */
focus(options?: FocusOptions) {
this.displayInput.focus(options);
}
/** Removes focus from the control. */
blur() {
this.displayInput.blur();
}
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.value.length > 0;
const isPlaceholderVisible = this.placeholder && 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
id="label"
part="form-control-label"
class="form-control__label"
aria-hidden=${hasLabel ? 'false' : 'true'}
@click=${this.handleLabelClick}
>
<slot name="label">${this.label}</slot>
</label>
<div part="form-control-input" class="form-control-input">
<sl-popup
class=${classMap({
select: true,
'select--standard': true,
'select--filled': this.filled,
'select--pill': this.pill,
'select--open': this.open,
'select--disabled': this.disabled,
'select--multiple': this.multiple,
'select--focused': this.hasFocus,
'select--placeholder-visible': isPlaceholderVisible,
'select--top': this.placement === 'top',
'select--bottom': this.placement === 'bottom',
'select--small': this.size === 'small',
'select--medium': this.size === 'medium',
'select--large': this.size === 'large'
})}
placement=${this.placement}
strategy=${this.hoist ? 'fixed' : 'absolute'}
flip
shift
sync="width"
auto-size="vertical"
auto-size-padding="10"
>
<div
part="combobox"
class="select__combobox"
slot="anchor"
@keydown=${this.handleComboboxKeyDown}
@mousedown=${this.handleComboboxMouseDown}
>
<slot part="prefix" name="prefix" class="select__prefix"></slot>
<input
part="display-input"
class="select__display-input"
type="text"
placeholder=${this.placeholder}
.disabled=${this.disabled}
.value=${this.displayLabel}
autocomplete="off"
spellcheck="false"
autocapitalize="off"
readonly
aria-controls="listbox"
aria-expanded=${this.open ? 'true' : 'false'}
aria-haspopup="listbox"
aria-labelledby="label"
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-describedby="help-text"
role="combobox"
tabindex="0"
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
${this.multiple
? html`
<div part="tags" class="select__tags">
${this.selectedOptions.map((option, index) => {
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
return html`
<sl-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base
"
?pill=${this.pill}
size=${this.size}
removable
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
>
${option.getTextLabel()}
</sl-tag>
`;
} else if (index === this.maxOptionsVisible) {
return html` <sl-tag size=${this.size}> +${this.selectedOptions.length - index} </sl-tag> `;
} else {
return null;
}
})}
</div>
`
: ''}
<input
class="select__value-input"
type="text"
?disabled=${this.disabled}
?required=${this.required}
.value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
tabindex="-1"
aria-hidden="true"
@focus=${() => this.focus()}
@invalid=${this.handleInvalid}
/>
${hasClearIcon
? html`
<button
part="clear-button"
class="select__clear"
type="button"
aria-label=${this.localize.term('clearEntry')}
@mousedown=${this.handleClearMouseDown}
@click=${this.handleClearClick}
tabindex="-1"
>
<slot name="clear-icon">
<sl-icon name="x-circle-fill" library="system"></sl-icon>
</slot>
</button>
`
: ''}
<slot name="expand-icon" part="expand-icon" class="select__expand-icon">
<sl-icon library="system" name="chevron-down"></sl-icon>
</slot>
</div>
<div
id="listbox"
role="listbox"
aria-expanded=${this.open ? 'true' : 'false'}
aria-multiselectable=${this.multiple ? 'true' : 'false'}
aria-labelledby="label"
part="listbox"
class="select__listbox"
tabindex="-1"
@mouseup=${this.handleOptionClick}
@slotchange=${this.handleDefaultSlotChange}
>
<slot></slot>
</div>
</sl-popup>
</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>
`;
}
}
setDefaultAnimation('select.show', {
keyframes: [
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1 }
],
options: { duration: 100, easing: 'ease' }
});
setDefaultAnimation('select.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.9 }
],
options: { duration: 100, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-select': SlSelect;
}
}
import SlSelect from './select.component.js';
export * from './select.component.js';
export default SlSelect;
SlSelect.define('sl-select');

View File

@@ -0,0 +1,47 @@
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 './skeleton.styles.js';
import type { CSSResultGroup } from 'lit';
/**
* @summary Skeletons are used to provide a visual representation of where content will eventually be drawn.
* @documentation https://shoelace.style/components/skeleton
* @status stable
* @since 2.0
*
* @csspart base - The component's base wrapper.
* @csspart indicator - The skeleton's indicator which is responsible for its color and animation.
*
* @cssproperty --border-radius - The skeleton's border radius.
* @cssproperty --color - The color of the skeleton.
* @cssproperty --sheen-color - The sheen color when the skeleton is in its loading state.
*/
export default class SlSkeleton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** Determines which effect the skeleton will use. */
@property() effect: 'pulse' | 'sheen' | 'none' = 'none';
render() {
return html`
<div
part="base"
class=${classMap({
skeleton: true,
'skeleton--pulse': this.effect === 'pulse',
'skeleton--sheen': this.effect === 'sheen'
})}
>
<div part="indicator" class="skeleton__indicator"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-skeleton': SlSkeleton;
}
}

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