diff --git a/custom-elements-manifest.config.js b/custom-elements-manifest.config.js index e9b9414723..7a41f759a9 100644 --- a/custom-elements-manifest.config.js +++ b/custom-elements-manifest.config.js @@ -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) { diff --git a/docs/pages/getting-started/installation.md b/docs/pages/getting-started/installation.md index cbef3331b2..2d6af34f1e 100644 --- a/docs/pages/getting-started/installation.md +++ b/docs/pages/getting-started/installation.md @@ -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 won’t work as expected. +::: + ## The difference between CDN and npm You'll notice that the CDN links all start with `/%CDNDIR%/` and npm imports use `/%NPMDIR%/`. 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. diff --git a/docs/pages/resources/changelog.md b/docs/pages/resources/changelog.md index d569494672..17b02275ea 100644 --- a/docs/pages/resources/changelog.md +++ b/docs/pages/resources/changelog.md @@ -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 `` [#1416] - Added support for pressing [[Space]] to select/toggle selected `` elements [#1429] - Added support for virtual elements in `` [#1449] - Added the `spinner` part to `` [#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 `` where it was auto-defining ``. [#1450] - Fixed a bug in focus trapping of modal elements like ``. We now manually handle focus ordering as well as added `offsetParent()` check for tabbable boundaries in Safari. Test cases added for `` inside a shadowRoot [#1403] - Fixed a bug in `valueAsDate` on `` where it would always set `type="date"` for the underlying `` 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 `` 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 `` that caused focus to be stolen when removing focused tree items [#1430] - Fixed a bug in `` and `` 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 diff --git a/docs/pages/resources/contributing.md b/docs/pages/resources/contributing.md index 29a88a7955..1cb528cadb 100644 --- a/docs/pages/resources/contributing.md +++ b/docs/pages/resources/contributing.md @@ -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 { // ... } diff --git a/package-lock.json b/package-lock.json index 9180b9dd82..f37e42643f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3d295ac47b..06ae127c31 100644 --- a/package.json +++ b/package.json @@ -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/**/*((? { 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[0]> { + constructor (...args: Parameters) { + 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' diff --git a/scripts/plop/plopfile.js b/scripts/plop/plopfile.js index cc658ebfb0..542004435e 100644 --- a/scripts/plop/plopfile.js +++ b/scripts/plop/plopfile.js @@ -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' }, { diff --git a/scripts/plop/templates/component/component.hbs b/scripts/plop/templates/component/component.hbs index 5cac01571c..803f4ea853 100644 --- a/scripts/plop/templates/component/component.hbs +++ b/scripts/plop/templates/component/component.hbs @@ -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; diff --git a/scripts/plop/templates/component/define.hbs b/scripts/plop/templates/component/define.hbs new file mode 100644 index 0000000000..691c41cf5d --- /dev/null +++ b/scripts/plop/templates/component/define.hbs @@ -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 }}'); diff --git a/src/components/alert/alert.component.ts b/src/components/alert/alert.component.ts new file mode 100644 index 0000000000..8a174bcdad --- /dev/null +++ b/src/components/alert/alert.component.ts @@ -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 ``. + * + * @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 ``. + * @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(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` + + `; + } +} + +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; + } +} diff --git a/src/components/alert/alert.ts b/src/components/alert/alert.ts index b88d623bb1..d51a4e0bc6 100644 --- a/src/components/alert/alert.ts +++ b/src/components/alert/alert.ts @@ -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 ``. - * - * @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 ``. - * @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(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` - - `; - } -} - -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'); diff --git a/src/components/animated-image/animated-image.component.ts b/src/components/animated-image/animated-image.component.ts new file mode 100644 index 0000000000..27ba367a8d --- /dev/null +++ b/src/components/animated-image/animated-image.component.ts @@ -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 ``. + * @slot pause-icon - Optional pause icon to use instead of the default. Works best with ``. + * + * @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` +
+ ${this.alt} + + ${this.isLoaded + ? html` + ${this.alt} + +
+ + +
+ ` + : ''} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-animated-image': SlAnimatedImage; + } +} diff --git a/src/components/animated-image/animated-image.ts b/src/components/animated-image/animated-image.ts index 6c47bc2f31..b53f0e6853 100644 --- a/src/components/animated-image/animated-image.ts +++ b/src/components/animated-image/animated-image.ts @@ -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 ``. - * @slot pause-icon - Optional pause icon to use instead of the default. Works best with ``. - * - * @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` -
- ${this.alt} - - ${this.isLoaded - ? html` - ${this.alt} - -
- - -
- ` - : ''} -
- `; - } -} - -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'); diff --git a/src/components/animation/animation.component.ts b/src/components/animation/animation.component.ts new file mode 100644 index 0000000000..832cd6192f --- /dev/null +++ b/src/components/animation/animation.component.ts @@ -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 `` elements. + */ +export default class SlAnimation extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + private animation?: Animation; + private hasStarted = false; + + @queryAsync('slot') defaultSlot: Promise; + + /** 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>)[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` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-animation': SlAnimation; + } +} diff --git a/src/components/animation/animation.ts b/src/components/animation/animation.ts index 81c1bc5113..f1bd3f9a58 100644 --- a/src/components/animation/animation.ts +++ b/src/components/animation/animation.ts @@ -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 `` elements. - */ -@customElement('sl-animation') -export default class SlAnimation extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - private animation?: Animation; - private hasStarted = false; - - @queryAsync('slot') defaultSlot: Promise; - - /** 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>)[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` `; - } -} - -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'); diff --git a/src/components/avatar/avatar.component.ts b/src/components/avatar/avatar.component.ts new file mode 100644 index 0000000000..446f2833fe --- /dev/null +++ b/src/components/avatar/avatar.component.ts @@ -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 ``. + * + * @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` + + `; + + let avatarWithoutImage = html``; + + if (this.initials) { + avatarWithoutImage = html`
${this.initials}
`; + } else { + avatarWithoutImage = html` + + `; + } + + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-avatar': SlAvatar; + } +} diff --git a/src/components/avatar/avatar.ts b/src/components/avatar/avatar.ts index bbcf469459..69b5997ab6 100644 --- a/src/components/avatar/avatar.ts +++ b/src/components/avatar/avatar.ts @@ -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 ``. - * - * @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` - - `; - - let avatarWithoutImage = html``; - - if (this.initials) { - avatarWithoutImage = html`
${this.initials}
`; - } else { - avatarWithoutImage = html` - - `; - } - - return html` - - `; - } -} - -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'); diff --git a/src/components/badge/badge.component.ts b/src/components/badge/badge.component.ts new file mode 100644 index 0000000000..5286d8e521 --- /dev/null +++ b/src/components/badge/badge.component.ts @@ -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` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-badge': SlBadge; + } +} diff --git a/src/components/badge/badge.ts b/src/components/badge/badge.ts index 8eb3054485..d3849cd5c2 100644 --- a/src/components/badge/badge.ts +++ b/src/components/badge/badge.ts @@ -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` - - - - `; - } -} - -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'); diff --git a/src/components/breadcrumb-item/breadcrumb-item.component.ts b/src/components/breadcrumb-item/breadcrumb-item.component.ts new file mode 100644 index 0000000000..781aaffcd2 --- /dev/null +++ b/src/components/breadcrumb-item/breadcrumb-item.component.ts @@ -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 `` 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` +
+ + + + + ${isLink + ? html` + + + + ` + : html` + + `} + + + + + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-breadcrumb-item': SlBreadcrumbItem; + } +} diff --git a/src/components/breadcrumb-item/breadcrumb-item.ts b/src/components/breadcrumb-item/breadcrumb-item.ts index 1ba8a78de1..5be7859563 100644 --- a/src/components/breadcrumb-item/breadcrumb-item.ts +++ b/src/components/breadcrumb-item/breadcrumb-item.ts @@ -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 `` 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` -
- - - - - ${isLink - ? html` - - - - ` - : html` - - `} - - - - - - -
- `; - } -} - -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'); diff --git a/src/components/breadcrumb/breadcrumb.component.ts b/src/components/breadcrumb/breadcrumb.component.ts new file mode 100644 index 0000000000..36a0695038 --- /dev/null +++ b/src/components/breadcrumb/breadcrumb.component.ts @@ -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 ``. + * + * @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` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-breadcrumb': SlBreadcrumb; + } +} diff --git a/src/components/breadcrumb/breadcrumb.ts b/src/components/breadcrumb/breadcrumb.ts index 6f61d7b5c1..d838e29223 100644 --- a/src/components/breadcrumb/breadcrumb.ts +++ b/src/components/breadcrumb/breadcrumb.ts @@ -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 ``. - * - * @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` - - - - `; - } -} - -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'); diff --git a/src/components/button-group/button-group.component.ts b/src/components/button-group/button-group.component.ts new file mode 100644 index 0000000000..4fe80f5f18 --- /dev/null +++ b/src/components/button-group/button-group.component.ts @@ -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 `` 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` +
+ +
+ `; + } +} + +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; + } +} diff --git a/src/components/button-group/button-group.ts b/src/components/button-group/button-group.ts index b3a1d19ad4..e32a901d76 100644 --- a/src/components/button-group/button-group.ts +++ b/src/components/button-group/button-group.ts @@ -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 `` 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` -
- -
- `; - } -} - -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'); diff --git a/src/components/button/button.component.ts b/src/components/button/button.component.ts new file mode 100644 index 0000000000..9f1005426a --- /dev/null +++ b/src/components/button/button.component.ts @@ -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 `` 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 `` 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 + * ` + + + + ` + : ''} + ${this.pagination + ? html` + + ` + : ''} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-carousel': SlCarousel; + } +} diff --git a/src/components/carousel/carousel.ts b/src/components/carousel/carousel.ts index 93cfe51988..0979be32ea 100644 --- a/src/components/carousel/carousel.ts +++ b/src/components/carousel/carousel.ts @@ -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 `` elements. - * @slot next-icon - Optional next icon to use instead of the default. Works best with ``. - * @slot previous-icon - Optional previous icon to use instead of the default. Works best with ``. - * - * @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(); - 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( - '[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` - - `; - } -} - -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'); diff --git a/src/components/checkbox/checkbox.component.ts b/src/components/checkbox/checkbox.component.ts new file mode 100644 index 0000000000..dc828bbfb8 --- /dev/null +++ b/src/components/checkbox/checkbox.component.ts @@ -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 `` element. + * @csspart indeterminate-icon - The indeterminate icon, an `` 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 `
` 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
around the label slot because of this Chrome bug. + // + // https://bugs.chromium.org/p/chromium/issues/detail?id=1413733 + // + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-checkbox': SlCheckbox; + } +} diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index 6709c382c6..15bddf0ef3 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -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 `` element. - * @csspart indeterminate-icon - The indeterminate icon, an `` 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 `` 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
around the label slot because of this Chrome bug. - // - // https://bugs.chromium.org/p/chromium/issues/detail?id=1413733 - // - return html` - - `; - } -} - -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'); diff --git a/src/components/color-picker/color-picker.component.ts b/src/components/color-picker/color-picker.component.ts new file mode 100644 index 0000000000..ac5c1e43df --- /dev/null +++ b/src/components/color-picker/color-picker.component.ts @@ -0,0 +1,1073 @@ +import { clamp } from '../../internal/math.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { defaultValue } from '../../internal/default-value.js'; +import { drag } from '../../internal/drag.js'; +import { FormControlController } from '../../internal/form.js'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { TinyColor } from '@ctrl/tinycolor'; +import { watch } from '../../internal/watch.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import SlButton from '../button/button.component.js'; +import SlButtonGroup from '../button-group/button-group.component.js'; +import SlDropdown from '../dropdown/dropdown.component.js'; +import SlIcon from '../icon/icon.component.js'; +import SlInput from '../input/input.component.js'; +import SlVisuallyHidden from '../visually-hidden/visually-hidden.component.js'; +import styles from './color-picker.styles.js'; +import type { CSSResultGroup } from 'lit'; +import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; +import type SlChangeEvent from '../../events/sl-change.js'; +import type SlInputEvent from '../../events/sl-input.js'; + +const hasEyeDropper = 'EyeDropper' in window; + +interface EyeDropperConstructor { + new (): EyeDropperInterface; +} + +interface EyeDropperInterface { + open: () => Promise<{ sRGBHex: string }>; +} + +declare const EyeDropper: EyeDropperConstructor; + +/** + * @summary Color pickers allow the user to select a color. + * @documentation https://shoelace.style/components/color-picker + * @status stable + * @since 2.0 + * + * @dependency sl-button + * @dependency sl-button-group + * @dependency sl-dropdown + * @dependency sl-input + * @dependency sl-visually-hidden + * + * @slot label - The color picker's form label. Alternatively, you can use the `label` attribute. + * + * @event sl-blur - Emitted when the color picker loses focus. + * @event sl-change - Emitted when the color picker's value changes. + * @event sl-focus - Emitted when the color picker receives focus. + * @event sl-input - Emitted when the color picker receives input. + * @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. + * + * @csspart base - The component's base wrapper. + * @csspart trigger - The color picker's dropdown trigger. + * @csspart swatches - The container that holds the swatches. + * @csspart swatch - Each individual swatch. + * @csspart grid - The color grid. + * @csspart grid-handle - The color grid's handle. + * @csspart slider - Hue and opacity sliders. + * @csspart slider-handle - Hue and opacity slider handles. + * @csspart hue-slider - The hue slider. + * @csspart hue-slider-handle - The hue slider's handle. + * @csspart opacity-slider - The opacity slider. + * @csspart opacity-slider-handle - The opacity slider's handle. + * @csspart preview - The preview color. + * @csspart input - The text input. + * @csspart eye-dropper-button - The eye dropper button. + * @csspart eye-dropper-button__base - The eye dropper button's exported `button` part. + * @csspart eye-dropper-button__prefix - The eye dropper button's exported `prefix` part. + * @csspart eye-dropper-button__label - The eye dropper button's exported `label` part. + * @csspart eye-dropper-button__suffix - The eye dropper button's exported `suffix` part. + * @csspart eye-dropper-button__caret - The eye dropper button's exported `caret` part. + * @csspart format-button - The format button. + * @csspart format-button__base - The format button's exported `button` part. + * @csspart format-button__prefix - The format button's exported `prefix` part. + * @csspart format-button__label - The format button's exported `label` part. + * @csspart format-button__suffix - The format button's exported `suffix` part. + * @csspart format-button__caret - The format button's exported `caret` part. + * + * @cssproperty --grid-width - The width of the color grid. + * @cssproperty --grid-height - The height of the color grid. + * @cssproperty --grid-handle-size - The size of the color grid's handle. + * @cssproperty --slider-height - The height of the hue and alpha sliders. + * @cssproperty --slider-handle-size - The diameter of the slider's handle. + * @cssproperty --swatch-size - The size of each predefined color swatch. + */ +export default class SlColorPicker extends ShoelaceElement implements ShoelaceFormControl { + static styles: CSSResultGroup = styles; + + static dependencies = { + 'sl-button-group': SlButtonGroup, + 'sl-button': SlButton, + 'sl-dropdown': SlDropdown, + 'sl-icon': SlIcon, + 'sl-input': SlInput, + 'sl-visually-hidden': SlVisuallyHidden + }; + + private readonly formControlController = new FormControlController(this); + private isSafeValue = false; + private readonly localize = new LocalizeController(this); + + @query('[part~="base"]') base: HTMLElement; + @query('[part~="input"]') input: SlInput; + @query('.color-dropdown') dropdown: SlDropdown; + @query('[part~="preview"]') previewButton: HTMLButtonElement; + @query('[part~="trigger"]') trigger: HTMLButtonElement; + + @state() private hasFocus = false; + @state() private isDraggingGridHandle = false; + @state() private isEmpty = false; + @state() private inputValue = ''; + @state() private hue = 0; + @state() private saturation = 100; + @state() private brightness = 100; + @state() private alpha = 100; + + /** + * The current value of the color picker. The value's format will vary based the `format` attribute. To get the value + * in a specific format, use the `getFormattedValue()` method. The value is 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 color picker's label. This will not be displayed, but it will be announced by assistive devices. If you need to + * display HTML, you can use the `label` slot` instead. + */ + @property() label = ''; + + /** + * The format to use. If opacity is enabled, these will translate to HEXA, RGBA, HSLA, and HSVA respectively. The color + * picker will accept user input in any format (including CSS color names) and convert it to the desired format. + */ + @property() format: 'hex' | 'rgb' | 'hsl' | 'hsv' = 'hex'; + + /** Renders the color picker inline rather than in a dropdown. */ + @property({ type: Boolean, reflect: true }) inline = false; + + /** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; + + /** Removes the button that lets users toggle between format. */ + @property({ attribute: 'no-format-toggle', type: Boolean }) noFormatToggle = false; + + /** The name of the form control, submitted as a name/value pair with form data. */ + @property() name = ''; + + /** Disables the color picker. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** + * 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; + + /** Shows the opacity slider. Enabling this will cause the formatted value to be HEXA, RGBA, or HSLA. */ + @property({ type: Boolean }) opacity = false; + + /** By default, values are lowercase. With this attribute, values will be uppercase instead. */ + @property({ type: Boolean }) uppercase = false; + + /** + * One or more predefined color swatches to display as presets in the color picker. Can include any format the color + * picker can parse, including HEX(A), RGB(A), HSL(A), HSV(A), and CSS color names. Each color must be separated by a + * semicolon (`;`). Alternatively, you can pass an array of color values to this property using JavaScript. + */ + @property() swatches: string | string[] = ''; + + /** + * By default, form controls are associated with the nearest containing `` 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 color picker 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; + } + + constructor() { + super(); + this.addEventListener('focusin', this.handleFocusIn); + this.addEventListener('focusout', this.handleFocusOut); + } + + firstUpdated() { + this.input.updateComplete.then(() => { + this.formControlController.updateValidity(); + }); + } + + private handleCopy() { + this.input.select(); + document.execCommand('copy'); + this.previewButton.focus(); + + // Show copied animation + this.previewButton.classList.add('color-picker__preview-color--copied'); + this.previewButton.addEventListener('animationend', () => { + this.previewButton.classList.remove('color-picker__preview-color--copied'); + }); + } + + private handleFocusIn = () => { + this.hasFocus = true; + this.emit('sl-focus'); + }; + + private handleFocusOut = () => { + this.hasFocus = false; + this.emit('sl-blur'); + }; + + private handleFormatToggle() { + const formats = ['hex', 'rgb', 'hsl', 'hsv']; + const nextIndex = (formats.indexOf(this.format) + 1) % formats.length; + this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl' | 'hsv'; + this.setColor(this.value); + this.emit('sl-change'); + this.emit('sl-input'); + } + + private handleAlphaDrag(event: PointerEvent) { + const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__alpha')!; + const handle = container.querySelector('.color-picker__slider-handle')!; + const { width } = container.getBoundingClientRect(); + let oldValue = this.value; + + handle.focus(); + event.preventDefault(); + + drag(container, { + onMove: x => { + this.alpha = clamp((x / width) * 100, 0, 100); + this.syncValues(); + + if (this.value !== oldValue) { + oldValue = this.value; + this.emit('sl-change'); + this.emit('sl-input'); + } + }, + initialEvent: event + }); + } + + private handleHueDrag(event: PointerEvent) { + const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__hue')!; + const handle = container.querySelector('.color-picker__slider-handle')!; + const { width } = container.getBoundingClientRect(); + let oldValue = this.value; + + handle.focus(); + event.preventDefault(); + + drag(container, { + onMove: x => { + this.hue = clamp((x / width) * 360, 0, 360); + this.syncValues(); + + if (this.value !== oldValue) { + oldValue = this.value; + this.emit('sl-change'); + this.emit('sl-input'); + } + }, + initialEvent: event + }); + } + + private handleGridDrag(event: PointerEvent) { + const grid = this.shadowRoot!.querySelector('.color-picker__grid')!; + const handle = grid.querySelector('.color-picker__grid-handle')!; + const { width, height } = grid.getBoundingClientRect(); + let oldValue = this.value; + + handle.focus(); + event.preventDefault(); + + this.isDraggingGridHandle = true; + + drag(grid, { + onMove: (x, y) => { + this.saturation = clamp((x / width) * 100, 0, 100); + this.brightness = clamp(100 - (y / height) * 100, 0, 100); + this.syncValues(); + + if (this.value !== oldValue) { + oldValue = this.value; + this.emit('sl-change'); + this.emit('sl-input'); + } + }, + onStop: () => (this.isDraggingGridHandle = false), + initialEvent: event + }); + } + + private handleAlphaKeyDown(event: KeyboardEvent) { + const increment = event.shiftKey ? 10 : 1; + const oldValue = this.value; + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.alpha = clamp(this.alpha - increment, 0, 100); + this.syncValues(); + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + this.alpha = clamp(this.alpha + increment, 0, 100); + this.syncValues(); + } + + if (event.key === 'Home') { + event.preventDefault(); + this.alpha = 0; + this.syncValues(); + } + + if (event.key === 'End') { + event.preventDefault(); + this.alpha = 100; + this.syncValues(); + } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + } + + private handleHueKeyDown(event: KeyboardEvent) { + const increment = event.shiftKey ? 10 : 1; + const oldValue = this.value; + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.hue = clamp(this.hue - increment, 0, 360); + this.syncValues(); + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + this.hue = clamp(this.hue + increment, 0, 360); + this.syncValues(); + } + + if (event.key === 'Home') { + event.preventDefault(); + this.hue = 0; + this.syncValues(); + } + + if (event.key === 'End') { + event.preventDefault(); + this.hue = 360; + this.syncValues(); + } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + } + + private handleGridKeyDown(event: KeyboardEvent) { + const increment = event.shiftKey ? 10 : 1; + const oldValue = this.value; + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.saturation = clamp(this.saturation - increment, 0, 100); + this.syncValues(); + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + this.saturation = clamp(this.saturation + increment, 0, 100); + this.syncValues(); + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.brightness = clamp(this.brightness + increment, 0, 100); + this.syncValues(); + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.brightness = clamp(this.brightness - increment, 0, 100); + this.syncValues(); + } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + } + + private handleInputChange(event: SlChangeEvent) { + const target = event.target as HTMLInputElement; + const oldValue = this.value; + + // Prevent the 's sl-change event from bubbling up + event.stopPropagation(); + + if (this.input.value) { + this.setColor(target.value); + target.value = this.value; + } else { + this.value = ''; + } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + } + + private handleInputInput(event: SlInputEvent) { + this.formControlController.updateValidity(); + + // Prevent the 's sl-input event from bubbling up + event.stopPropagation(); + } + + private handleInputKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + const oldValue = this.value; + + if (this.input.value) { + this.setColor(this.input.value); + this.input.value = this.value; + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + + setTimeout(() => this.input.select()); + } else { + this.hue = 0; + } + } + } + + private handleInputInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitInvalidEvent(event); + } + + private handleTouchMove(event: TouchEvent) { + event.preventDefault(); + } + + private parseColor(colorString: string) { + const color = new TinyColor(colorString); + if (!color.isValid) { + return null; + } + + const hslColor = color.toHsl(); + // Adjust saturation and lightness from 0-1 to 0-100 + const hsl = { + h: hslColor.h, + s: hslColor.s * 100, + l: hslColor.l * 100, + a: hslColor.a + }; + + const rgb = color.toRgb(); + + const hex = color.toHexString(); + const hexa = color.toHex8String(); + + const hsvColor = color.toHsv(); + // Adjust saturation and value from 0-1 to 0-100 + const hsv = { + h: hsvColor.h, + s: hsvColor.s * 100, + v: hsvColor.v * 100, + a: hsvColor.a + }; + + return { + hsl: { + h: hsl.h, + s: hsl.s, + l: hsl.l, + string: this.setLetterCase(`hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`) + }, + hsla: { + h: hsl.h, + s: hsl.s, + l: hsl.l, + a: hsl.a, + string: this.setLetterCase( + `hsla(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%, ${hsl.a.toFixed(2).toString()})` + ) + }, + hsv: { + h: hsv.h, + s: hsv.s, + v: hsv.v, + string: this.setLetterCase(`hsv(${Math.round(hsv.h)}, ${Math.round(hsv.s)}%, ${Math.round(hsv.v)}%)`) + }, + hsva: { + h: hsv.h, + s: hsv.s, + v: hsv.v, + a: hsv.a, + string: this.setLetterCase( + `hsva(${Math.round(hsv.h)}, ${Math.round(hsv.s)}%, ${Math.round(hsv.v)}%, ${hsv.a.toFixed(2).toString()})` + ) + }, + rgb: { + r: rgb.r, + g: rgb.g, + b: rgb.b, + string: this.setLetterCase(`rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`) + }, + rgba: { + r: rgb.r, + g: rgb.g, + b: rgb.b, + a: rgb.a, + string: this.setLetterCase( + `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${rgb.a.toFixed(2).toString()})` + ) + }, + hex: this.setLetterCase(hex), + hexa: this.setLetterCase(hexa) + }; + } + + private setColor(colorString: string) { + const newColor = this.parseColor(colorString); + + if (newColor === null) { + return false; + } + + this.hue = newColor.hsva.h; + this.saturation = newColor.hsva.s; + this.brightness = newColor.hsva.v; + this.alpha = this.opacity ? newColor.hsva.a * 100 : 100; + + this.syncValues(); + + return true; + } + + private setLetterCase(string: string) { + if (typeof string !== 'string') { + return ''; + } + return this.uppercase ? string.toUpperCase() : string.toLowerCase(); + } + + private async syncValues() { + const currentColor = this.parseColor( + `hsva(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha / 100})` + ); + + if (currentColor === null) { + return; + } + + // Update the value + if (this.format === 'hsl') { + this.inputValue = this.opacity ? currentColor.hsla.string : currentColor.hsl.string; + } else if (this.format === 'rgb') { + this.inputValue = this.opacity ? currentColor.rgba.string : currentColor.rgb.string; + } else if (this.format === 'hsv') { + this.inputValue = this.opacity ? currentColor.hsva.string : currentColor.hsv.string; + } else { + this.inputValue = this.opacity ? currentColor.hexa : currentColor.hex; + } + + // Setting this.value will trigger the watcher which parses the new value. We want to bypass that behavior because + // we've already parsed the color here and conversion/rounding can lead to values changing slightly. When this + // happens, dragging the grid handle becomes jumpy. After the next update, the usual behavior is restored. + this.isSafeValue = true; + this.value = this.inputValue; + await this.updateComplete; + this.isSafeValue = false; + } + + private handleAfterHide() { + this.previewButton.classList.remove('color-picker__preview-color--copied'); + } + + private handleEyeDropper() { + if (!hasEyeDropper) { + return; + } + + const eyeDropper = new EyeDropper(); + + eyeDropper + .open() + .then(colorSelectionResult => { + const oldValue = this.value; + + this.setColor(colorSelectionResult.sRGBHex); + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + }) + .catch(() => { + // The user canceled, do nothing + }); + } + + private selectSwatch(color: string) { + const oldValue = this.value; + + if (!this.disabled) { + this.setColor(color); + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + } + } + + /** Generates a hex string from HSV values. Hue must be 0-360. All other arguments must be 0-100. */ + private getHexString(hue: number, saturation: number, brightness: number, alpha = 100) { + const color = new TinyColor(`hsva(${hue}, ${saturation}, ${brightness}, ${alpha / 100})`); + if (!color.isValid) { + return ''; + } + + return color.toHex8String(); + } + + // Prevents nested components from leaking events + private stopNestedEventPropagation(event: CustomEvent) { + event.stopImmediatePropagation(); + } + + @watch('format', { waitUntilFirstUpdate: true }) + handleFormatChange() { + this.syncValues(); + } + + @watch('opacity', { waitUntilFirstUpdate: true }) + handleOpacityChange() { + this.alpha = 100; + } + + @watch('value') + handleValueChange(oldValue: string | undefined, newValue: string) { + this.isEmpty = !newValue; + + if (!newValue) { + this.hue = 0; + this.saturation = 0; + this.brightness = 100; + this.alpha = 100; + } + + if (!this.isSafeValue) { + const newColor = this.parseColor(newValue); + + if (newColor !== null) { + this.inputValue = this.value; + this.hue = newColor.hsva.h; + this.saturation = newColor.hsva.s; + this.brightness = newColor.hsva.v; + this.alpha = newColor.hsva.a * 100; + this.syncValues(); + } else { + this.inputValue = oldValue ?? ''; + } + } + } + + /** Sets focus on the color picker. */ + focus(options?: FocusOptions) { + if (this.inline) { + this.base.focus(options); + } else { + this.trigger.focus(options); + } + } + + /** Removes focus from the color picker. */ + blur() { + const elementToBlur = this.inline ? this.base : this.trigger; + + if (this.hasFocus) { + // We don't know which element in the color picker has focus, so we'll move it to the trigger or base (inline) and + // blur that instead. This results in document.activeElement becoming the . This doesn't cause another focus + // event because we're using focusin and something inside the color picker already has focus. + elementToBlur.focus({ preventScroll: true }); + elementToBlur.blur(); + } + + if (this.dropdown?.open) { + this.dropdown.hide(); + } + } + + /** Returns the current value as a string in the specified format. */ + getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva' = 'hex') { + const currentColor = this.parseColor( + `hsva(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha / 100})` + ); + + if (currentColor === null) { + return ''; + } + + switch (format) { + case 'hex': + return currentColor.hex; + case 'hexa': + return currentColor.hexa; + case 'rgb': + return currentColor.rgb.string; + case 'rgba': + return currentColor.rgba.string; + case 'hsl': + return currentColor.hsl.string; + case 'hsla': + return currentColor.hsla.string; + case 'hsv': + return currentColor.hsv.string; + case 'hsva': + return currentColor.hsva.string; + default: + return ''; + } + } + + /** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */ + checkValidity() { + return this.input.checkValidity(); + } + + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + + /** Checks for validity and shows the browser's validation message if the control is invalid. */ + reportValidity() { + if (!this.inline && !this.validity.valid) { + // If the input is inline and invalid, show the dropdown so the browser can focus on it + this.dropdown.show(); + this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true }); + + if (!this.disabled) { + // By standards we have to emit a `sl-invalid` event here synchronously. + this.formControlController.emitInvalidEvent(); + } + + return false; + } + + return this.input.reportValidity(); + } + + /** Sets a custom validation message. Pass an empty string to restore validity. */ + setCustomValidity(message: string) { + this.input.setCustomValidity(message); + this.formControlController.updateValidity(); + } + + render() { + const gridHandleX = this.saturation; + const gridHandleY = 100 - this.brightness; + const swatches = Array.isArray(this.swatches) + ? this.swatches // allow arrays for legacy purposes + : this.swatches.split(';').filter(color => color.trim() !== ''); + + const colorPicker = html` +
+ ${this.inline + ? html` + + ${this.label} + + ` + : null} + +
+ +
+ +
+
+
+ +
+ + ${this.opacity + ? html` +
+
+ +
+ ` + : ''} +
+ + +
+ +
+ + + + ${!this.noFormatToggle + ? html` + + ${this.setLetterCase(this.format)} + + ` + : ''} + ${hasEyeDropper + ? html` + + + + ` + : ''} + +
+ + ${swatches.length > 0 + ? html` +
+ ${swatches.map(swatch => { + const parsedColor = this.parseColor(swatch); + + // If we can't parse it, skip it + if (!parsedColor) { + console.error(`Unable to parse swatch color: "${swatch}"`, this); + return ''; + } + + return html` +
this.selectSwatch(swatch)} + @keydown=${(event: KeyboardEvent) => + !this.disabled && event.key === 'Enter' && this.setColor(parsedColor.hexa)} + > +
+
+ `; + })} +
+ ` + : ''} +
+ `; + + // Render inline + if (this.inline) { + return colorPicker; + } + + // Render as a dropdown + return html` + + + ${colorPicker} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-color-picker': SlColorPicker; + } +} diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index eb51b9f75c..e346db40c1 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -1,1067 +1,4 @@ -import '../button-group/button-group.js'; -import '../button/button.js'; -import '../dropdown/dropdown.js'; -import '../icon/icon.js'; -import '../input/input.js'; -import '../visually-hidden/visually-hidden.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 { defaultValue } from '../../internal/default-value.js'; -import { drag } from '../../internal/drag.js'; -import { FormControlController } from '../../internal/form.js'; -import { html } from 'lit'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { LocalizeController } from '../../utilities/localize.js'; -import { styleMap } from 'lit/directives/style-map.js'; -import { TinyColor } from '@ctrl/tinycolor'; -import { watch } from '../../internal/watch.js'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import styles from './color-picker.styles.js'; -import type { CSSResultGroup } from 'lit'; -import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; -import type SlChangeEvent from '../../events/sl-change.js'; -import type SlDropdown from '../dropdown/dropdown.js'; -import type SlInput from '../input/input.js'; -import type SlInputEvent from '../../events/sl-input.js'; - -const hasEyeDropper = 'EyeDropper' in window; - -interface EyeDropperConstructor { - new (): EyeDropperInterface; -} - -interface EyeDropperInterface { - open: () => Promise<{ sRGBHex: string }>; -} - -declare const EyeDropper: EyeDropperConstructor; - -/** - * @summary Color pickers allow the user to select a color. - * @documentation https://shoelace.style/components/color-picker - * @status stable - * @since 2.0 - * - * @dependency sl-button - * @dependency sl-button-group - * @dependency sl-dropdown - * @dependency sl-input - * @dependency sl-visually-hidden - * - * @slot label - The color picker's form label. Alternatively, you can use the `label` attribute. - * - * @event sl-blur - Emitted when the color picker loses focus. - * @event sl-change - Emitted when the color picker's value changes. - * @event sl-focus - Emitted when the color picker receives focus. - * @event sl-input - Emitted when the color picker receives input. - * @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. - * - * @csspart base - The component's base wrapper. - * @csspart trigger - The color picker's dropdown trigger. - * @csspart swatches - The container that holds the swatches. - * @csspart swatch - Each individual swatch. - * @csspart grid - The color grid. - * @csspart grid-handle - The color grid's handle. - * @csspart slider - Hue and opacity sliders. - * @csspart slider-handle - Hue and opacity slider handles. - * @csspart hue-slider - The hue slider. - * @csspart hue-slider-handle - The hue slider's handle. - * @csspart opacity-slider - The opacity slider. - * @csspart opacity-slider-handle - The opacity slider's handle. - * @csspart preview - The preview color. - * @csspart input - The text input. - * @csspart eye-dropper-button - The eye dropper button. - * @csspart eye-dropper-button__base - The eye dropper button's exported `button` part. - * @csspart eye-dropper-button__prefix - The eye dropper button's exported `prefix` part. - * @csspart eye-dropper-button__label - The eye dropper button's exported `label` part. - * @csspart eye-dropper-button__suffix - The eye dropper button's exported `suffix` part. - * @csspart eye-dropper-button__caret - The eye dropper button's exported `caret` part. - * @csspart format-button - The format button. - * @csspart format-button__base - The format button's exported `button` part. - * @csspart format-button__prefix - The format button's exported `prefix` part. - * @csspart format-button__label - The format button's exported `label` part. - * @csspart format-button__suffix - The format button's exported `suffix` part. - * @csspart format-button__caret - The format button's exported `caret` part. - * - * @cssproperty --grid-width - The width of the color grid. - * @cssproperty --grid-height - The height of the color grid. - * @cssproperty --grid-handle-size - The size of the color grid's handle. - * @cssproperty --slider-height - The height of the hue and alpha sliders. - * @cssproperty --slider-handle-size - The diameter of the slider's handle. - * @cssproperty --swatch-size - The size of each predefined color swatch. - */ -@customElement('sl-color-picker') -export default class SlColorPicker extends ShoelaceElement implements ShoelaceFormControl { - static styles: CSSResultGroup = styles; - - private readonly formControlController = new FormControlController(this); - private isSafeValue = false; - private readonly localize = new LocalizeController(this); - - @query('[part~="base"]') base: HTMLElement; - @query('[part~="input"]') input: SlInput; - @query('.color-dropdown') dropdown: SlDropdown; - @query('[part~="preview"]') previewButton: HTMLButtonElement; - @query('[part~="trigger"]') trigger: HTMLButtonElement; - - @state() private hasFocus = false; - @state() private isDraggingGridHandle = false; - @state() private isEmpty = false; - @state() private inputValue = ''; - @state() private hue = 0; - @state() private saturation = 100; - @state() private brightness = 100; - @state() private alpha = 100; - - /** - * The current value of the color picker. The value's format will vary based the `format` attribute. To get the value - * in a specific format, use the `getFormattedValue()` method. The value is 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 color picker's label. This will not be displayed, but it will be announced by assistive devices. If you need to - * display HTML, you can use the `label` slot` instead. - */ - @property() label = ''; - - /** - * The format to use. If opacity is enabled, these will translate to HEXA, RGBA, HSLA, and HSVA respectively. The color - * picker will accept user input in any format (including CSS color names) and convert it to the desired format. - */ - @property() format: 'hex' | 'rgb' | 'hsl' | 'hsv' = 'hex'; - - /** Renders the color picker inline rather than in a dropdown. */ - @property({ type: Boolean, reflect: true }) inline = false; - - /** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */ - @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; - - /** Removes the button that lets users toggle between format. */ - @property({ attribute: 'no-format-toggle', type: Boolean }) noFormatToggle = false; - - /** The name of the form control, submitted as a name/value pair with form data. */ - @property() name = ''; - - /** Disables the color picker. */ - @property({ type: Boolean, reflect: true }) disabled = false; - - /** - * 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; - - /** Shows the opacity slider. Enabling this will cause the formatted value to be HEXA, RGBA, or HSLA. */ - @property({ type: Boolean }) opacity = false; - - /** By default, values are lowercase. With this attribute, values will be uppercase instead. */ - @property({ type: Boolean }) uppercase = false; - - /** - * One or more predefined color swatches to display as presets in the color picker. Can include any format the color - * picker can parse, including HEX(A), RGB(A), HSL(A), HSV(A), and CSS color names. Each color must be separated by a - * semicolon (`;`). Alternatively, you can pass an array of color values to this property using JavaScript. - */ - @property() swatches: string | string[] = ''; - - /** - * By default, form controls are associated with the nearest containing `` 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 color picker 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; - } - - constructor() { - super(); - this.addEventListener('focusin', this.handleFocusIn); - this.addEventListener('focusout', this.handleFocusOut); - } - - firstUpdated() { - this.input.updateComplete.then(() => { - this.formControlController.updateValidity(); - }); - } - - private handleCopy() { - this.input.select(); - document.execCommand('copy'); - this.previewButton.focus(); - - // Show copied animation - this.previewButton.classList.add('color-picker__preview-color--copied'); - this.previewButton.addEventListener('animationend', () => { - this.previewButton.classList.remove('color-picker__preview-color--copied'); - }); - } - - private handleFocusIn = () => { - this.hasFocus = true; - this.emit('sl-focus'); - }; - - private handleFocusOut = () => { - this.hasFocus = false; - this.emit('sl-blur'); - }; - - private handleFormatToggle() { - const formats = ['hex', 'rgb', 'hsl', 'hsv']; - const nextIndex = (formats.indexOf(this.format) + 1) % formats.length; - this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl' | 'hsv'; - this.setColor(this.value); - this.emit('sl-change'); - this.emit('sl-input'); - } - - private handleAlphaDrag(event: PointerEvent) { - const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__alpha')!; - const handle = container.querySelector('.color-picker__slider-handle')!; - const { width } = container.getBoundingClientRect(); - let oldValue = this.value; - - handle.focus(); - event.preventDefault(); - - drag(container, { - onMove: x => { - this.alpha = clamp((x / width) * 100, 0, 100); - this.syncValues(); - - if (this.value !== oldValue) { - oldValue = this.value; - this.emit('sl-change'); - this.emit('sl-input'); - } - }, - initialEvent: event - }); - } - - private handleHueDrag(event: PointerEvent) { - const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__hue')!; - const handle = container.querySelector('.color-picker__slider-handle')!; - const { width } = container.getBoundingClientRect(); - let oldValue = this.value; - - handle.focus(); - event.preventDefault(); - - drag(container, { - onMove: x => { - this.hue = clamp((x / width) * 360, 0, 360); - this.syncValues(); - - if (this.value !== oldValue) { - oldValue = this.value; - this.emit('sl-change'); - this.emit('sl-input'); - } - }, - initialEvent: event - }); - } - - private handleGridDrag(event: PointerEvent) { - const grid = this.shadowRoot!.querySelector('.color-picker__grid')!; - const handle = grid.querySelector('.color-picker__grid-handle')!; - const { width, height } = grid.getBoundingClientRect(); - let oldValue = this.value; - - handle.focus(); - event.preventDefault(); - - this.isDraggingGridHandle = true; - - drag(grid, { - onMove: (x, y) => { - this.saturation = clamp((x / width) * 100, 0, 100); - this.brightness = clamp(100 - (y / height) * 100, 0, 100); - this.syncValues(); - - if (this.value !== oldValue) { - oldValue = this.value; - this.emit('sl-change'); - this.emit('sl-input'); - } - }, - onStop: () => (this.isDraggingGridHandle = false), - initialEvent: event - }); - } - - private handleAlphaKeyDown(event: KeyboardEvent) { - const increment = event.shiftKey ? 10 : 1; - const oldValue = this.value; - - if (event.key === 'ArrowLeft') { - event.preventDefault(); - this.alpha = clamp(this.alpha - increment, 0, 100); - this.syncValues(); - } - - if (event.key === 'ArrowRight') { - event.preventDefault(); - this.alpha = clamp(this.alpha + increment, 0, 100); - this.syncValues(); - } - - if (event.key === 'Home') { - event.preventDefault(); - this.alpha = 0; - this.syncValues(); - } - - if (event.key === 'End') { - event.preventDefault(); - this.alpha = 100; - this.syncValues(); - } - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); - } - } - - private handleHueKeyDown(event: KeyboardEvent) { - const increment = event.shiftKey ? 10 : 1; - const oldValue = this.value; - - if (event.key === 'ArrowLeft') { - event.preventDefault(); - this.hue = clamp(this.hue - increment, 0, 360); - this.syncValues(); - } - - if (event.key === 'ArrowRight') { - event.preventDefault(); - this.hue = clamp(this.hue + increment, 0, 360); - this.syncValues(); - } - - if (event.key === 'Home') { - event.preventDefault(); - this.hue = 0; - this.syncValues(); - } - - if (event.key === 'End') { - event.preventDefault(); - this.hue = 360; - this.syncValues(); - } - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); - } - } - - private handleGridKeyDown(event: KeyboardEvent) { - const increment = event.shiftKey ? 10 : 1; - const oldValue = this.value; - - if (event.key === 'ArrowLeft') { - event.preventDefault(); - this.saturation = clamp(this.saturation - increment, 0, 100); - this.syncValues(); - } - - if (event.key === 'ArrowRight') { - event.preventDefault(); - this.saturation = clamp(this.saturation + increment, 0, 100); - this.syncValues(); - } - - if (event.key === 'ArrowUp') { - event.preventDefault(); - this.brightness = clamp(this.brightness + increment, 0, 100); - this.syncValues(); - } - - if (event.key === 'ArrowDown') { - event.preventDefault(); - this.brightness = clamp(this.brightness - increment, 0, 100); - this.syncValues(); - } - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); - } - } - - private handleInputChange(event: SlChangeEvent) { - const target = event.target as HTMLInputElement; - const oldValue = this.value; - - // Prevent the 's sl-change event from bubbling up - event.stopPropagation(); - - if (this.input.value) { - this.setColor(target.value); - target.value = this.value; - } else { - this.value = ''; - } - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); - } - } - - private handleInputInput(event: SlInputEvent) { - this.formControlController.updateValidity(); - - // Prevent the 's sl-input event from bubbling up - event.stopPropagation(); - } - - private handleInputKeyDown(event: KeyboardEvent) { - if (event.key === 'Enter') { - const oldValue = this.value; - - if (this.input.value) { - this.setColor(this.input.value); - this.input.value = this.value; - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); - } - - setTimeout(() => this.input.select()); - } else { - this.hue = 0; - } - } - } - - private handleInputInvalid(event: Event) { - this.formControlController.setValidity(false); - this.formControlController.emitInvalidEvent(event); - } - - private handleTouchMove(event: TouchEvent) { - event.preventDefault(); - } - - private parseColor(colorString: string) { - const color = new TinyColor(colorString); - if (!color.isValid) { - return null; - } - - const hslColor = color.toHsl(); - // Adjust saturation and lightness from 0-1 to 0-100 - const hsl = { - h: hslColor.h, - s: hslColor.s * 100, - l: hslColor.l * 100, - a: hslColor.a - }; - - const rgb = color.toRgb(); - - const hex = color.toHexString(); - const hexa = color.toHex8String(); - - const hsvColor = color.toHsv(); - // Adjust saturation and value from 0-1 to 0-100 - const hsv = { - h: hsvColor.h, - s: hsvColor.s * 100, - v: hsvColor.v * 100, - a: hsvColor.a - }; - - return { - hsl: { - h: hsl.h, - s: hsl.s, - l: hsl.l, - string: this.setLetterCase(`hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`) - }, - hsla: { - h: hsl.h, - s: hsl.s, - l: hsl.l, - a: hsl.a, - string: this.setLetterCase( - `hsla(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%, ${hsl.a.toFixed(2).toString()})` - ) - }, - hsv: { - h: hsv.h, - s: hsv.s, - v: hsv.v, - string: this.setLetterCase(`hsv(${Math.round(hsv.h)}, ${Math.round(hsv.s)}%, ${Math.round(hsv.v)}%)`) - }, - hsva: { - h: hsv.h, - s: hsv.s, - v: hsv.v, - a: hsv.a, - string: this.setLetterCase( - `hsva(${Math.round(hsv.h)}, ${Math.round(hsv.s)}%, ${Math.round(hsv.v)}%, ${hsv.a.toFixed(2).toString()})` - ) - }, - rgb: { - r: rgb.r, - g: rgb.g, - b: rgb.b, - string: this.setLetterCase(`rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`) - }, - rgba: { - r: rgb.r, - g: rgb.g, - b: rgb.b, - a: rgb.a, - string: this.setLetterCase( - `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${rgb.a.toFixed(2).toString()})` - ) - }, - hex: this.setLetterCase(hex), - hexa: this.setLetterCase(hexa) - }; - } - - private setColor(colorString: string) { - const newColor = this.parseColor(colorString); - - if (newColor === null) { - return false; - } - - this.hue = newColor.hsva.h; - this.saturation = newColor.hsva.s; - this.brightness = newColor.hsva.v; - this.alpha = this.opacity ? newColor.hsva.a * 100 : 100; - - this.syncValues(); - - return true; - } - - private setLetterCase(string: string) { - if (typeof string !== 'string') { - return ''; - } - return this.uppercase ? string.toUpperCase() : string.toLowerCase(); - } - - private async syncValues() { - const currentColor = this.parseColor( - `hsva(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha / 100})` - ); - - if (currentColor === null) { - return; - } - - // Update the value - if (this.format === 'hsl') { - this.inputValue = this.opacity ? currentColor.hsla.string : currentColor.hsl.string; - } else if (this.format === 'rgb') { - this.inputValue = this.opacity ? currentColor.rgba.string : currentColor.rgb.string; - } else if (this.format === 'hsv') { - this.inputValue = this.opacity ? currentColor.hsva.string : currentColor.hsv.string; - } else { - this.inputValue = this.opacity ? currentColor.hexa : currentColor.hex; - } - - // Setting this.value will trigger the watcher which parses the new value. We want to bypass that behavior because - // we've already parsed the color here and conversion/rounding can lead to values changing slightly. When this - // happens, dragging the grid handle becomes jumpy. After the next update, the usual behavior is restored. - this.isSafeValue = true; - this.value = this.inputValue; - await this.updateComplete; - this.isSafeValue = false; - } - - private handleAfterHide() { - this.previewButton.classList.remove('color-picker__preview-color--copied'); - } - - private handleEyeDropper() { - if (!hasEyeDropper) { - return; - } - - const eyeDropper = new EyeDropper(); - - eyeDropper - .open() - .then(colorSelectionResult => { - const oldValue = this.value; - - this.setColor(colorSelectionResult.sRGBHex); - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); - } - }) - .catch(() => { - // The user canceled, do nothing - }); - } - - private selectSwatch(color: string) { - const oldValue = this.value; - - if (!this.disabled) { - this.setColor(color); - - if (this.value !== oldValue) { - this.emit('sl-change'); - this.emit('sl-input'); - } - } - } - - /** Generates a hex string from HSV values. Hue must be 0-360. All other arguments must be 0-100. */ - private getHexString(hue: number, saturation: number, brightness: number, alpha = 100) { - const color = new TinyColor(`hsva(${hue}, ${saturation}, ${brightness}, ${alpha / 100})`); - if (!color.isValid) { - return ''; - } - - return color.toHex8String(); - } - - // Prevents nested components from leaking events - private stopNestedEventPropagation(event: CustomEvent) { - event.stopImmediatePropagation(); - } - - @watch('format', { waitUntilFirstUpdate: true }) - handleFormatChange() { - this.syncValues(); - } - - @watch('opacity', { waitUntilFirstUpdate: true }) - handleOpacityChange() { - this.alpha = 100; - } - - @watch('value') - handleValueChange(oldValue: string | undefined, newValue: string) { - this.isEmpty = !newValue; - - if (!newValue) { - this.hue = 0; - this.saturation = 0; - this.brightness = 100; - this.alpha = 100; - } - - if (!this.isSafeValue) { - const newColor = this.parseColor(newValue); - - if (newColor !== null) { - this.inputValue = this.value; - this.hue = newColor.hsva.h; - this.saturation = newColor.hsva.s; - this.brightness = newColor.hsva.v; - this.alpha = newColor.hsva.a * 100; - this.syncValues(); - } else { - this.inputValue = oldValue ?? ''; - } - } - } - - /** Sets focus on the color picker. */ - focus(options?: FocusOptions) { - if (this.inline) { - this.base.focus(options); - } else { - this.trigger.focus(options); - } - } - - /** Removes focus from the color picker. */ - blur() { - const elementToBlur = this.inline ? this.base : this.trigger; - - if (this.hasFocus) { - // We don't know which element in the color picker has focus, so we'll move it to the trigger or base (inline) and - // blur that instead. This results in document.activeElement becoming the . This doesn't cause another focus - // event because we're using focusin and something inside the color picker already has focus. - elementToBlur.focus({ preventScroll: true }); - elementToBlur.blur(); - } - - if (this.dropdown?.open) { - this.dropdown.hide(); - } - } - - /** Returns the current value as a string in the specified format. */ - getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva' = 'hex') { - const currentColor = this.parseColor( - `hsva(${this.hue}, ${this.saturation}%, ${this.brightness}%, ${this.alpha / 100})` - ); - - if (currentColor === null) { - return ''; - } - - switch (format) { - case 'hex': - return currentColor.hex; - case 'hexa': - return currentColor.hexa; - case 'rgb': - return currentColor.rgb.string; - case 'rgba': - return currentColor.rgba.string; - case 'hsl': - return currentColor.hsl.string; - case 'hsla': - return currentColor.hsla.string; - case 'hsv': - return currentColor.hsv.string; - case 'hsva': - return currentColor.hsva.string; - default: - return ''; - } - } - - /** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */ - checkValidity() { - return this.input.checkValidity(); - } - - /** Gets the associated form, if one exists. */ - getForm(): HTMLFormElement | null { - return this.formControlController.getForm(); - } - - /** Checks for validity and shows the browser's validation message if the control is invalid. */ - reportValidity() { - if (!this.inline && !this.validity.valid) { - // If the input is inline and invalid, show the dropdown so the browser can focus on it - this.dropdown.show(); - this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true }); - - if (!this.disabled) { - // By standards we have to emit a `sl-invalid` event here synchronously. - this.formControlController.emitInvalidEvent(); - } - - return false; - } - - return this.input.reportValidity(); - } - - /** Sets a custom validation message. Pass an empty string to restore validity. */ - setCustomValidity(message: string) { - this.input.setCustomValidity(message); - this.formControlController.updateValidity(); - } - - render() { - const gridHandleX = this.saturation; - const gridHandleY = 100 - this.brightness; - const swatches = Array.isArray(this.swatches) - ? this.swatches // allow arrays for legacy purposes - : this.swatches.split(';').filter(color => color.trim() !== ''); - - const colorPicker = html` -
- ${this.inline - ? html` - - ${this.label} - - ` - : null} - -
- -
- -
-
-
- -
- - ${this.opacity - ? html` -
-
- -
- ` - : ''} -
- - -
- -
- - - - ${!this.noFormatToggle - ? html` - - ${this.setLetterCase(this.format)} - - ` - : ''} - ${hasEyeDropper - ? html` - - - - ` - : ''} - -
- - ${swatches.length > 0 - ? html` -
- ${swatches.map(swatch => { - const parsedColor = this.parseColor(swatch); - - // If we can't parse it, skip it - if (!parsedColor) { - console.error(`Unable to parse swatch color: "${swatch}"`, this); - return ''; - } - - return html` -
this.selectSwatch(swatch)} - @keydown=${(event: KeyboardEvent) => - !this.disabled && event.key === 'Enter' && this.setColor(parsedColor.hexa)} - > -
-
- `; - })} -
- ` - : ''} -
- `; - - // Render inline - if (this.inline) { - return colorPicker; - } - - // Render as a dropdown - return html` - - - ${colorPicker} - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-color-picker': SlColorPicker; - } -} +import SlColorPicker from './color-picker.component.js'; +export * from './color-picker.component.js'; +export default SlColorPicker; +SlColorPicker.define('sl-color-picker'); diff --git a/src/components/details/details.component.ts b/src/components/details/details.component.ts new file mode 100644 index 0000000000..88a3f40a36 --- /dev/null +++ b/src/components/details/details.component.ts @@ -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 ``. + * @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with ``. + * + * @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` +
+ + +
+ +
+
+ `; + } +} + +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; + } +} diff --git a/src/components/details/details.ts b/src/components/details/details.ts index 44b6c7a642..ecaf46adfd 100644 --- a/src/components/details/details.ts +++ b/src/components/details/details.ts @@ -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 ``. - * @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with ``. - * - * @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` -
- - -
- -
-
- `; - } -} - -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'); diff --git a/src/components/dialog/dialog.component.ts b/src/components/dialog/dialog.component.ts new file mode 100644 index 0000000000..805f509afe --- /dev/null +++ b/src/components/dialog/dialog.component.ts @@ -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 ``. + * @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 ``. + * @csspart title - The dialog's title. + * @csspart close-button - The close button, an ``. + * @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` +
+
this.requestClose('overlay')} tabindex="-1">
+ + +
+ `; + } +} + +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; + } +} diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index c7c26ecc1d..8eb2926fe4 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -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 ``. - * @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 ``. - * @csspart title - The dialog's title. - * @csspart close-button - The close button, an ``. - * @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` -
-
this.requestClose('overlay')} tabindex="-1">
- - -
- `; - } -} - -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'); diff --git a/src/components/divider/divider.component.ts b/src/components/divider/divider.component.ts new file mode 100644 index 0000000000..c2f2b4105c --- /dev/null +++ b/src/components/divider/divider.component.ts @@ -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; + } +} diff --git a/src/components/divider/divider.ts b/src/components/divider/divider.ts index 0a7bd0a196..c0f75a9530 100644 --- a/src/components/divider/divider.ts +++ b/src/components/divider/divider.ts @@ -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'); diff --git a/src/components/drawer/drawer.component.ts b/src/components/drawer/drawer.component.ts new file mode 100644 index 0000000000..76b3d5f72e --- /dev/null +++ b/src/components/drawer/drawer.component.ts @@ -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 ``. + * @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 ``. + * @csspart title - The drawer's title. + * @csspart close-button - The close button, an ``. + * @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` +
+
this.requestClose('overlay')} tabindex="-1">
+ + +
+ `; + } +} + +// 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; + } +} diff --git a/src/components/drawer/drawer.ts b/src/components/drawer/drawer.ts index 0eb0cacd11..3f5dbdc3c9 100644 --- a/src/components/drawer/drawer.ts +++ b/src/components/drawer/drawer.ts @@ -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 ``. - * @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 ``. - * @csspart title - The drawer's title. - * @csspart close-button - The close button, an ``. - * @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` -
-
this.requestClose('overlay')} tabindex="-1">
- - -
- `; - } -} - -// 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'); diff --git a/src/components/dropdown/dropdown.component.ts b/src/components/dropdown/dropdown.component.ts new file mode 100644 index 0000000000..9d99d402ee --- /dev/null +++ b/src/components/dropdown/dropdown.component.ts @@ -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 `` 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 is a
+ ` + : ''} + ${this.passwordToggle && !this.disabled + ? html` + + ` + : ''} + + + + +
+
+ +
+ ${this.helpText} +
+ + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-input': SlInput; + } +} diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 1bfca59f3c..2e980454d1 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -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 `` 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 `` 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 `` 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 `` 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` -
- - -
-
- - - - - - - ${hasClearIcon - ? html` - - ` - : ''} - ${this.passwordToggle && !this.disabled - ? html` - - ` - : ''} - - - - -
-
- -
- ${this.helpText} -
-
- `; - } -} - -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'); diff --git a/src/components/menu-item/menu-item.component.ts b/src/components/menu-item/menu-item.component.ts new file mode 100644 index 0000000000..c2dd979852 --- /dev/null +++ b/src/components/menu-item/menu-item.component.ts @@ -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` +
+ + + + + + + + + + + + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-menu-item': SlMenuItem; + } +} diff --git a/src/components/menu-item/menu-item.ts b/src/components/menu-item/menu-item.ts index 2d95eb889d..9cdea14cb8 100644 --- a/src/components/menu-item/menu-item.ts +++ b/src/components/menu-item/menu-item.ts @@ -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` -
- - - - - - - - - - - - - -
- `; - } -} - -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'); diff --git a/src/components/menu-label/menu-label.component.ts b/src/components/menu-label/menu-label.component.ts new file mode 100644 index 0000000000..1a8d4b8f8b --- /dev/null +++ b/src/components/menu-label/menu-label.component.ts @@ -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` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-menu-label': SlMenuLabel; + } +} diff --git a/src/components/menu-label/menu-label.ts b/src/components/menu-label/menu-label.ts index 00ab1d9486..0a2eabb546 100644 --- a/src/components/menu-label/menu-label.ts +++ b/src/components/menu-label/menu-label.ts @@ -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` `; - } -} - -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'); diff --git a/src/components/menu/menu.component.ts b/src/components/menu/menu.component.ts new file mode 100644 index 0000000000..ca2367a4c6 --- /dev/null +++ b/src/components/menu/menu.component.ts @@ -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` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-menu': SlMenu; + } +} diff --git a/src/components/menu/menu.ts b/src/components/menu/menu.ts index 3091f834fe..7702f0b0b4 100644 --- a/src/components/menu/menu.ts +++ b/src/components/menu/menu.ts @@ -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` - - `; - } -} - -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'); diff --git a/src/components/mutation-observer/mutation-observer.component.ts b/src/components/mutation-observer/mutation-observer.component.ts new file mode 100644 index 0000000000..a51ae88306 --- /dev/null +++ b/src/components/mutation-observer/mutation-observer.component.ts @@ -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` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-mutation-observer': SlMutationObserver; + } +} diff --git a/src/components/mutation-observer/mutation-observer.ts b/src/components/mutation-observer/mutation-observer.ts index c0109feaba..150cbc91e4 100644 --- a/src/components/mutation-observer/mutation-observer.ts +++ b/src/components/mutation-observer/mutation-observer.ts @@ -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` `; - } -} - -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'); diff --git a/src/components/option/option.component.ts b/src/components/option/option.component.ts new file mode 100644 index 0000000000..9b6cf4e429 --- /dev/null +++ b/src/components/option/option.component.ts @@ -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 `` 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` +
+ + + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-option': SlOption; + } +} diff --git a/src/components/option/option.ts b/src/components/option/option.ts index 40159adfab..788e23266d 100644 --- a/src/components/option/option.ts +++ b/src/components/option/option.ts @@ -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 `` 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` -
- - - - -
- `; - } -} - -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'); diff --git a/src/components/popup/popup.component.ts b/src/components/popup/popup.component.ts new file mode 100644 index 0000000000..365622fe82 --- /dev/null +++ b/src/components/popup/popup.component.ts @@ -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 | 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) { + 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('[slot="anchor"]'); + } + + // If the anchor is a , 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 { + 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` + + +
+ + ${this.arrow ? html`` : ''} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-popup': SlPopup; + } +} diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts index 438ba7ba18..74b6076ba6 100644 --- a/src/components/popup/popup.ts +++ b/src/components/popup/popup.ts @@ -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 | 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) { - 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('[slot="anchor"]'); - } - - // If the anchor is a , 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 { - 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` - - -
- - ${this.arrow ? html`` : ''} -
- `; - } -} - -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'); diff --git a/src/components/progress-bar/progress-bar.component.ts b/src/components/progress-bar/progress-bar.component.ts new file mode 100644 index 0000000000..693461b64b --- /dev/null +++ b/src/components/progress-bar/progress-bar.component.ts @@ -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` +
0 ? this.label : this.localize.term('progress')} + aria-valuemin="0" + aria-valuemax="100" + aria-valuenow=${this.indeterminate ? 0 : this.value} + > +
+ ${!this.indeterminate ? html` ` : ''} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-progress-bar': SlProgressBar; + } +} diff --git a/src/components/progress-bar/progress-bar.ts b/src/components/progress-bar/progress-bar.ts index b2867c63fa..993e38ce56 100644 --- a/src/components/progress-bar/progress-bar.ts +++ b/src/components/progress-bar/progress-bar.ts @@ -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` -
0 ? this.label : this.localize.term('progress')} - aria-valuemin="0" - aria-valuemax="100" - aria-valuenow=${this.indeterminate ? 0 : this.value} - > -
- ${!this.indeterminate ? html` ` : ''} -
-
- `; - } -} - -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'); diff --git a/src/components/progress-ring/progress-ring.component.ts b/src/components/progress-ring/progress-ring.component.ts new file mode 100644 index 0000000000..b3a118ccce --- /dev/null +++ b/src/components/progress-ring/progress-ring.component.ts @@ -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) { + 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` +
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}" + > + + + + + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-progress-ring': SlProgressRing; + } +} diff --git a/src/components/progress-ring/progress-ring.ts b/src/components/progress-ring/progress-ring.ts index 6740e352d5..af7b545cf4 100644 --- a/src/components/progress-ring/progress-ring.ts +++ b/src/components/progress-ring/progress-ring.ts @@ -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) { - 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` -
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}" - > - - - - - - -
- `; - } -} - -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'); diff --git a/src/components/qr-code/qr-code.component.ts b/src/components/qr-code/qr-code.component.ts new file mode 100644 index 0000000000..c4366852d9 --- /dev/null +++ b/src/components/qr-code/qr-code.component.ts @@ -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` + 0 ? this.label : this.value} + style=${styleMap({ + width: `${this.size}px`, + height: `${this.size}px` + })} + > + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-qr-code': SlQrCode; + } +} diff --git a/src/components/qr-code/qr-code.ts b/src/components/qr-code/qr-code.ts index 875c96f76f..df396382fd 100644 --- a/src/components/qr-code/qr-code.ts +++ b/src/components/qr-code/qr-code.ts @@ -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` - 0 ? this.label : this.value} - style=${styleMap({ - width: `${this.size}px`, - height: `${this.size}px` - })} - > - `; - } -} - -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'); diff --git a/src/components/radio-button/radio-button.component.ts b/src/components/radio-button/radio-button.component.ts new file mode 100644 index 0000000000..ac3167a9fa --- /dev/null +++ b/src/components/radio-button/radio-button.component.ts @@ -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 ` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-radio-button': SlRadioButton; + } +} diff --git a/src/components/radio-button/radio-button.ts b/src/components/radio-button/radio-button.ts index f87256cd7a..93f82fe7a9 100644 --- a/src/components/radio-button/radio-button.ts +++ b/src/components/radio-button/radio-button.ts @@ -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 ` - - `; - } -} - -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'); diff --git a/src/components/radio-group/radio-group.component.ts b/src/components/radio-group/radio-group.component.ts new file mode 100644 index 0000000000..5f6afb319a --- /dev/null +++ b/src/components/radio-group/radio-group.component.ts @@ -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 `` or `` 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 `` 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('sl-radio, sl-radio-button')]; + } + + private handleRadioClick(event: MouseEvent) { + const target = (event.target as HTMLElement).closest('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 or 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` + + + + `; + + return html` +
+ + +
+
+
${this.errorMessage}
+ +
+ + ${this.hasButtonGroup + ? html` + + ${defaultSlot} + + ` + : defaultSlot} +
+ +
+ ${this.helpText} +
+
+ `; + /* eslint-enable lit-a11y/click-events-have-key-events */ + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-radio-group': SlRadioGroup; + } +} diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index 7167bd55fd..37aea9d30c 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -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 `` or `` 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 `` 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('sl-radio, sl-radio-button')]; - } - - private handleRadioClick(event: MouseEvent) { - const target = (event.target as HTMLElement).closest('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 or 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` - - - - `; - - return html` -
- - -
-
-
${this.errorMessage}
- -
- - ${this.hasButtonGroup - ? html` - - ${defaultSlot} - - ` - : defaultSlot} -
- -
- ${this.helpText} -
-
- `; - /* 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'); diff --git a/src/components/radio/radio.component.ts b/src/components/radio/radio.component.ts new file mode 100644 index 0000000000..888939231a --- /dev/null +++ b/src/components/radio/radio.component.ts @@ -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 `` 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` + + + ${this.checked + ? html` ` + : ''} + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-radio': SlRadio; + } +} diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index c8bf8dc574..ff0918c338 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -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 `` 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` - - - ${this.checked - ? html` ` - : ''} - - - - - `; - } -} - -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'); diff --git a/src/components/range/range.component.ts b/src/components/range/range.component.ts new file mode 100644 index 0000000000..40cbe998cb --- /dev/null +++ b/src/components/range/range.component.ts @@ -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 `` 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 `` 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` +
+ + +
+
+ + ${this.tooltip !== 'none' && !this.disabled + ? html` + + ${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value} + + ` + : ''} +
+
+ +
+ ${this.helpText} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-range': SlRange; + } +} diff --git a/src/components/range/range.ts b/src/components/range/range.ts index a69787d501..e48cbff9f1 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -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 `` 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 `` 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` -
- - -
-
- - ${this.tooltip !== 'none' && !this.disabled - ? html` - - ${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value} - - ` - : ''} -
-
- -
- ${this.helpText} -
-
- `; - } -} - -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'); diff --git a/src/components/rating/rating.component.ts b/src/components/rating/rating.component.ts new file mode 100644 index 0000000000..ba981ce24f --- /dev/null +++ b/src/components/rating/rating.component.ts @@ -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 `` elements. + */ + @property() getSymbol: (value: number) => string = () => ''; + + 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` +
+ + ${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` + +
+ ${unsafeHTML(this.getSymbol(index + 1))} +
+
+ ${unsafeHTML(this.getSymbol(index + 1))} +
+
+ `; + } + + return html` + = index + 1 + })} + role="presentation" + @mouseenter=${this.handleMouseEnter} + > + ${unsafeHTML(this.getSymbol(index + 1))} + + `; + })} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-rating': SlRating; + } +} diff --git a/src/components/rating/rating.ts b/src/components/rating/rating.ts index 227593bfe6..f71198df22 100644 --- a/src/components/rating/rating.ts +++ b/src/components/rating/rating.ts @@ -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 `` elements. - */ - @property() getSymbol: (value: number) => string = () => ''; - - 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` -
- - ${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` - -
- ${unsafeHTML(this.getSymbol(index + 1))} -
-
- ${unsafeHTML(this.getSymbol(index + 1))} -
-
- `; - } - - return html` - = index + 1 - })} - role="presentation" - @mouseenter=${this.handleMouseEnter} - > - ${unsafeHTML(this.getSymbol(index + 1))} - - `; - })} -
-
- `; - } -} - -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'); diff --git a/src/components/relative-time/relative-time.component.ts b/src/components/relative-time/relative-time.component.ts new file mode 100644 index 0000000000..315e8439ae --- /dev/null +++ b/src/components/relative-time/relative-time.component.ts @@ -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` `; + } +} + +// 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; + } +} diff --git a/src/components/relative-time/relative-time.ts b/src/components/relative-time/relative-time.ts index 97b9c4ffd5..16a024b9ab 100644 --- a/src/components/relative-time/relative-time.ts +++ b/src/components/relative-time/relative-time.ts @@ -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` `; - } -} - -// 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'); diff --git a/src/components/resize-observer/resize-observer.component.ts b/src/components/resize-observer/resize-observer.component.ts new file mode 100644 index 0000000000..26db868909 --- /dev/null +++ b/src/components/resize-observer/resize-observer.component.ts @@ -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` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-resize-observer': SlResizeObserver; + } +} diff --git a/src/components/resize-observer/resize-observer.ts b/src/components/resize-observer/resize-observer.ts index 4c370e40a6..95a85e2329 100644 --- a/src/components/resize-observer/resize-observer.ts +++ b/src/components/resize-observer/resize-observer.ts @@ -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` `; - } -} - -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'); diff --git a/src/components/select/select.component.ts b/src/components/select/select.component.ts new file mode 100644 index 0000000000..8a74a1d4f7 --- /dev/null +++ b/src/components/select/select.component.ts @@ -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 `` elements. You can use `` 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 `` 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 `` 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 ) + 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 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 elements + private getAllOptions() { + return [...this.querySelectorAll('sl-option')]; + } + + // Gets the first element + private getFirstOption() { + return this.querySelector('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` +
+ + +
+ +
+ + + + + ${this.multiple + ? html` +
+ ${this.selectedOptions.map((option, index) => { + if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) { + return html` + this.handleTagRemove(event, option)} + > + ${option.getTextLabel()} + + `; + } else if (index === this.maxOptionsVisible) { + return html` +${this.selectedOptions.length - index} `; + } else { + return null; + } + })} +
+ ` + : ''} + + this.focus()} + @invalid=${this.handleInvalid} + /> + + ${hasClearIcon + ? html` + + ` + : ''} + + + + +
+ +
+ +
+
+
+ +
+ ${this.helpText} +
+
+ `; + } +} + +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; + } +} diff --git a/src/components/select/select.ts b/src/components/select/select.ts index e1b4a4198f..ec840e3db2 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -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 `` elements. You can use `` 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 `` 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 `` 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 ) - 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 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 elements - private getAllOptions() { - return [...this.querySelectorAll('sl-option')]; - } - - // Gets the first element - private getFirstOption() { - return this.querySelector('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` -
- - -
- -
- - - - - ${this.multiple - ? html` -
- ${this.selectedOptions.map((option, index) => { - if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) { - return html` - this.handleTagRemove(event, option)} - > - ${option.getTextLabel()} - - `; - } else if (index === this.maxOptionsVisible) { - return html` +${this.selectedOptions.length - index} `; - } else { - return null; - } - })} -
- ` - : ''} - - this.focus()} - @invalid=${this.handleInvalid} - /> - - ${hasClearIcon - ? html` - - ` - : ''} - - - - -
- -
- -
-
-
- -
- ${this.helpText} -
-
- `; - } -} - -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'); diff --git a/src/components/skeleton/skeleton.component.ts b/src/components/skeleton/skeleton.component.ts new file mode 100644 index 0000000000..0813212c6d --- /dev/null +++ b/src/components/skeleton/skeleton.component.ts @@ -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` +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-skeleton': SlSkeleton; + } +} diff --git a/src/components/skeleton/skeleton.ts b/src/components/skeleton/skeleton.ts index f99ddc4f24..2a4b90cd28 100644 --- a/src/components/skeleton/skeleton.ts +++ b/src/components/skeleton/skeleton.ts @@ -1,48 +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 './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. - */ -@customElement('sl-skeleton') -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` -
-
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-skeleton': SlSkeleton; - } -} +import SlSkeleton from './skeleton.component.js'; +export * from './skeleton.component.js'; +export default SlSkeleton; +SlSkeleton.define('sl-skeleton'); diff --git a/src/components/spinner/spinner.component.ts b/src/components/spinner/spinner.component.ts new file mode 100644 index 0000000000..d9868632ff --- /dev/null +++ b/src/components/spinner/spinner.component.ts @@ -0,0 +1,39 @@ +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './spinner.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Spinners are used to show the progress of an indeterminate operation. + * @documentation https://shoelace.style/components/spinner + * @status stable + * @since 2.0 + * + * @csspart base - The component's base wrapper. + * + * @cssproperty --track-width - The width of the track. + * @cssproperty --track-color - The color of the track. + * @cssproperty --indicator-color - The color of the spinner's indicator. + * @cssproperty --speed - The time it takes for the spinner to complete one animation cycle. + */ +export default class SlSpinner extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + private readonly localize = new LocalizeController(this); + + render() { + return html` + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-spinner': SlSpinner; + } +} diff --git a/src/components/spinner/spinner.ts b/src/components/spinner/spinner.ts index 1a4896c26b..f38b25d567 100644 --- a/src/components/spinner/spinner.ts +++ b/src/components/spinner/spinner.ts @@ -1,41 +1,4 @@ -import { customElement } from 'lit/decorators.js'; -import { html } from 'lit'; -import { LocalizeController } from '../../utilities/localize.js'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import styles from './spinner.styles.js'; -import type { CSSResultGroup } from 'lit'; - -/** - * @summary Spinners are used to show the progress of an indeterminate operation. - * @documentation https://shoelace.style/components/spinner - * @status stable - * @since 2.0 - * - * @csspart base - The component's base wrapper. - * - * @cssproperty --track-width - The width of the track. - * @cssproperty --track-color - The color of the track. - * @cssproperty --indicator-color - The color of the spinner's indicator. - * @cssproperty --speed - The time it takes for the spinner to complete one animation cycle. - */ -@customElement('sl-spinner') -export default class SlSpinner extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - private readonly localize = new LocalizeController(this); - - render() { - return html` - - - - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-spinner': SlSpinner; - } -} +import SlSpinner from './spinner.component.js'; +export * from './spinner.component.js'; +export default SlSpinner; +SlSpinner.define('sl-spinner'); diff --git a/src/components/split-panel/split-panel.component.ts b/src/components/split-panel/split-panel.component.ts new file mode 100644 index 0000000000..c8b658c494 --- /dev/null +++ b/src/components/split-panel/split-panel.component.ts @@ -0,0 +1,276 @@ +import { clamp } from '../../internal/math.js'; +import { drag } from '../../internal/drag.js'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query } from 'lit/decorators.js'; +import { watch } from '../../internal/watch.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './split-panel.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Split panels display two adjacent panels, allowing the user to reposition them. + * @documentation https://shoelace.style/components/split-panel + * @status stable + * @since 2.0 + * + * @event sl-reposition - Emitted when the divider's position changes. + * + * @slot start - Content to place in the start panel. + * @slot end - Content to place in the end panel. + * @slot divider - The divider. Useful for slotting in a custom icon that renders as a handle. + * + * @csspart start - The start panel. + * @csspart end - The end panel. + * @csspart panel - Targets both the start and end panels. + * @csspart divider - The divider that separates the start and end panels. + * + * @cssproperty [--divider-width=4px] - The width of the visible divider. + * @cssproperty [--divider-hit-area=12px] - The invisible region around the divider where dragging can occur. This is + * usually wider than the divider to facilitate easier dragging. + * @cssproperty [--min=0] - The minimum allowed size of the primary panel. + * @cssproperty [--max=100%] - The maximum allowed size of the primary panel. + */ +export default class SlSplitPanel extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + private cachedPositionInPixels: number; + private readonly localize = new LocalizeController(this); + private resizeObserver: ResizeObserver; + private size: number; + + @query('.divider') divider: HTMLElement; + + /** + * The current position of the divider from the primary panel's edge as a percentage 0-100. Defaults to 50% of the + * container's initial size. + */ + @property({ type: Number, reflect: true }) position = 50; + + /** The current position of the divider from the primary panel's edge in pixels. */ + @property({ attribute: 'position-in-pixels', type: Number }) positionInPixels: number; + + /** Draws the split panel in a vertical orientation with the start and end panels stacked. */ + @property({ type: Boolean, reflect: true }) vertical = false; + + /** Disables resizing. Note that the position may still change as a result of resizing the host element. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** + * If no primary panel is designated, both panels will resize proportionally when the host element is resized. If a + * primary panel is designated, it will maintain its size and the other panel will grow or shrink as needed when the + * host element is resized. + */ + @property() primary?: 'start' | 'end'; + + /** + * One or more space-separated values at which the divider should snap. Values can be in pixels or percentages, e.g. + * `"100px 50%"`. + */ + @property() snap?: string; + + /** How close the divider must be to a snap point until snapping occurs. */ + @property({ type: Number, attribute: 'snap-threshold' }) snapThreshold = 12; + + connectedCallback() { + super.connectedCallback(); + this.resizeObserver = new ResizeObserver(entries => this.handleResize(entries)); + this.updateComplete.then(() => this.resizeObserver.observe(this)); + + this.detectSize(); + this.cachedPositionInPixels = this.percentageToPixels(this.position); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.resizeObserver.unobserve(this); + } + + private detectSize() { + const { width, height } = this.getBoundingClientRect(); + this.size = this.vertical ? height : width; + } + + private percentageToPixels(value: number) { + return this.size * (value / 100); + } + + private pixelsToPercentage(value: number) { + return (value / this.size) * 100; + } + + private handleDrag(event: PointerEvent) { + const isRtl = this.localize.dir() === 'rtl'; + + if (this.disabled) { + return; + } + + // Prevent text selection when dragging + if (event.cancelable) { + event.preventDefault(); + } + + drag(this, { + onMove: (x, y) => { + let newPositionInPixels = this.vertical ? y : x; + + // Flip for end panels + if (this.primary === 'end') { + newPositionInPixels = this.size - newPositionInPixels; + } + + // Check snap points + if (this.snap) { + const snaps = this.snap.split(' '); + + snaps.forEach(value => { + let snapPoint: number; + + if (value.endsWith('%')) { + snapPoint = this.size * (parseFloat(value) / 100); + } else { + snapPoint = parseFloat(value); + } + + if (isRtl && !this.vertical) { + snapPoint = this.size - snapPoint; + } + + if ( + newPositionInPixels >= snapPoint - this.snapThreshold && + newPositionInPixels <= snapPoint + this.snapThreshold + ) { + newPositionInPixels = snapPoint; + } + }); + } + + this.position = clamp(this.pixelsToPercentage(newPositionInPixels), 0, 100); + }, + initialEvent: event + }); + } + + private handleKeyDown(event: KeyboardEvent) { + if (this.disabled) { + return; + } + + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { + let newPosition = this.position; + const incr = (event.shiftKey ? 10 : 1) * (this.primary === 'end' ? -1 : 1); + + event.preventDefault(); + + if ((event.key === 'ArrowLeft' && !this.vertical) || (event.key === 'ArrowUp' && this.vertical)) { + newPosition -= incr; + } + + if ((event.key === 'ArrowRight' && !this.vertical) || (event.key === 'ArrowDown' && this.vertical)) { + newPosition += incr; + } + + if (event.key === 'Home') { + newPosition = this.primary === 'end' ? 100 : 0; + } + + if (event.key === 'End') { + newPosition = this.primary === 'end' ? 0 : 100; + } + + this.position = clamp(newPosition, 0, 100); + } + } + + private handleResize(entries: ResizeObserverEntry[]) { + const { width, height } = entries[0].contentRect; + this.size = this.vertical ? height : width; + + // Resize when a primary panel is set + if (this.primary) { + this.position = this.pixelsToPercentage(this.cachedPositionInPixels); + } + } + + @watch('position') + handlePositionChange() { + this.cachedPositionInPixels = this.percentageToPixels(this.position); + this.positionInPixels = this.percentageToPixels(this.position); + this.emit('sl-reposition'); + } + + @watch('positionInPixels') + handlePositionInPixelsChange() { + this.position = this.pixelsToPercentage(this.positionInPixels); + } + + @watch('vertical') + handleVerticalChange() { + this.detectSize(); + } + + render() { + const gridTemplate = this.vertical ? 'gridTemplateRows' : 'gridTemplateColumns'; + const gridTemplateAlt = this.vertical ? 'gridTemplateColumns' : 'gridTemplateRows'; + const isRtl = this.localize.dir() === 'rtl'; + const primary = ` + clamp( + 0%, + clamp( + var(--min), + ${this.position}% - var(--divider-width) / 2, + var(--max) + ), + calc(100% - var(--divider-width)) + ) + `; + const secondary = 'auto'; + + if (this.primary === 'end') { + if (isRtl && !this.vertical) { + this.style[gridTemplate] = `${primary} var(--divider-width) ${secondary}`; + } else { + this.style[gridTemplate] = `${secondary} var(--divider-width) ${primary}`; + } + } else { + if (isRtl && !this.vertical) { + this.style[gridTemplate] = `${secondary} var(--divider-width) ${primary}`; + } else { + this.style[gridTemplate] = `${primary} var(--divider-width) ${secondary}`; + } + } + + // Unset the alt grid template property + this.style[gridTemplateAlt] = ''; + + return html` + + + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-split-panel': SlSplitPanel; + } +} diff --git a/src/components/split-panel/split-panel.ts b/src/components/split-panel/split-panel.ts index 2cb9c42108..a3248810bd 100644 --- a/src/components/split-panel/split-panel.ts +++ b/src/components/split-panel/split-panel.ts @@ -1,277 +1,4 @@ -import { clamp } from '../../internal/math.js'; -import { customElement, property, query } from 'lit/decorators.js'; -import { drag } from '../../internal/drag.js'; -import { html } from 'lit'; -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 './split-panel.styles.js'; -import type { CSSResultGroup } from 'lit'; - -/** - * @summary Split panels display two adjacent panels, allowing the user to reposition them. - * @documentation https://shoelace.style/components/split-panel - * @status stable - * @since 2.0 - * - * @event sl-reposition - Emitted when the divider's position changes. - * - * @slot start - Content to place in the start panel. - * @slot end - Content to place in the end panel. - * @slot divider - The divider. Useful for slotting in a custom icon that renders as a handle. - * - * @csspart start - The start panel. - * @csspart end - The end panel. - * @csspart panel - Targets both the start and end panels. - * @csspart divider - The divider that separates the start and end panels. - * - * @cssproperty [--divider-width=4px] - The width of the visible divider. - * @cssproperty [--divider-hit-area=12px] - The invisible region around the divider where dragging can occur. This is - * usually wider than the divider to facilitate easier dragging. - * @cssproperty [--min=0] - The minimum allowed size of the primary panel. - * @cssproperty [--max=100%] - The maximum allowed size of the primary panel. - */ -@customElement('sl-split-panel') -export default class SlSplitPanel extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - private cachedPositionInPixels: number; - private readonly localize = new LocalizeController(this); - private resizeObserver: ResizeObserver; - private size: number; - - @query('.divider') divider: HTMLElement; - - /** - * The current position of the divider from the primary panel's edge as a percentage 0-100. Defaults to 50% of the - * container's initial size. - */ - @property({ type: Number, reflect: true }) position = 50; - - /** The current position of the divider from the primary panel's edge in pixels. */ - @property({ attribute: 'position-in-pixels', type: Number }) positionInPixels: number; - - /** Draws the split panel in a vertical orientation with the start and end panels stacked. */ - @property({ type: Boolean, reflect: true }) vertical = false; - - /** Disables resizing. Note that the position may still change as a result of resizing the host element. */ - @property({ type: Boolean, reflect: true }) disabled = false; - - /** - * If no primary panel is designated, both panels will resize proportionally when the host element is resized. If a - * primary panel is designated, it will maintain its size and the other panel will grow or shrink as needed when the - * host element is resized. - */ - @property() primary?: 'start' | 'end'; - - /** - * One or more space-separated values at which the divider should snap. Values can be in pixels or percentages, e.g. - * `"100px 50%"`. - */ - @property() snap?: string; - - /** How close the divider must be to a snap point until snapping occurs. */ - @property({ type: Number, attribute: 'snap-threshold' }) snapThreshold = 12; - - connectedCallback() { - super.connectedCallback(); - this.resizeObserver = new ResizeObserver(entries => this.handleResize(entries)); - this.updateComplete.then(() => this.resizeObserver.observe(this)); - - this.detectSize(); - this.cachedPositionInPixels = this.percentageToPixels(this.position); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this.resizeObserver.unobserve(this); - } - - private detectSize() { - const { width, height } = this.getBoundingClientRect(); - this.size = this.vertical ? height : width; - } - - private percentageToPixels(value: number) { - return this.size * (value / 100); - } - - private pixelsToPercentage(value: number) { - return (value / this.size) * 100; - } - - private handleDrag(event: PointerEvent) { - const isRtl = this.localize.dir() === 'rtl'; - - if (this.disabled) { - return; - } - - // Prevent text selection when dragging - if (event.cancelable) { - event.preventDefault(); - } - - drag(this, { - onMove: (x, y) => { - let newPositionInPixels = this.vertical ? y : x; - - // Flip for end panels - if (this.primary === 'end') { - newPositionInPixels = this.size - newPositionInPixels; - } - - // Check snap points - if (this.snap) { - const snaps = this.snap.split(' '); - - snaps.forEach(value => { - let snapPoint: number; - - if (value.endsWith('%')) { - snapPoint = this.size * (parseFloat(value) / 100); - } else { - snapPoint = parseFloat(value); - } - - if (isRtl && !this.vertical) { - snapPoint = this.size - snapPoint; - } - - if ( - newPositionInPixels >= snapPoint - this.snapThreshold && - newPositionInPixels <= snapPoint + this.snapThreshold - ) { - newPositionInPixels = snapPoint; - } - }); - } - - this.position = clamp(this.pixelsToPercentage(newPositionInPixels), 0, 100); - }, - initialEvent: event - }); - } - - private handleKeyDown(event: KeyboardEvent) { - if (this.disabled) { - return; - } - - if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { - let newPosition = this.position; - const incr = (event.shiftKey ? 10 : 1) * (this.primary === 'end' ? -1 : 1); - - event.preventDefault(); - - if ((event.key === 'ArrowLeft' && !this.vertical) || (event.key === 'ArrowUp' && this.vertical)) { - newPosition -= incr; - } - - if ((event.key === 'ArrowRight' && !this.vertical) || (event.key === 'ArrowDown' && this.vertical)) { - newPosition += incr; - } - - if (event.key === 'Home') { - newPosition = this.primary === 'end' ? 100 : 0; - } - - if (event.key === 'End') { - newPosition = this.primary === 'end' ? 0 : 100; - } - - this.position = clamp(newPosition, 0, 100); - } - } - - private handleResize(entries: ResizeObserverEntry[]) { - const { width, height } = entries[0].contentRect; - this.size = this.vertical ? height : width; - - // Resize when a primary panel is set - if (this.primary) { - this.position = this.pixelsToPercentage(this.cachedPositionInPixels); - } - } - - @watch('position') - handlePositionChange() { - this.cachedPositionInPixels = this.percentageToPixels(this.position); - this.positionInPixels = this.percentageToPixels(this.position); - this.emit('sl-reposition'); - } - - @watch('positionInPixels') - handlePositionInPixelsChange() { - this.position = this.pixelsToPercentage(this.positionInPixels); - } - - @watch('vertical') - handleVerticalChange() { - this.detectSize(); - } - - render() { - const gridTemplate = this.vertical ? 'gridTemplateRows' : 'gridTemplateColumns'; - const gridTemplateAlt = this.vertical ? 'gridTemplateColumns' : 'gridTemplateRows'; - const isRtl = this.localize.dir() === 'rtl'; - const primary = ` - clamp( - 0%, - clamp( - var(--min), - ${this.position}% - var(--divider-width) / 2, - var(--max) - ), - calc(100% - var(--divider-width)) - ) - `; - const secondary = 'auto'; - - if (this.primary === 'end') { - if (isRtl && !this.vertical) { - this.style[gridTemplate] = `${primary} var(--divider-width) ${secondary}`; - } else { - this.style[gridTemplate] = `${secondary} var(--divider-width) ${primary}`; - } - } else { - if (isRtl && !this.vertical) { - this.style[gridTemplate] = `${secondary} var(--divider-width) ${primary}`; - } else { - this.style[gridTemplate] = `${primary} var(--divider-width) ${secondary}`; - } - } - - // Unset the alt grid template property - this.style[gridTemplateAlt] = ''; - - return html` - - - - - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-split-panel': SlSplitPanel; - } -} +import SlSplitPanel from './split-panel.component.js'; +export * from './split-panel.component.js'; +export default SlSplitPanel; +SlSplitPanel.define('sl-split-panel'); diff --git a/src/components/switch/switch.component.ts b/src/components/switch/switch.component.ts new file mode 100644 index 0000000000..232d033328 --- /dev/null +++ b/src/components/switch/switch.component.ts @@ -0,0 +1,228 @@ +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 styles from './switch.styles.js'; +import type { CSSResultGroup } from 'lit'; +import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; + +/** + * @summary Switches allow the user to toggle an option on or off. + * @documentation https://shoelace.style/components/switch + * @status stable + * @since 2.0 + * + * @slot - The switch's label. + * + * @event sl-blur - Emitted when the control loses focus. + * @event sl-change - Emitted when the control's checked state changes. + * @event sl-input - Emitted when the control receives input. + * @event sl-focus - Emitted when the control gains focus. + * @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 control that houses the switch's thumb. + * @csspart thumb - The switch's thumb. + * @csspart label - The switch's label. + * + * @cssproperty --width - The width of the switch. + * @cssproperty --height - The height of the switch. + * @cssproperty --thumb-size - The size of the thumb. + */ +export default class SlSwitch extends ShoelaceElement implements ShoelaceFormControl { + static styles: CSSResultGroup = styles; + + private readonly formControlController = new FormControlController(this, { + value: (control: SlSwitch) => (control.checked ? control.value || 'on' : undefined), + defaultValue: (control: SlSwitch) => control.defaultChecked, + setValue: (control: SlSwitch, 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 switch, submitted as a name/value pair with form data. */ + @property() name = ''; + + /** The current value of the switch, submitted as a name/value pair with form data. */ + @property() value: string; + + /** The switch's size. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; + + /** Disables the switch. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** Draws the switch in a checked state. */ + @property({ type: Boolean, reflect: true }) checked = 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 `` 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 switch 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 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 handleClick() { + this.checked = !this.checked; + this.emit('sl-change'); + } + + private handleFocus() { + this.hasFocus = true; + this.emit('sl-focus'); + } + + private handleKeyDown(event: KeyboardEvent) { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.checked = false; + this.emit('sl-change'); + this.emit('sl-input'); + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + this.checked = true; + this.emit('sl-change'); + this.emit('sl-input'); + } + } + + @watch('checked', { waitUntilFirstUpdate: true }) + handleCheckedChange() { + this.input.checked = this.checked; // force a sync update + this.formControlController.updateValidity(); + } + + @watch('disabled', { waitUntilFirstUpdate: true }) + handleDisabledChange() { + // Disabled form controls are always valid + this.formControlController.setValidity(true); + } + + /** Simulates a click on the switch. */ + click() { + this.input.click(); + } + + /** Sets focus on the switch. */ + focus(options?: FocusOptions) { + this.input.focus(options); + } + + /** Removes focus from the switch. */ + 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. Pass an empty string to restore validity. */ + setCustomValidity(message: string) { + this.input.setCustomValidity(message); + this.formControlController.updateValidity(); + } + + render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-switch': SlSwitch; + } +} diff --git a/src/components/switch/switch.ts b/src/components/switch/switch.ts index 3b0177ed0a..48aed413b7 100644 --- a/src/components/switch/switch.ts +++ b/src/components/switch/switch.ts @@ -1,229 +1,4 @@ -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 './switch.styles.js'; -import type { CSSResultGroup } from 'lit'; -import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; - -/** - * @summary Switches allow the user to toggle an option on or off. - * @documentation https://shoelace.style/components/switch - * @status stable - * @since 2.0 - * - * @slot - The switch's label. - * - * @event sl-blur - Emitted when the control loses focus. - * @event sl-change - Emitted when the control's checked state changes. - * @event sl-input - Emitted when the control receives input. - * @event sl-focus - Emitted when the control gains focus. - * @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 control that houses the switch's thumb. - * @csspart thumb - The switch's thumb. - * @csspart label - The switch's label. - * - * @cssproperty --width - The width of the switch. - * @cssproperty --height - The height of the switch. - * @cssproperty --thumb-size - The size of the thumb. - */ -@customElement('sl-switch') -export default class SlSwitch extends ShoelaceElement implements ShoelaceFormControl { - static styles: CSSResultGroup = styles; - - private readonly formControlController = new FormControlController(this, { - value: (control: SlSwitch) => (control.checked ? control.value || 'on' : undefined), - defaultValue: (control: SlSwitch) => control.defaultChecked, - setValue: (control: SlSwitch, 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 switch, submitted as a name/value pair with form data. */ - @property() name = ''; - - /** The current value of the switch, submitted as a name/value pair with form data. */ - @property() value: string; - - /** The switch's size. */ - @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; - - /** Disables the switch. */ - @property({ type: Boolean, reflect: true }) disabled = false; - - /** Draws the switch in a checked state. */ - @property({ type: Boolean, reflect: true }) checked = 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 `` 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 switch 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 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 handleClick() { - this.checked = !this.checked; - this.emit('sl-change'); - } - - private handleFocus() { - this.hasFocus = true; - this.emit('sl-focus'); - } - - private handleKeyDown(event: KeyboardEvent) { - if (event.key === 'ArrowLeft') { - event.preventDefault(); - this.checked = false; - this.emit('sl-change'); - this.emit('sl-input'); - } - - if (event.key === 'ArrowRight') { - event.preventDefault(); - this.checked = true; - this.emit('sl-change'); - this.emit('sl-input'); - } - } - - @watch('checked', { waitUntilFirstUpdate: true }) - handleCheckedChange() { - this.input.checked = this.checked; // force a sync update - this.formControlController.updateValidity(); - } - - @watch('disabled', { waitUntilFirstUpdate: true }) - handleDisabledChange() { - // Disabled form controls are always valid - this.formControlController.setValidity(true); - } - - /** Simulates a click on the switch. */ - click() { - this.input.click(); - } - - /** Sets focus on the switch. */ - focus(options?: FocusOptions) { - this.input.focus(options); - } - - /** Removes focus from the switch. */ - 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. Pass an empty string to restore validity. */ - setCustomValidity(message: string) { - this.input.setCustomValidity(message); - this.formControlController.updateValidity(); - } - - render() { - return html` - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-switch': SlSwitch; - } -} +import SlSwitch from './switch.component.js'; +export * from './switch.component.js'; +export default SlSwitch; +SlSwitch.define('sl-switch'); diff --git a/src/components/tab-group/tab-group.component.ts b/src/components/tab-group/tab-group.component.ts new file mode 100644 index 0000000000..f1753807ef --- /dev/null +++ b/src/components/tab-group/tab-group.component.ts @@ -0,0 +1,430 @@ +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 { scrollIntoView } from '../../internal/scroll.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 './tab-group.styles.js'; +import type { CSSResultGroup } from 'lit'; +import type SlTab from '../tab/tab.js'; +import type SlTabPanel from '../tab-panel/tab-panel.js'; + +/** + * @summary Tab groups organize content into a container that shows one section at a time. + * @documentation https://shoelace.style/components/tab-group + * @status stable + * @since 2.0 + * + * @dependency sl-icon-button + * + * @slot - Used for grouping tab panels in the tab group. Must be `` elements. + * @slot nav - Used for grouping tabs in the tab group. Must be `` elements. + * + * @event {{ name: String }} sl-tab-show - Emitted when a tab is shown. + * @event {{ name: String }} sl-tab-hide - Emitted when a tab is hidden. + * + * @csspart base - The component's base wrapper. + * @csspart nav - The tab group's navigation container where tabs are slotted in. + * @csspart tabs - The container that wraps the tabs. + * @csspart active-tab-indicator - The line that highlights the currently selected tab. + * @csspart body - The tab group's body where tab panels are slotted in. + * @csspart scroll-button - The previous/next scroll buttons that show when tabs are scrollable, an ``. + * @csspart scroll-button--start - The starting scroll button. + * @csspart scroll-button--end - The ending scroll button. + * @csspart scroll-button__base - The scroll button's exported `base` part. + * + * @cssproperty --indicator-color - The color of the active tab indicator. + * @cssproperty --track-color - The color of the indicator's track (the line that separates tabs from panels). + * @cssproperty --track-width - The width of the indicator's track (the line that separates tabs from panels). + */ +export default class SlTabGroup extends ShoelaceElement { + static styles: CSSResultGroup = styles; + static dependencies = { 'sl-icon-button': SlIconButton }; + + private readonly localize = new LocalizeController(this); + + private activeTab?: SlTab; + private mutationObserver: MutationObserver; + private resizeObserver: ResizeObserver; + private tabs: SlTab[] = []; + private panels: SlTabPanel[] = []; + + @query('.tab-group') tabGroup: HTMLElement; + @query('.tab-group__body') body: HTMLSlotElement; + @query('.tab-group__nav') nav: HTMLElement; + @query('.tab-group__indicator') indicator: HTMLElement; + + @state() private hasScrollControls = false; + + /** The placement of the tabs. */ + @property() placement: 'top' | 'bottom' | 'start' | 'end' = 'top'; + + /** + * When set to auto, navigating tabs with the arrow keys will instantly show the corresponding tab panel. When set to + * manual, the tab will receive focus but will not show until the user presses spacebar or enter. + */ + @property() activation: 'auto' | 'manual' = 'auto'; + + /** Disables the scroll arrows that appear when tabs overflow. */ + @property({ attribute: 'no-scroll-controls', type: Boolean }) noScrollControls = false; + + connectedCallback() { + const whenAllDefined = Promise.all([ + customElements.whenDefined('sl-tab'), + customElements.whenDefined('sl-tab-panel') + ]); + + super.connectedCallback(); + + this.resizeObserver = new ResizeObserver(() => { + this.repositionIndicator(); + this.updateScrollControls(); + }); + + this.mutationObserver = new MutationObserver(mutations => { + // Update aria labels when the DOM changes + if (mutations.some(m => !['aria-labelledby', 'aria-controls'].includes(m.attributeName!))) { + setTimeout(() => this.setAriaLabels()); + } + + // Sync tabs when disabled states change + if (mutations.some(m => m.attributeName === 'disabled')) { + this.syncTabsAndPanels(); + } + }); + + // After the first update... + this.updateComplete.then(() => { + this.syncTabsAndPanels(); + this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true }); + this.resizeObserver.observe(this.nav); + + // Wait for tabs and tab panels to be registered + whenAllDefined.then(() => { + // Set initial tab state when the tabs become visible + const intersectionObserver = new IntersectionObserver((entries, observer) => { + if (entries[0].intersectionRatio > 0) { + this.setAriaLabels(); + this.setActiveTab(this.getActiveTab() ?? this.tabs[0], { emitEvents: false }); + observer.unobserve(entries[0].target); + } + }); + intersectionObserver.observe(this.tabGroup); + }); + }); + } + + disconnectedCallback() { + this.mutationObserver.disconnect(); + this.resizeObserver.unobserve(this.nav); + } + + private getAllTabs(options: { includeDisabled: boolean } = { includeDisabled: true }) { + const slot = this.shadowRoot!.querySelector('slot[name="nav"]')!; + + return [...(slot.assignedElements() as SlTab[])].filter(el => { + return options.includeDisabled + ? el.tagName.toLowerCase() === 'sl-tab' + : el.tagName.toLowerCase() === 'sl-tab' && !el.disabled; + }); + } + + private getAllPanels() { + return [...this.body.assignedElements()].filter(el => el.tagName.toLowerCase() === 'sl-tab-panel') as [SlTabPanel]; + } + + private getActiveTab() { + return this.tabs.find(el => el.active); + } + + private handleClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const tab = target.closest('sl-tab'); + const tabGroup = tab?.closest('sl-tab-group'); + + // Ensure the target tab is in this tab group + if (tabGroup !== this) { + return; + } + + if (tab !== null) { + this.setActiveTab(tab, { scrollBehavior: 'smooth' }); + } + } + + private handleKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement; + const tab = target.closest('sl-tab'); + const tabGroup = tab?.closest('sl-tab-group'); + + // Ensure the target tab is in this tab group + if (tabGroup !== this) { + return; + } + + // Activate a tab + if (['Enter', ' '].includes(event.key)) { + if (tab !== null) { + this.setActiveTab(tab, { scrollBehavior: 'smooth' }); + event.preventDefault(); + } + } + + // Move focus left or right + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { + const activeEl = this.tabs.find(t => t.matches(':focus')); + const isRtl = this.localize.dir() === 'rtl'; + + if (activeEl?.tagName.toLowerCase() === 'sl-tab') { + let index = this.tabs.indexOf(activeEl); + + if (event.key === 'Home') { + index = 0; + } else if (event.key === 'End') { + index = this.tabs.length - 1; + } else if ( + (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowRight' : 'ArrowLeft')) || + (['start', 'end'].includes(this.placement) && event.key === 'ArrowUp') + ) { + index--; + } else if ( + (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowLeft' : 'ArrowRight')) || + (['start', 'end'].includes(this.placement) && event.key === 'ArrowDown') + ) { + index++; + } + + if (index < 0) { + index = this.tabs.length - 1; + } + + if (index > this.tabs.length - 1) { + index = 0; + } + + this.tabs[index].focus({ preventScroll: true }); + + if (this.activation === 'auto') { + this.setActiveTab(this.tabs[index], { scrollBehavior: 'smooth' }); + } + + if (['top', 'bottom'].includes(this.placement)) { + scrollIntoView(this.tabs[index], this.nav, 'horizontal'); + } + + event.preventDefault(); + } + } + } + + private handleScrollToStart() { + this.nav.scroll({ + left: + this.localize.dir() === 'rtl' + ? this.nav.scrollLeft + this.nav.clientWidth + : this.nav.scrollLeft - this.nav.clientWidth, + behavior: 'smooth' + }); + } + + private handleScrollToEnd() { + this.nav.scroll({ + left: + this.localize.dir() === 'rtl' + ? this.nav.scrollLeft - this.nav.clientWidth + : this.nav.scrollLeft + this.nav.clientWidth, + behavior: 'smooth' + }); + } + + private setActiveTab(tab: SlTab, options?: { emitEvents?: boolean; scrollBehavior?: 'auto' | 'smooth' }) { + options = { + emitEvents: true, + scrollBehavior: 'auto', + ...options + }; + + if (tab !== this.activeTab && !tab.disabled) { + const previousTab = this.activeTab; + this.activeTab = tab; + + // Sync active tab and panel + this.tabs.forEach(el => (el.active = el === this.activeTab)); + this.panels.forEach(el => (el.active = el.name === this.activeTab?.panel)); + this.syncIndicator(); + + if (['top', 'bottom'].includes(this.placement)) { + scrollIntoView(this.activeTab, this.nav, 'horizontal', options.scrollBehavior); + } + + // Emit events + if (options.emitEvents) { + if (previousTab) { + this.emit('sl-tab-hide', { detail: { name: previousTab.panel } }); + } + + this.emit('sl-tab-show', { detail: { name: this.activeTab.panel } }); + } + } + } + + private setAriaLabels() { + // Link each tab with its corresponding panel + this.tabs.forEach(tab => { + const panel = this.panels.find(el => el.name === tab.panel); + if (panel) { + tab.setAttribute('aria-controls', panel.getAttribute('id')!); + panel.setAttribute('aria-labelledby', tab.getAttribute('id')!); + } + }); + } + + private repositionIndicator() { + const currentTab = this.getActiveTab(); + + if (!currentTab) { + return; + } + + const width = currentTab.clientWidth; + const height = currentTab.clientHeight; + const isRtl = this.localize.dir() === 'rtl'; + + // We can't used offsetLeft/offsetTop here due to a shadow parent issue where neither can getBoundingClientRect + // because it provides invalid values for animating elements: https://bugs.chromium.org/p/chromium/issues/detail?id=920069 + const allTabs = this.getAllTabs(); + const precedingTabs = allTabs.slice(0, allTabs.indexOf(currentTab)); + const offset = precedingTabs.reduce( + (previous, current) => ({ + left: previous.left + current.clientWidth, + top: previous.top + current.clientHeight + }), + { left: 0, top: 0 } + ); + + switch (this.placement) { + case 'top': + case 'bottom': + this.indicator.style.width = `${width}px`; + this.indicator.style.height = 'auto'; + this.indicator.style.translate = isRtl ? `${-1 * offset.left}px` : `${offset.left}px`; + break; + + case 'start': + case 'end': + this.indicator.style.width = 'auto'; + this.indicator.style.height = `${height}px`; + this.indicator.style.translate = `0 ${offset.top}px`; + break; + } + } + + // This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times. + private syncTabsAndPanels() { + this.tabs = this.getAllTabs({ includeDisabled: false }); + this.panels = this.getAllPanels(); + this.syncIndicator(); + + // After updating, show or hide scroll controls as needed + this.updateComplete.then(() => this.updateScrollControls()); + } + + @watch('noScrollControls', { waitUntilFirstUpdate: true }) + updateScrollControls() { + if (this.noScrollControls) { + this.hasScrollControls = false; + } else { + this.hasScrollControls = + ['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth; + } + } + + @watch('placement', { waitUntilFirstUpdate: true }) + syncIndicator() { + const tab = this.getActiveTab(); + + if (tab) { + this.indicator.style.display = 'block'; + this.repositionIndicator(); + } else { + this.indicator.style.display = 'none'; + } + } + + /** Shows the specified tab panel. */ + show(panel: string) { + const tab = this.tabs.find(el => el.panel === panel); + + if (tab) { + this.setActiveTab(tab, { scrollBehavior: 'smooth' }); + } + } + + render() { + const isRtl = this.localize.dir() === 'rtl'; + + return html` +
+
+ ${this.hasScrollControls + ? html` + + ` + : ''} + +
+
+
+ +
+
+ + ${this.hasScrollControls + ? html` + + ` + : ''} +
+ + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-tab-group': SlTabGroup; + } +} diff --git a/src/components/tab-group/tab-group.ts b/src/components/tab-group/tab-group.ts index 074257f597..467e033c62 100644 --- a/src/components/tab-group/tab-group.ts +++ b/src/components/tab-group/tab-group.ts @@ -1,429 +1,4 @@ -import '../icon-button/icon-button.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 { scrollIntoView } from '../../internal/scroll.js'; -import { watch } from '../../internal/watch.js'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import styles from './tab-group.styles.js'; -import type { CSSResultGroup } from 'lit'; -import type SlTab from '../tab/tab.js'; -import type SlTabPanel from '../tab-panel/tab-panel.js'; - -/** - * @summary Tab groups organize content into a container that shows one section at a time. - * @documentation https://shoelace.style/components/tab-group - * @status stable - * @since 2.0 - * - * @dependency sl-icon-button - * - * @slot - Used for grouping tab panels in the tab group. Must be `` elements. - * @slot nav - Used for grouping tabs in the tab group. Must be `` elements. - * - * @event {{ name: String }} sl-tab-show - Emitted when a tab is shown. - * @event {{ name: String }} sl-tab-hide - Emitted when a tab is hidden. - * - * @csspart base - The component's base wrapper. - * @csspart nav - The tab group's navigation container where tabs are slotted in. - * @csspart tabs - The container that wraps the tabs. - * @csspart active-tab-indicator - The line that highlights the currently selected tab. - * @csspart body - The tab group's body where tab panels are slotted in. - * @csspart scroll-button - The previous/next scroll buttons that show when tabs are scrollable, an ``. - * @csspart scroll-button--start - The starting scroll button. - * @csspart scroll-button--end - The ending scroll button. - * @csspart scroll-button__base - The scroll button's exported `base` part. - * - * @cssproperty --indicator-color - The color of the active tab indicator. - * @cssproperty --track-color - The color of the indicator's track (the line that separates tabs from panels). - * @cssproperty --track-width - The width of the indicator's track (the line that separates tabs from panels). - */ -@customElement('sl-tab-group') -export default class SlTabGroup extends ShoelaceElement { - static styles: CSSResultGroup = styles; - private readonly localize = new LocalizeController(this); - - private activeTab?: SlTab; - private mutationObserver: MutationObserver; - private resizeObserver: ResizeObserver; - private tabs: SlTab[] = []; - private panels: SlTabPanel[] = []; - - @query('.tab-group') tabGroup: HTMLElement; - @query('.tab-group__body') body: HTMLSlotElement; - @query('.tab-group__nav') nav: HTMLElement; - @query('.tab-group__indicator') indicator: HTMLElement; - - @state() private hasScrollControls = false; - - /** The placement of the tabs. */ - @property() placement: 'top' | 'bottom' | 'start' | 'end' = 'top'; - - /** - * When set to auto, navigating tabs with the arrow keys will instantly show the corresponding tab panel. When set to - * manual, the tab will receive focus but will not show until the user presses spacebar or enter. - */ - @property() activation: 'auto' | 'manual' = 'auto'; - - /** Disables the scroll arrows that appear when tabs overflow. */ - @property({ attribute: 'no-scroll-controls', type: Boolean }) noScrollControls = false; - - connectedCallback() { - const whenAllDefined = Promise.all([ - customElements.whenDefined('sl-tab'), - customElements.whenDefined('sl-tab-panel') - ]); - - super.connectedCallback(); - - this.resizeObserver = new ResizeObserver(() => { - this.repositionIndicator(); - this.updateScrollControls(); - }); - - this.mutationObserver = new MutationObserver(mutations => { - // Update aria labels when the DOM changes - if (mutations.some(m => !['aria-labelledby', 'aria-controls'].includes(m.attributeName!))) { - setTimeout(() => this.setAriaLabels()); - } - - // Sync tabs when disabled states change - if (mutations.some(m => m.attributeName === 'disabled')) { - this.syncTabsAndPanels(); - } - }); - - // After the first update... - this.updateComplete.then(() => { - this.syncTabsAndPanels(); - this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true }); - this.resizeObserver.observe(this.nav); - - // Wait for tabs and tab panels to be registered - whenAllDefined.then(() => { - // Set initial tab state when the tabs become visible - const intersectionObserver = new IntersectionObserver((entries, observer) => { - if (entries[0].intersectionRatio > 0) { - this.setAriaLabels(); - this.setActiveTab(this.getActiveTab() ?? this.tabs[0], { emitEvents: false }); - observer.unobserve(entries[0].target); - } - }); - intersectionObserver.observe(this.tabGroup); - }); - }); - } - - disconnectedCallback() { - this.mutationObserver.disconnect(); - this.resizeObserver.unobserve(this.nav); - } - - private getAllTabs(options: { includeDisabled: boolean } = { includeDisabled: true }) { - const slot = this.shadowRoot!.querySelector('slot[name="nav"]')!; - - return [...(slot.assignedElements() as SlTab[])].filter(el => { - return options.includeDisabled - ? el.tagName.toLowerCase() === 'sl-tab' - : el.tagName.toLowerCase() === 'sl-tab' && !el.disabled; - }); - } - - private getAllPanels() { - return [...this.body.assignedElements()].filter(el => el.tagName.toLowerCase() === 'sl-tab-panel') as [SlTabPanel]; - } - - private getActiveTab() { - return this.tabs.find(el => el.active); - } - - private handleClick(event: MouseEvent) { - const target = event.target as HTMLElement; - const tab = target.closest('sl-tab'); - const tabGroup = tab?.closest('sl-tab-group'); - - // Ensure the target tab is in this tab group - if (tabGroup !== this) { - return; - } - - if (tab !== null) { - this.setActiveTab(tab, { scrollBehavior: 'smooth' }); - } - } - - private handleKeyDown(event: KeyboardEvent) { - const target = event.target as HTMLElement; - const tab = target.closest('sl-tab'); - const tabGroup = tab?.closest('sl-tab-group'); - - // Ensure the target tab is in this tab group - if (tabGroup !== this) { - return; - } - - // Activate a tab - if (['Enter', ' '].includes(event.key)) { - if (tab !== null) { - this.setActiveTab(tab, { scrollBehavior: 'smooth' }); - event.preventDefault(); - } - } - - // Move focus left or right - if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { - const activeEl = this.tabs.find(t => t.matches(':focus')); - const isRtl = this.localize.dir() === 'rtl'; - - if (activeEl?.tagName.toLowerCase() === 'sl-tab') { - let index = this.tabs.indexOf(activeEl); - - if (event.key === 'Home') { - index = 0; - } else if (event.key === 'End') { - index = this.tabs.length - 1; - } else if ( - (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowRight' : 'ArrowLeft')) || - (['start', 'end'].includes(this.placement) && event.key === 'ArrowUp') - ) { - index--; - } else if ( - (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowLeft' : 'ArrowRight')) || - (['start', 'end'].includes(this.placement) && event.key === 'ArrowDown') - ) { - index++; - } - - if (index < 0) { - index = this.tabs.length - 1; - } - - if (index > this.tabs.length - 1) { - index = 0; - } - - this.tabs[index].focus({ preventScroll: true }); - - if (this.activation === 'auto') { - this.setActiveTab(this.tabs[index], { scrollBehavior: 'smooth' }); - } - - if (['top', 'bottom'].includes(this.placement)) { - scrollIntoView(this.tabs[index], this.nav, 'horizontal'); - } - - event.preventDefault(); - } - } - } - - private handleScrollToStart() { - this.nav.scroll({ - left: - this.localize.dir() === 'rtl' - ? this.nav.scrollLeft + this.nav.clientWidth - : this.nav.scrollLeft - this.nav.clientWidth, - behavior: 'smooth' - }); - } - - private handleScrollToEnd() { - this.nav.scroll({ - left: - this.localize.dir() === 'rtl' - ? this.nav.scrollLeft - this.nav.clientWidth - : this.nav.scrollLeft + this.nav.clientWidth, - behavior: 'smooth' - }); - } - - private setActiveTab(tab: SlTab, options?: { emitEvents?: boolean; scrollBehavior?: 'auto' | 'smooth' }) { - options = { - emitEvents: true, - scrollBehavior: 'auto', - ...options - }; - - if (tab !== this.activeTab && !tab.disabled) { - const previousTab = this.activeTab; - this.activeTab = tab; - - // Sync active tab and panel - this.tabs.forEach(el => (el.active = el === this.activeTab)); - this.panels.forEach(el => (el.active = el.name === this.activeTab?.panel)); - this.syncIndicator(); - - if (['top', 'bottom'].includes(this.placement)) { - scrollIntoView(this.activeTab, this.nav, 'horizontal', options.scrollBehavior); - } - - // Emit events - if (options.emitEvents) { - if (previousTab) { - this.emit('sl-tab-hide', { detail: { name: previousTab.panel } }); - } - - this.emit('sl-tab-show', { detail: { name: this.activeTab.panel } }); - } - } - } - - private setAriaLabels() { - // Link each tab with its corresponding panel - this.tabs.forEach(tab => { - const panel = this.panels.find(el => el.name === tab.panel); - if (panel) { - tab.setAttribute('aria-controls', panel.getAttribute('id')!); - panel.setAttribute('aria-labelledby', tab.getAttribute('id')!); - } - }); - } - - private repositionIndicator() { - const currentTab = this.getActiveTab(); - - if (!currentTab) { - return; - } - - const width = currentTab.clientWidth; - const height = currentTab.clientHeight; - const isRtl = this.localize.dir() === 'rtl'; - - // We can't used offsetLeft/offsetTop here due to a shadow parent issue where neither can getBoundingClientRect - // because it provides invalid values for animating elements: https://bugs.chromium.org/p/chromium/issues/detail?id=920069 - const allTabs = this.getAllTabs(); - const precedingTabs = allTabs.slice(0, allTabs.indexOf(currentTab)); - const offset = precedingTabs.reduce( - (previous, current) => ({ - left: previous.left + current.clientWidth, - top: previous.top + current.clientHeight - }), - { left: 0, top: 0 } - ); - - switch (this.placement) { - case 'top': - case 'bottom': - this.indicator.style.width = `${width}px`; - this.indicator.style.height = 'auto'; - this.indicator.style.translate = isRtl ? `${-1 * offset.left}px` : `${offset.left}px`; - break; - - case 'start': - case 'end': - this.indicator.style.width = 'auto'; - this.indicator.style.height = `${height}px`; - this.indicator.style.translate = `0 ${offset.top}px`; - break; - } - } - - // This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times. - private syncTabsAndPanels() { - this.tabs = this.getAllTabs({ includeDisabled: false }); - this.panels = this.getAllPanels(); - this.syncIndicator(); - - // After updating, show or hide scroll controls as needed - this.updateComplete.then(() => this.updateScrollControls()); - } - - @watch('noScrollControls', { waitUntilFirstUpdate: true }) - updateScrollControls() { - if (this.noScrollControls) { - this.hasScrollControls = false; - } else { - this.hasScrollControls = - ['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth; - } - } - - @watch('placement', { waitUntilFirstUpdate: true }) - syncIndicator() { - const tab = this.getActiveTab(); - - if (tab) { - this.indicator.style.display = 'block'; - this.repositionIndicator(); - } else { - this.indicator.style.display = 'none'; - } - } - - /** Shows the specified tab panel. */ - show(panel: string) { - const tab = this.tabs.find(el => el.panel === panel); - - if (tab) { - this.setActiveTab(tab, { scrollBehavior: 'smooth' }); - } - } - - render() { - const isRtl = this.localize.dir() === 'rtl'; - - return html` -
-
- ${this.hasScrollControls - ? html` - - ` - : ''} - -
-
-
- -
-
- - ${this.hasScrollControls - ? html` - - ` - : ''} -
- - -
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-tab-group': SlTabGroup; - } -} +import SlTabGroup from './tab-group.component.js'; +export * from './tab-group.component.js'; +export default SlTabGroup; +SlTabGroup.define('sl-tab-group'); diff --git a/src/components/tab-panel/tab-panel.component.ts b/src/components/tab-panel/tab-panel.component.ts new file mode 100644 index 0000000000..9dfed51d9e --- /dev/null +++ b/src/components/tab-panel/tab-panel.component.ts @@ -0,0 +1,63 @@ +import { classMap } from 'lit/directives/class-map.js'; +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 './tab-panel.styles.js'; +import type { CSSResultGroup } from 'lit'; + +let id = 0; + +/** + * @summary Tab panels are used inside [tab groups](/components/tab-group) to display tabbed content. + * @documentation https://shoelace.style/components/tab-panel + * @status stable + * @since 2.0 + * + * @slot - The tab panel's content. + * + * @csspart base - The component's base wrapper. + * + * @cssproperty --padding - The tab panel's padding. + */ +export default class SlTabPanel extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + private readonly attrId = ++id; + private readonly componentId = `sl-tab-panel-${this.attrId}`; + + /** The tab panel's name. */ + @property({ reflect: true }) name = ''; + + /** When true, the tab panel will be shown. */ + @property({ type: Boolean, reflect: true }) active = false; + + connectedCallback() { + super.connectedCallback(); + this.id = this.id.length > 0 ? this.id : this.componentId; + this.setAttribute('role', 'tabpanel'); + } + + @watch('active') + handleActiveChange() { + this.setAttribute('aria-hidden', this.active ? 'false' : 'true'); + } + + render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-tab-panel': SlTabPanel; + } +} diff --git a/src/components/tab-panel/tab-panel.ts b/src/components/tab-panel/tab-panel.ts index a2065b0199..f997fa0f37 100644 --- a/src/components/tab-panel/tab-panel.ts +++ b/src/components/tab-panel/tab-panel.ts @@ -1,64 +1,4 @@ -import { classMap } from 'lit/directives/class-map.js'; -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 './tab-panel.styles.js'; -import type { CSSResultGroup } from 'lit'; - -let id = 0; - -/** - * @summary Tab panels are used inside [tab groups](/components/tab-group) to display tabbed content. - * @documentation https://shoelace.style/components/tab-panel - * @status stable - * @since 2.0 - * - * @slot - The tab panel's content. - * - * @csspart base - The component's base wrapper. - * - * @cssproperty --padding - The tab panel's padding. - */ -@customElement('sl-tab-panel') -export default class SlTabPanel extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - private readonly attrId = ++id; - private readonly componentId = `sl-tab-panel-${this.attrId}`; - - /** The tab panel's name. */ - @property({ reflect: true }) name = ''; - - /** When true, the tab panel will be shown. */ - @property({ type: Boolean, reflect: true }) active = false; - - connectedCallback() { - super.connectedCallback(); - this.id = this.id.length > 0 ? this.id : this.componentId; - this.setAttribute('role', 'tabpanel'); - } - - @watch('active') - handleActiveChange() { - this.setAttribute('aria-hidden', this.active ? 'false' : 'true'); - } - - render() { - return html` - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-tab-panel': SlTabPanel; - } -} +import SlTabPanel from './tab-panel.component.js'; +export * from './tab-panel.component.js'; +export default SlTabPanel; +SlTabPanel.define('sl-tab-panel'); diff --git a/src/components/tab/tab.component.ts b/src/components/tab/tab.component.ts new file mode 100644 index 0000000000..b418c390b8 --- /dev/null +++ b/src/components/tab/tab.component.ts @@ -0,0 +1,121 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query } from 'lit/decorators.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 './tab.styles.js'; +import type { CSSResultGroup } from 'lit'; + +let id = 0; + +/** + * @summary Tabs are used inside [tab groups](/components/tab-group) to represent and activate [tab panels](/components/tab-panel). + * @documentation https://shoelace.style/components/tab + * @status stable + * @since 2.0 + * + * @dependency sl-icon-button + * + * @slot - The tab's label. + * + * @event sl-close - Emitted when the tab is closable and the close button is activated. + * + * @csspart base - The component's base wrapper. + * @csspart close-button - The close button, an ``. + * @csspart close-button__base - The close button's exported `base` part. + */ +export default class SlTab extends ShoelaceElement { + static styles: CSSResultGroup = styles; + static dependencies = { 'sl-icon-button': SlIconButton }; + + private readonly localize = new LocalizeController(this); + + private readonly attrId = ++id; + private readonly componentId = `sl-tab-${this.attrId}`; + + @query('.tab') tab: HTMLElement; + + /** The name of the tab panel this tab is associated with. The panel must be located in the same tab group. */ + @property({ reflect: true }) panel = ''; + + /** Draws the tab in an active state. */ + @property({ type: Boolean, reflect: true }) active = false; + + /** Makes the tab closable and shows a close button. */ + @property({ type: Boolean }) closable = false; + + /** Disables the tab and prevents selection. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'tab'); + } + + private handleCloseClick(event: Event) { + event.stopPropagation(); + this.emit('sl-close'); + } + + @watch('active') + handleActiveChange() { + this.setAttribute('aria-selected', this.active ? 'true' : 'false'); + } + + @watch('disabled') + handleDisabledChange() { + this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + } + + /** Sets focus to the tab. */ + focus(options?: FocusOptions) { + this.tab.focus(options); + } + + /** Removes focus from the tab. */ + blur() { + this.tab.blur(); + } + + render() { + // If the user didn't provide an ID, we'll set one so we can link tabs and tab panels with aria labels + this.id = this.id.length > 0 ? this.id : this.componentId; + + return html` +
+ + ${this.closable + ? html` + + ` + : ''} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-tab': SlTab; + } +} diff --git a/src/components/tab/tab.ts b/src/components/tab/tab.ts index efca85f232..76a59c9c34 100644 --- a/src/components/tab/tab.ts +++ b/src/components/tab/tab.ts @@ -1,120 +1,4 @@ -import '../icon-button/icon-button.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property, query } 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 './tab.styles.js'; -import type { CSSResultGroup } from 'lit'; - -let id = 0; - -/** - * @summary Tabs are used inside [tab groups](/components/tab-group) to represent and activate [tab panels](/components/tab-panel). - * @documentation https://shoelace.style/components/tab - * @status stable - * @since 2.0 - * - * @dependency sl-icon-button - * - * @slot - The tab's label. - * - * @event sl-close - Emitted when the tab is closable and the close button is activated. - * - * @csspart base - The component's base wrapper. - * @csspart close-button - The close button, an ``. - * @csspart close-button__base - The close button's exported `base` part. - */ -@customElement('sl-tab') -export default class SlTab extends ShoelaceElement { - static styles: CSSResultGroup = styles; - private readonly localize = new LocalizeController(this); - - private readonly attrId = ++id; - private readonly componentId = `sl-tab-${this.attrId}`; - - @query('.tab') tab: HTMLElement; - - /** The name of the tab panel this tab is associated with. The panel must be located in the same tab group. */ - @property({ reflect: true }) panel = ''; - - /** Draws the tab in an active state. */ - @property({ type: Boolean, reflect: true }) active = false; - - /** Makes the tab closable and shows a close button. */ - @property({ type: Boolean }) closable = false; - - /** Disables the tab and prevents selection. */ - @property({ type: Boolean, reflect: true }) disabled = false; - - connectedCallback() { - super.connectedCallback(); - this.setAttribute('role', 'tab'); - } - - private handleCloseClick(event: Event) { - event.stopPropagation(); - this.emit('sl-close'); - } - - @watch('active') - handleActiveChange() { - this.setAttribute('aria-selected', this.active ? 'true' : 'false'); - } - - @watch('disabled') - handleDisabledChange() { - this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); - } - - /** Sets focus to the tab. */ - focus(options?: FocusOptions) { - this.tab.focus(options); - } - - /** Removes focus from the tab. */ - blur() { - this.tab.blur(); - } - - render() { - // If the user didn't provide an ID, we'll set one so we can link tabs and tab panels with aria labels - this.id = this.id.length > 0 ? this.id : this.componentId; - - return html` -
- - ${this.closable - ? html` - - ` - : ''} -
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-tab': SlTab; - } -} +import SlTab from './tab.component.js'; +export * from './tab.component.js'; +export default SlTab; +SlTab.define('sl-tab'); diff --git a/src/components/tag/tag.component.ts b/src/components/tag/tag.component.ts new file mode 100644 index 0000000000..c355664d41 --- /dev/null +++ b/src/components/tag/tag.component.ts @@ -0,0 +1,99 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property } from 'lit/decorators.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import SlIconButton from '../icon-button/icon-button.component.js'; +import styles from './tag.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Tags are used as labels to organize things or to indicate a selection. + * @documentation https://shoelace.style/components/tag + * @status stable + * @since 2.0 + * + * @dependency sl-icon-button + * + * @slot - The tag's content. + * + * @event sl-remove - Emitted when the remove button is activated. + * + * @csspart base - The component's base wrapper. + * @csspart content - The tag's content. + * @csspart remove-button - The tag's remove button, an ``. + * @csspart remove-button__base - The remove button's exported `base` part. + */ +export default class SlTag extends ShoelaceElement { + static styles: CSSResultGroup = styles; + static dependencies = { 'sl-icon-button': SlIconButton }; + + private readonly localize = new LocalizeController(this); + + /** The tag's theme variant. */ + @property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' = 'neutral'; + + /** The tag's size. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; + + /** Draws a pill-style tag with rounded edges. */ + @property({ type: Boolean, reflect: true }) pill = false; + + /** Makes the tag removable and shows a remove button. */ + @property({ type: Boolean }) removable = false; + + private handleRemoveClick() { + this.emit('sl-remove'); + } + + render() { + return html` + + + + ${this.removable + ? html` + + ` + : ''} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-tag': SlTag; + } +} diff --git a/src/components/tag/tag.ts b/src/components/tag/tag.ts index ccee7fb384..197e9a981e 100644 --- a/src/components/tag/tag.ts +++ b/src/components/tag/tag.ts @@ -1,98 +1,4 @@ -import '../icon-button/icon-button.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property } from 'lit/decorators.js'; -import { html } from 'lit'; -import { LocalizeController } from '../../utilities/localize.js'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import styles from './tag.styles.js'; -import type { CSSResultGroup } from 'lit'; - -/** - * @summary Tags are used as labels to organize things or to indicate a selection. - * @documentation https://shoelace.style/components/tag - * @status stable - * @since 2.0 - * - * @dependency sl-icon-button - * - * @slot - The tag's content. - * - * @event sl-remove - Emitted when the remove button is activated. - * - * @csspart base - The component's base wrapper. - * @csspart content - The tag's content. - * @csspart remove-button - The tag's remove button, an ``. - * @csspart remove-button__base - The remove button's exported `base` part. - */ -@customElement('sl-tag') -export default class SlTag extends ShoelaceElement { - static styles: CSSResultGroup = styles; - private readonly localize = new LocalizeController(this); - - /** The tag's theme variant. */ - @property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' = 'neutral'; - - /** The tag's size. */ - @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; - - /** Draws a pill-style tag with rounded edges. */ - @property({ type: Boolean, reflect: true }) pill = false; - - /** Makes the tag removable and shows a remove button. */ - @property({ type: Boolean }) removable = false; - - private handleRemoveClick() { - this.emit('sl-remove'); - } - - render() { - return html` - - - - ${this.removable - ? html` - - ` - : ''} - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-tag': SlTag; - } -} +import SlTag from './tag.component.js'; +export * from './tag.component.js'; +export default SlTag; +SlTag.define('sl-tag'); diff --git a/src/components/textarea/textarea.component.ts b/src/components/textarea/textarea.component.ts new file mode 100644 index 0000000000..bb4058cf29 --- /dev/null +++ b/src/components/textarea/textarea.component.ts @@ -0,0 +1,391 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { defaultValue } from '../../internal/default-value.js'; +import { FormControlController } from '../../internal/form.js'; +import { HasSlotController } from '../../internal/slot.js'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { live } from 'lit/directives/live.js'; +import { property, query, state } from 'lit/decorators.js'; +import { watch } from '../../internal/watch.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './textarea.styles.js'; +import type { CSSResultGroup } from 'lit'; +import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; + +/** + * @summary Textareas collect data from the user and allow multiple lines of text. + * @documentation https://shoelace.style/components/textarea + * @status stable + * @since 2.0 + * + * @slot label - The textarea'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 input's wrapper. + * @csspart form-control-help-text - The help text's wrapper. + * @csspart base - The component's base wrapper. + * @csspart textarea - The internal ` + + + +
+ ${this.helpText} +
+ + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-textarea': SlTextarea; + } +} diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index e57a8250a3..6be43a3295 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -1,392 +1,4 @@ -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 { watch } from '../../internal/watch.js'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import styles from './textarea.styles.js'; -import type { CSSResultGroup } from 'lit'; -import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; - -/** - * @summary Textareas collect data from the user and allow multiple lines of text. - * @documentation https://shoelace.style/components/textarea - * @status stable - * @since 2.0 - * - * @slot label - The textarea'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 input's wrapper. - * @csspart form-control-help-text - The help text's wrapper. - * @csspart base - The component's base wrapper. - * @csspart textarea - The internal ` - - - -
- ${this.helpText} -
- - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-textarea': SlTextarea; - } -} +import SlTextarea from './textarea.component.js'; +export * from './textarea.component.js'; +export default SlTextarea; +SlTextarea.define('sl-textarea'); diff --git a/src/components/tooltip/tooltip.component.ts b/src/components/tooltip/tooltip.component.ts new file mode 100644 index 0000000000..cc8fa2ca98 --- /dev/null +++ b/src/components/tooltip/tooltip.component.ts @@ -0,0 +1,301 @@ +import { animateTo, parseDuration, 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 SlPopup from '../popup/popup.component.js'; +import styles from './tooltip.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Tooltips display additional information based on a specific action. + * @documentation https://shoelace.style/components/tooltip + * @status stable + * @since 2.0 + * + * @dependency sl-popup + * + * @slot - The tooltip's target element. Avoid slotting in more than one element, as subsequent ones will be ignored. + * @slot content - The content to render in the tooltip. Alternatively, you can use the `content` attribute. + * + * @event sl-show - Emitted when the tooltip begins to show. + * @event sl-after-show - Emitted after the tooltip has shown and all animations are complete. + * @event sl-hide - Emitted when the tooltip begins to hide. + * @event sl-after-hide - Emitted after the tooltip has hidden and all animations are complete. + * + * @csspart base - The component's base wrapper, an `` element. + * @csspart base__popup - The popup's exported `popup` part. Use this to target the tooltip's popup container. + * @csspart base__arrow - The popup's exported `arrow` part. Use this to target the tooltip's arrow. + * @csspart body - The tooltip's body where its content is rendered. + * + * @cssproperty --max-width - The maximum width of the tooltip before its content will wrap. + * @cssproperty --hide-delay - The amount of time to wait before hiding the tooltip when hovering. + * @cssproperty --show-delay - The amount of time to wait before showing the tooltip when hovering. + * + * @animation tooltip.show - The animation to use when showing the tooltip. + * @animation tooltip.hide - The animation to use when hiding the tooltip. + */ +export default class SlTooltip extends ShoelaceElement { + static styles: CSSResultGroup = styles; + static dependencies = { 'sl-popup': SlPopup }; + + private hoverTimeout: number; + private readonly localize = new LocalizeController(this); + + @query('slot:not([name])') defaultSlot: HTMLSlotElement; + @query('.tooltip__body') body: HTMLElement; + @query('sl-popup') popup: SlPopup; + + /** The tooltip's content. If you need to display HTML, use the `content` slot instead. */ + @property() content = ''; + + /** + * The preferred placement of the tooltip. Note that the actual placement may vary as needed to keep the tooltip + * inside of the viewport. + */ + @property() placement: + | 'top' + | 'top-start' + | 'top-end' + | 'right' + | 'right-start' + | 'right-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end' = 'top'; + + /** Disables the tooltip so it won't show when triggered. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** The distance in pixels from which to offset the tooltip away from its target. */ + @property({ type: Number }) distance = 8; + + /** Indicates whether or not the tooltip is open. You can use this in lieu of the show/hide methods. */ + @property({ type: Boolean, reflect: true }) open = false; + + /** The distance in pixels from which to offset the tooltip along its target. */ + @property({ type: Number }) skidding = 0; + + /** + * Controls how the tooltip is activated. Possible options include `click`, `hover`, `focus`, and `manual`. Multiple + * options can be passed by separating them with a space. When manual is used, the tooltip must be activated + * programmatically. + */ + @property() trigger = 'hover focus'; + + /** + * Enable this option to prevent the tooltip from being clipped when the component is placed inside a container with + * `overflow: auto|hidden|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, + * scenarios. + */ + @property({ type: Boolean }) hoist = false; + + constructor() { + super(); + // TODO (justinfagnani): does this need to be done in firstUpdated for some + // reason? If so, document why in a comment. + this.addEventListener('blur', this.handleBlur, true); + this.addEventListener('focus', this.handleFocus, true); + this.addEventListener('click', this.handleClick); + this.addEventListener('keydown', this.handleKeyDown); + this.addEventListener('mouseover', this.handleMouseOver); + this.addEventListener('mouseout', this.handleMouseOut); + } + + connectedCallback() { + super.connectedCallback(); + } + + firstUpdated() { + this.body.hidden = !this.open; + + // If the tooltip is visible on init, update its position + if (this.open) { + this.popup.active = true; + this.popup.reposition(); + } + } + + private handleBlur = () => { + if (this.hasTrigger('focus')) { + this.hide(); + } + }; + + private handleClick = () => { + if (this.hasTrigger('click')) { + if (this.open) { + this.hide(); + } else { + this.show(); + } + } + }; + + private handleFocus = () => { + if (this.hasTrigger('focus')) { + this.show(); + } + }; + + private handleKeyDown = (event: KeyboardEvent) => { + // Pressing escape when the target element has focus should dismiss the tooltip + if (this.open && event.key === 'Escape') { + event.stopPropagation(); + this.hide(); + } + }; + + private handleMouseOver = () => { + if (this.hasTrigger('hover')) { + const delay = parseDuration(getComputedStyle(this).getPropertyValue('--show-delay')); + clearTimeout(this.hoverTimeout); + this.hoverTimeout = window.setTimeout(() => this.show(), delay); + } + }; + + private handleMouseOut = () => { + if (this.hasTrigger('hover')) { + const delay = parseDuration(getComputedStyle(this).getPropertyValue('--hide-delay')); + clearTimeout(this.hoverTimeout); + this.hoverTimeout = window.setTimeout(() => this.hide(), delay); + } + }; + + private hasTrigger(triggerType: string) { + const triggers = this.trigger.split(' '); + return triggers.includes(triggerType); + } + + @watch('open', { waitUntilFirstUpdate: true }) + async handleOpenChange() { + if (this.open) { + if (this.disabled) { + return; + } + + // Show + this.emit('sl-show'); + + await stopAnimations(this.body); + this.body.hidden = false; + this.popup.active = true; + const { keyframes, options } = getAnimation(this, 'tooltip.show', { dir: this.localize.dir() }); + await animateTo(this.popup.popup, keyframes, options); + + this.emit('sl-after-show'); + } else { + // Hide + this.emit('sl-hide'); + + await stopAnimations(this.body); + const { keyframes, options } = getAnimation(this, 'tooltip.hide', { dir: this.localize.dir() }); + await animateTo(this.popup.popup, keyframes, options); + this.popup.active = false; + this.body.hidden = true; + + this.emit('sl-after-hide'); + } + } + + @watch(['content', 'distance', 'hoist', 'placement', 'skidding']) + async handleOptionsChange() { + if (this.hasUpdated) { + await this.updateComplete; + this.popup.reposition(); + } + } + + @watch('disabled') + handleDisabledChange() { + if (this.disabled && this.open) { + this.hide(); + } + } + + /** Shows the tooltip. */ + async show() { + if (this.open) { + return undefined; + } + + this.open = true; + return waitForEvent(this, 'sl-after-show'); + } + + /** Hides the tooltip */ + async hide() { + if (!this.open) { + return undefined; + } + + this.open = false; + return waitForEvent(this, 'sl-after-hide'); + } + + // + // NOTE: Tooltip is a bit unique in that we're using aria-live instead of aria-labelledby to trick screen readers into + // announcing the content. It works really well, but it violates an accessibility rule. We're also adding the + // aria-describedby attribute to a slot, which is required by to correctly locate the first assigned + // element, otherwise positioning is incorrect. + // + render() { + return html` + + ${'' /* eslint-disable-next-line lit-a11y/no-aria-slot */} + + + ${'' /* eslint-disable-next-line lit-a11y/accessible-name */} + + + `; + } +} + +setDefaultAnimation('tooltip.show', { + keyframes: [ + { opacity: 0, scale: 0.8 }, + { opacity: 1, scale: 1 } + ], + options: { duration: 150, easing: 'ease' } +}); + +setDefaultAnimation('tooltip.hide', { + keyframes: [ + { opacity: 1, scale: 1 }, + { opacity: 0, scale: 0.8 } + ], + options: { duration: 150, easing: 'ease' } +}); + +declare global { + interface HTMLElementTagNameMap { + 'sl-tooltip': SlTooltip; + } +} diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 02af120a2f..587b41ec8f 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -1,302 +1,4 @@ -import '../popup/popup.js'; -import { animateTo, parseDuration, 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 './tooltip.styles.js'; -import type { CSSResultGroup } from 'lit'; -import type SlPopup from '../popup/popup.js'; - -/** - * @summary Tooltips display additional information based on a specific action. - * @documentation https://shoelace.style/components/tooltip - * @status stable - * @since 2.0 - * - * @dependency sl-popup - * - * @slot - The tooltip's target element. Avoid slotting in more than one element, as subsequent ones will be ignored. - * @slot content - The content to render in the tooltip. Alternatively, you can use the `content` attribute. - * - * @event sl-show - Emitted when the tooltip begins to show. - * @event sl-after-show - Emitted after the tooltip has shown and all animations are complete. - * @event sl-hide - Emitted when the tooltip begins to hide. - * @event sl-after-hide - Emitted after the tooltip has hidden and all animations are complete. - * - * @csspart base - The component's base wrapper, an `` element. - * @csspart base__popup - The popup's exported `popup` part. Use this to target the tooltip's popup container. - * @csspart base__arrow - The popup's exported `arrow` part. Use this to target the tooltip's arrow. - * @csspart body - The tooltip's body where its content is rendered. - * - * @cssproperty --max-width - The maximum width of the tooltip before its content will wrap. - * @cssproperty --hide-delay - The amount of time to wait before hiding the tooltip when hovering. - * @cssproperty --show-delay - The amount of time to wait before showing the tooltip when hovering. - * - * @animation tooltip.show - The animation to use when showing the tooltip. - * @animation tooltip.hide - The animation to use when hiding the tooltip. - */ -@customElement('sl-tooltip') -export default class SlTooltip extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - private hoverTimeout: number; - private readonly localize = new LocalizeController(this); - - @query('slot:not([name])') defaultSlot: HTMLSlotElement; - @query('.tooltip__body') body: HTMLElement; - @query('sl-popup') popup: SlPopup; - - /** The tooltip's content. If you need to display HTML, use the `content` slot instead. */ - @property() content = ''; - - /** - * The preferred placement of the tooltip. Note that the actual placement may vary as needed to keep the tooltip - * inside of the viewport. - */ - @property() placement: - | 'top' - | 'top-start' - | 'top-end' - | 'right' - | 'right-start' - | 'right-end' - | 'bottom' - | 'bottom-start' - | 'bottom-end' - | 'left' - | 'left-start' - | 'left-end' = 'top'; - - /** Disables the tooltip so it won't show when triggered. */ - @property({ type: Boolean, reflect: true }) disabled = false; - - /** The distance in pixels from which to offset the tooltip away from its target. */ - @property({ type: Number }) distance = 8; - - /** Indicates whether or not the tooltip is open. You can use this in lieu of the show/hide methods. */ - @property({ type: Boolean, reflect: true }) open = false; - - /** The distance in pixels from which to offset the tooltip along its target. */ - @property({ type: Number }) skidding = 0; - - /** - * Controls how the tooltip is activated. Possible options include `click`, `hover`, `focus`, and `manual`. Multiple - * options can be passed by separating them with a space. When manual is used, the tooltip must be activated - * programmatically. - */ - @property() trigger = 'hover focus'; - - /** - * Enable this option to prevent the tooltip from being clipped when the component is placed inside a container with - * `overflow: auto|hidden|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, - * scenarios. - */ - @property({ type: Boolean }) hoist = false; - - constructor() { - super(); - // TODO (justinfagnani): does this need to be done in firstUpdated for some - // reason? If so, document why in a comment. - this.addEventListener('blur', this.handleBlur, true); - this.addEventListener('focus', this.handleFocus, true); - this.addEventListener('click', this.handleClick); - this.addEventListener('keydown', this.handleKeyDown); - this.addEventListener('mouseover', this.handleMouseOver); - this.addEventListener('mouseout', this.handleMouseOut); - } - - connectedCallback() { - super.connectedCallback(); - } - - firstUpdated() { - this.body.hidden = !this.open; - - // If the tooltip is visible on init, update its position - if (this.open) { - this.popup.active = true; - this.popup.reposition(); - } - } - - private handleBlur = () => { - if (this.hasTrigger('focus')) { - this.hide(); - } - }; - - private handleClick = () => { - if (this.hasTrigger('click')) { - if (this.open) { - this.hide(); - } else { - this.show(); - } - } - }; - - private handleFocus = () => { - if (this.hasTrigger('focus')) { - this.show(); - } - }; - - private handleKeyDown = (event: KeyboardEvent) => { - // Pressing escape when the target element has focus should dismiss the tooltip - if (this.open && event.key === 'Escape') { - event.stopPropagation(); - this.hide(); - } - }; - - private handleMouseOver = () => { - if (this.hasTrigger('hover')) { - const delay = parseDuration(getComputedStyle(this).getPropertyValue('--show-delay')); - clearTimeout(this.hoverTimeout); - this.hoverTimeout = window.setTimeout(() => this.show(), delay); - } - }; - - private handleMouseOut = () => { - if (this.hasTrigger('hover')) { - const delay = parseDuration(getComputedStyle(this).getPropertyValue('--hide-delay')); - clearTimeout(this.hoverTimeout); - this.hoverTimeout = window.setTimeout(() => this.hide(), delay); - } - }; - - private hasTrigger(triggerType: string) { - const triggers = this.trigger.split(' '); - return triggers.includes(triggerType); - } - - @watch('open', { waitUntilFirstUpdate: true }) - async handleOpenChange() { - if (this.open) { - if (this.disabled) { - return; - } - - // Show - this.emit('sl-show'); - - await stopAnimations(this.body); - this.body.hidden = false; - this.popup.active = true; - const { keyframes, options } = getAnimation(this, 'tooltip.show', { dir: this.localize.dir() }); - await animateTo(this.popup.popup, keyframes, options); - - this.emit('sl-after-show'); - } else { - // Hide - this.emit('sl-hide'); - - await stopAnimations(this.body); - const { keyframes, options } = getAnimation(this, 'tooltip.hide', { dir: this.localize.dir() }); - await animateTo(this.popup.popup, keyframes, options); - this.popup.active = false; - this.body.hidden = true; - - this.emit('sl-after-hide'); - } - } - - @watch(['content', 'distance', 'hoist', 'placement', 'skidding']) - async handleOptionsChange() { - if (this.hasUpdated) { - await this.updateComplete; - this.popup.reposition(); - } - } - - @watch('disabled') - handleDisabledChange() { - if (this.disabled && this.open) { - this.hide(); - } - } - - /** Shows the tooltip. */ - async show() { - if (this.open) { - return undefined; - } - - this.open = true; - return waitForEvent(this, 'sl-after-show'); - } - - /** Hides the tooltip */ - async hide() { - if (!this.open) { - return undefined; - } - - this.open = false; - return waitForEvent(this, 'sl-after-hide'); - } - - // - // NOTE: Tooltip is a bit unique in that we're using aria-live instead of aria-labelledby to trick screen readers into - // announcing the content. It works really well, but it violates an accessibility rule. We're also adding the - // aria-describedby attribute to a slot, which is required by to correctly locate the first assigned - // element, otherwise positioning is incorrect. - // - render() { - return html` - - ${'' /* eslint-disable-next-line lit-a11y/no-aria-slot */} - - - ${'' /* eslint-disable-next-line lit-a11y/accessible-name */} - - - `; - } -} - -setDefaultAnimation('tooltip.show', { - keyframes: [ - { opacity: 0, scale: 0.8 }, - { opacity: 1, scale: 1 } - ], - options: { duration: 150, easing: 'ease' } -}); - -setDefaultAnimation('tooltip.hide', { - keyframes: [ - { opacity: 1, scale: 1 }, - { opacity: 0, scale: 0.8 } - ], - options: { duration: 150, easing: 'ease' } -}); - -declare global { - interface HTMLElementTagNameMap { - 'sl-tooltip': SlTooltip; - } -} +import SlTooltip from './tooltip.component.js'; +export * from './tooltip.component.js'; +export default SlTooltip; +SlTooltip.define('sl-tooltip'); diff --git a/src/components/tree-item/tree-item.component.ts b/src/components/tree-item/tree-item.component.ts new file mode 100644 index 0000000000..2fed06ced4 --- /dev/null +++ b/src/components/tree-item/tree-item.component.ts @@ -0,0 +1,323 @@ +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 { 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 { when } from 'lit/directives/when.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import SlCheckbox from '../checkbox/checkbox.component.js'; +import SlIcon from '../icon/icon.component.js'; +import SlSpinner from '../spinner/spinner.component.js'; +import styles from './tree-item.styles.js'; +import type { CSSResultGroup, PropertyValueMap } from 'lit'; + +/** + * @summary A tree item serves as a hierarchical node that lives inside a [tree](/components/tree). + * @documentation https://shoelace.style/components/tree-item + * @status stable + * @since 2.0 + * + * @dependency sl-checkbox + * @dependency sl-icon + * @dependency sl-spinner + * + * @event sl-expand - Emitted when the tree item expands. + * @event sl-after-expand - Emitted after the tree item expands and all animations are complete. + * @event sl-collapse - Emitted when the tree item collapses. + * @event sl-after-collapse - Emitted after the tree item collapses and all animations are complete. + * @event sl-lazy-change - Emitted when the tree item's lazy state changes. + * @event sl-lazy-load - Emitted when a lazy item is selected. Use this event to asynchronously load data and append + * items to the tree before expanding. After appending new items, remove the `lazy` attribute to remove the loading + * state and update the tree. + * + * @slot - The default slot. + * @slot expand-icon - The icon to show when the tree item is expanded. + * @slot collapse-icon - The icon to show when the tree item is collapsed. + * + * @csspart base - The component's base wrapper. + * @csspart item - The tree item's container. This element wraps everything except slotted tree item children. + * @csspart item--disabled - Applied when the tree item is disabled. + * @csspart item--expanded - Applied when the tree item is expanded. + * @csspart item--indeterminate - Applied when the selection is indeterminate. + * @csspart item--selected - Applied when the tree item is selected. + * @csspart indentation - The tree item's indentation container. + * @csspart expand-button - The container that wraps the tree item's expand button and spinner. + * @csspart label - The tree item's label. + * @csspart children - The container that wraps the tree item's nested children. + * @csspart checkbox - The checkbox that shows when using multiselect. + * @csspart checkbox__base - The checkbox's exported `base` part. + * @csspart checkbox__control - The checkbox's exported `control` part. + * @csspart checkbox__control--checked - The checkbox's exported `control--checked` part. + * @csspart checkbox__control--indeterminate - The checkbox's exported `control--indeterminate` part. + * @csspart checkbox__checked-icon - The checkbox's exported `checked-icon` part. + * @csspart checkbox__indeterminate-icon - The checkbox's exported `indeterminate-icon` part. + * @csspart checkbox__label - The checkbox's exported `label` part. + */ +export default class SlTreeItem extends ShoelaceElement { + static styles: CSSResultGroup = styles; + static dependencies = { + 'sl-checkbox': SlCheckbox, + 'sl-icon': SlIcon, + 'sl-spinner': SlSpinner + }; + + static isTreeItem(node: Node) { + return node instanceof Element && node.getAttribute('role') === 'treeitem'; + } + + private readonly localize = new LocalizeController(this); + + @state() indeterminate = false; + @state() isLeaf = false; + @state() loading = false; + @state() selectable = false; + + /** Expands the tree item. */ + @property({ type: Boolean, reflect: true }) expanded = false; + + /** Draws the tree item in a selected state. */ + @property({ type: Boolean, reflect: true }) selected = false; + + /** Disables the tree item. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** Enables lazy loading behavior. */ + @property({ type: Boolean, reflect: true }) lazy = false; + + @query('slot:not([name])') defaultSlot: HTMLSlotElement; + @query('slot[name=children]') childrenSlot: HTMLSlotElement; + @query('.tree-item__item') itemElement: HTMLDivElement; + @query('.tree-item__children') childrenContainer: HTMLDivElement; + @query('.tree-item__expand-button slot') expandButtonSlot: HTMLSlotElement; + + connectedCallback() { + super.connectedCallback(); + + this.setAttribute('role', 'treeitem'); + this.setAttribute('tabindex', '-1'); + + if (this.isNestedItem()) { + this.slot = 'children'; + } + } + + firstUpdated() { + this.childrenContainer.hidden = !this.expanded; + this.childrenContainer.style.height = this.expanded ? 'auto' : '0'; + + this.isLeaf = !this.lazy && this.getChildrenItems().length === 0; + this.handleExpandedChange(); + } + + private async animateCollapse() { + this.emit('sl-collapse'); + + await stopAnimations(this.childrenContainer); + + const { keyframes, options } = getAnimation(this, 'tree-item.collapse', { dir: this.localize.dir() }); + await animateTo( + this.childrenContainer, + shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight), + options + ); + this.childrenContainer.hidden = true; + + this.emit('sl-after-collapse'); + } + + // Checks whether the item is nested into an item + private isNestedItem(): boolean { + const parent = this.parentElement; + return !!parent && SlTreeItem.isTreeItem(parent); + } + + private handleChildrenSlotChange() { + this.loading = false; + this.isLeaf = !this.lazy && this.getChildrenItems().length === 0; + } + + protected willUpdate(changedProperties: PropertyValueMap | Map) { + if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) { + this.indeterminate = false; + } + } + + private async animateExpand() { + this.emit('sl-expand'); + + await stopAnimations(this.childrenContainer); + this.childrenContainer.hidden = false; + + const { keyframes, options } = getAnimation(this, 'tree-item.expand', { dir: this.localize.dir() }); + await animateTo( + this.childrenContainer, + shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight), + options + ); + this.childrenContainer.style.height = 'auto'; + + this.emit('sl-after-expand'); + } + + @watch('loading', { waitUntilFirstUpdate: true }) + handleLoadingChange() { + this.setAttribute('aria-busy', this.loading ? 'true' : 'false'); + + if (!this.loading) { + this.animateExpand(); + } + } + + @watch('disabled') + handleDisabledChange() { + this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + } + + @watch('selected') + handleSelectedChange() { + this.setAttribute('aria-selected', this.selected ? 'true' : 'false'); + } + + @watch('expanded', { waitUntilFirstUpdate: true }) + handleExpandedChange() { + if (!this.isLeaf) { + this.setAttribute('aria-expanded', this.expanded ? 'true' : 'false'); + } else { + this.removeAttribute('aria-expanded'); + } + } + + @watch('expanded', { waitUntilFirstUpdate: true }) + handleExpandAnimation() { + if (this.expanded) { + if (this.lazy) { + this.loading = true; + + this.emit('sl-lazy-load'); + } else { + this.animateExpand(); + } + } else { + this.animateCollapse(); + } + } + + @watch('lazy', { waitUntilFirstUpdate: true }) + handleLazyChange() { + this.emit('sl-lazy-change'); + } + + /** Gets all the nested tree items in this node. */ + getChildrenItems({ includeDisabled = true }: { includeDisabled?: boolean } = {}): SlTreeItem[] { + return this.childrenSlot + ? ([...this.childrenSlot.assignedElements({ flatten: true })].filter( + (item: SlTreeItem) => SlTreeItem.isTreeItem(item) && (includeDisabled || !item.disabled) + ) as SlTreeItem[]) + : []; + } + + render() { + const isRtl = this.localize.dir() === 'rtl'; + const showExpandButton = !this.loading && (!this.isLeaf || this.lazy); + + return html` +
+
+
+ + + + ${when( + this.selectable, + () => + html` + + ` + )} + + +
+ +
+ +
+
+ `; + } +} + +setDefaultAnimation('tree-item.expand', { + keyframes: [ + { height: '0', opacity: '0', overflow: 'hidden' }, + { height: 'auto', opacity: '1', overflow: 'hidden' } + ], + options: { duration: 250, easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)' } +}); + +setDefaultAnimation('tree-item.collapse', { + keyframes: [ + { height: 'auto', opacity: '1', overflow: 'hidden' }, + { height: '0', opacity: '0', overflow: 'hidden' } + ], + options: { duration: 200, easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)' } +}); + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree-item': SlTreeItem; + } +} diff --git a/src/components/tree-item/tree-item.ts b/src/components/tree-item/tree-item.ts index 892bd6c2cf..2baa5aa9b3 100644 --- a/src/components/tree-item/tree-item.ts +++ b/src/components/tree-item/tree-item.ts @@ -1,319 +1,4 @@ -import '../checkbox/checkbox.js'; -import '../icon/icon.js'; -import '../spinner/spinner.js'; -import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property, query, state } from 'lit/decorators.js'; -import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js'; -import { html } from 'lit'; -import { live } from 'lit/directives/live.js'; -import { LocalizeController } from '../../utilities/localize.js'; -import { watch } from '../../internal/watch.js'; -import { when } from 'lit/directives/when.js'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import styles from './tree-item.styles.js'; -import type { CSSResultGroup, PropertyValueMap } from 'lit'; - -/** - * @summary A tree item serves as a hierarchical node that lives inside a [tree](/components/tree). - * @documentation https://shoelace.style/components/tree-item - * @status stable - * @since 2.0 - * - * @dependency sl-checkbox - * @dependency sl-icon - * @dependency sl-spinner - * - * @event sl-expand - Emitted when the tree item expands. - * @event sl-after-expand - Emitted after the tree item expands and all animations are complete. - * @event sl-collapse - Emitted when the tree item collapses. - * @event sl-after-collapse - Emitted after the tree item collapses and all animations are complete. - * @event sl-lazy-change - Emitted when the tree item's lazy state changes. - * @event sl-lazy-load - Emitted when a lazy item is selected. Use this event to asynchronously load data and append - * items to the tree before expanding. After appending new items, remove the `lazy` attribute to remove the loading - * state and update the tree. - * - * @slot - The default slot. - * @slot expand-icon - The icon to show when the tree item is expanded. - * @slot collapse-icon - The icon to show when the tree item is collapsed. - * - * @csspart base - The component's base wrapper. - * @csspart item - The tree item's container. This element wraps everything except slotted tree item children. - * @csspart item--disabled - Applied when the tree item is disabled. - * @csspart item--expanded - Applied when the tree item is expanded. - * @csspart item--indeterminate - Applied when the selection is indeterminate. - * @csspart item--selected - Applied when the tree item is selected. - * @csspart indentation - The tree item's indentation container. - * @csspart expand-button - The container that wraps the tree item's expand button and spinner. - * @csspart label - The tree item's label. - * @csspart children - The container that wraps the tree item's nested children. - * @csspart checkbox - The checkbox that shows when using multiselect. - * @csspart checkbox__base - The checkbox's exported `base` part. - * @csspart checkbox__control - The checkbox's exported `control` part. - * @csspart checkbox__control--checked - The checkbox's exported `control--checked` part. - * @csspart checkbox__control--indeterminate - The checkbox's exported `control--indeterminate` part. - * @csspart checkbox__checked-icon - The checkbox's exported `checked-icon` part. - * @csspart checkbox__indeterminate-icon - The checkbox's exported `indeterminate-icon` part. - * @csspart checkbox__label - The checkbox's exported `label` part. - */ -@customElement('sl-tree-item') -export default class SlTreeItem extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - static isTreeItem(node: Node) { - return node instanceof Element && node.getAttribute('role') === 'treeitem'; - } - - private readonly localize = new LocalizeController(this); - - @state() indeterminate = false; - @state() isLeaf = false; - @state() loading = false; - @state() selectable = false; - - /** Expands the tree item. */ - @property({ type: Boolean, reflect: true }) expanded = false; - - /** Draws the tree item in a selected state. */ - @property({ type: Boolean, reflect: true }) selected = false; - - /** Disables the tree item. */ - @property({ type: Boolean, reflect: true }) disabled = false; - - /** Enables lazy loading behavior. */ - @property({ type: Boolean, reflect: true }) lazy = false; - - @query('slot:not([name])') defaultSlot: HTMLSlotElement; - @query('slot[name=children]') childrenSlot: HTMLSlotElement; - @query('.tree-item__item') itemElement: HTMLDivElement; - @query('.tree-item__children') childrenContainer: HTMLDivElement; - @query('.tree-item__expand-button slot') expandButtonSlot: HTMLSlotElement; - - connectedCallback() { - super.connectedCallback(); - - this.setAttribute('role', 'treeitem'); - this.setAttribute('tabindex', '-1'); - - if (this.isNestedItem()) { - this.slot = 'children'; - } - } - - firstUpdated() { - this.childrenContainer.hidden = !this.expanded; - this.childrenContainer.style.height = this.expanded ? 'auto' : '0'; - - this.isLeaf = !this.lazy && this.getChildrenItems().length === 0; - this.handleExpandedChange(); - } - - private async animateCollapse() { - this.emit('sl-collapse'); - - await stopAnimations(this.childrenContainer); - - const { keyframes, options } = getAnimation(this, 'tree-item.collapse', { dir: this.localize.dir() }); - await animateTo( - this.childrenContainer, - shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight), - options - ); - this.childrenContainer.hidden = true; - - this.emit('sl-after-collapse'); - } - - // Checks whether the item is nested into an item - private isNestedItem(): boolean { - const parent = this.parentElement; - return !!parent && SlTreeItem.isTreeItem(parent); - } - - private handleChildrenSlotChange() { - this.loading = false; - this.isLeaf = !this.lazy && this.getChildrenItems().length === 0; - } - - protected willUpdate(changedProperties: PropertyValueMap | Map) { - if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) { - this.indeterminate = false; - } - } - - private async animateExpand() { - this.emit('sl-expand'); - - await stopAnimations(this.childrenContainer); - this.childrenContainer.hidden = false; - - const { keyframes, options } = getAnimation(this, 'tree-item.expand', { dir: this.localize.dir() }); - await animateTo( - this.childrenContainer, - shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight), - options - ); - this.childrenContainer.style.height = 'auto'; - - this.emit('sl-after-expand'); - } - - @watch('loading', { waitUntilFirstUpdate: true }) - handleLoadingChange() { - this.setAttribute('aria-busy', this.loading ? 'true' : 'false'); - - if (!this.loading) { - this.animateExpand(); - } - } - - @watch('disabled') - handleDisabledChange() { - this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); - } - - @watch('selected') - handleSelectedChange() { - this.setAttribute('aria-selected', this.selected ? 'true' : 'false'); - } - - @watch('expanded', { waitUntilFirstUpdate: true }) - handleExpandedChange() { - if (!this.isLeaf) { - this.setAttribute('aria-expanded', this.expanded ? 'true' : 'false'); - } else { - this.removeAttribute('aria-expanded'); - } - } - - @watch('expanded', { waitUntilFirstUpdate: true }) - handleExpandAnimation() { - if (this.expanded) { - if (this.lazy) { - this.loading = true; - - this.emit('sl-lazy-load'); - } else { - this.animateExpand(); - } - } else { - this.animateCollapse(); - } - } - - @watch('lazy', { waitUntilFirstUpdate: true }) - handleLazyChange() { - this.emit('sl-lazy-change'); - } - - /** Gets all the nested tree items in this node. */ - getChildrenItems({ includeDisabled = true }: { includeDisabled?: boolean } = {}): SlTreeItem[] { - return this.childrenSlot - ? ([...this.childrenSlot.assignedElements({ flatten: true })].filter( - (item: SlTreeItem) => SlTreeItem.isTreeItem(item) && (includeDisabled || !item.disabled) - ) as SlTreeItem[]) - : []; - } - - render() { - const isRtl = this.localize.dir() === 'rtl'; - const showExpandButton = !this.loading && (!this.isLeaf || this.lazy); - - return html` -
-
-
- - - - ${when( - this.selectable, - () => - html` - - ` - )} - - -
- -
- -
-
- `; - } -} - -setDefaultAnimation('tree-item.expand', { - keyframes: [ - { height: '0', opacity: '0', overflow: 'hidden' }, - { height: 'auto', opacity: '1', overflow: 'hidden' } - ], - options: { duration: 250, easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)' } -}); - -setDefaultAnimation('tree-item.collapse', { - keyframes: [ - { height: 'auto', opacity: '1', overflow: 'hidden' }, - { height: '0', opacity: '0', overflow: 'hidden' } - ], - options: { duration: 200, easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)' } -}); - -declare global { - interface HTMLElementTagNameMap { - 'sl-tree-item': SlTreeItem; - } -} +import SlTreeItem from './tree-item.component.js'; +export * from './tree-item.component.js'; +export default SlTreeItem; +SlTreeItem.define('sl-tree-item'); diff --git a/src/components/tree/tree.component.ts b/src/components/tree/tree.component.ts new file mode 100644 index 0000000000..245a7f7b8b --- /dev/null +++ b/src/components/tree/tree.component.ts @@ -0,0 +1,421 @@ +import { clamp } from '../../internal/math.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query } from 'lit/decorators.js'; +import { watch } from '../../internal/watch.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import SlTreeItem from '../tree-item/tree-item.component.js'; +import styles from './tree.styles.js'; +import type { CSSResultGroup } from 'lit'; + +function syncCheckboxes(changedTreeItem: SlTreeItem, initialSync = false) { + function syncParentItem(treeItem: SlTreeItem) { + const children = treeItem.getChildrenItems({ includeDisabled: false }); + + if (children.length) { + const allChecked = children.every(item => item.selected); + const allUnchecked = children.every(item => !item.selected && !item.indeterminate); + + treeItem.selected = allChecked; + treeItem.indeterminate = !allChecked && !allUnchecked; + } + } + + function syncAncestors(treeItem: SlTreeItem) { + const parentItem: SlTreeItem | null = treeItem.parentElement as SlTreeItem; + + if (SlTreeItem.isTreeItem(parentItem)) { + syncParentItem(parentItem); + syncAncestors(parentItem); + } + } + + function syncDescendants(treeItem: SlTreeItem) { + for (const childItem of treeItem.getChildrenItems()) { + childItem.selected = initialSync + ? treeItem.selected || childItem.selected + : !childItem.disabled && treeItem.selected; + + syncDescendants(childItem); + } + + if (initialSync) { + syncParentItem(treeItem); + } + } + + syncDescendants(changedTreeItem); + syncAncestors(changedTreeItem); +} + +/** + * @summary Trees allow you to display a hierarchical list of selectable [tree items](/components/tree-item). Items with children can be expanded and collapsed as desired by the user. + * @documentation https://shoelace.style/components/tree + * @status stable + * @since 2.0 + * + * @event {{ selection: SlTreeItem[] }} sl-selection-change - Emitted when a tree item is selected or deselected. + * + * @slot - The default slot. + * @slot expand-icon - The icon to show when the tree item is expanded. Works best with ``. + * @slot collapse-icon - The icon to show when the tree item is collapsed. Works best with ``. + * + * @csspart base - The component's base wrapper. + * + * @cssproperty [--indent-size=var(--sl-spacing-medium)] - The size of the indentation for nested items. + * @cssproperty [--indent-guide-color=var(--sl-color-neutral-200)] - The color of the indentation line. + * @cssproperty [--indent-guide-offset=0] - The amount of vertical spacing to leave between the top and bottom of the + * indentation line's starting position. + * @cssproperty [--indent-guide-style=solid] - The style of the indentation line, e.g. solid, dotted, dashed. + * @cssproperty [--indent-guide-width=0] - The width of the indentation line. + */ +export default class SlTree extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + @query('slot:not([name])') defaultSlot: HTMLSlotElement; + @query('slot[name=expand-icon]') expandedIconSlot: HTMLSlotElement; + @query('slot[name=collapse-icon]') collapsedIconSlot: HTMLSlotElement; + + /** + * The selection behavior of the tree. Single selection allows only one node to be selected at a time. Multiple + * displays checkboxes and allows more than one node to be selected. Leaf allows only leaf nodes to be selected. + */ + @property() selection: 'single' | 'multiple' | 'leaf' = 'single'; + + // + // A collection of all the items in the tree, in the order they appear. The collection is live, meaning it is + // automatically updated when the underlying document is changed. + // + private lastFocusedItem: SlTreeItem | null; + private readonly localize = new LocalizeController(this); + private mutationObserver: MutationObserver; + private clickTarget: SlTreeItem | null = null; + + constructor() { + super(); + this.addEventListener('focusin', this.handleFocusIn); + this.addEventListener('focusout', this.handleFocusOut); + this.addEventListener('sl-lazy-change', this.handleSlotChange); + } + + async connectedCallback() { + super.connectedCallback(); + + this.setAttribute('role', 'tree'); + this.setAttribute('tabindex', '0'); + + await this.updateComplete; + + this.mutationObserver = new MutationObserver(this.handleTreeChanged); + this.mutationObserver.observe(this, { childList: true, subtree: true }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this.mutationObserver.disconnect(); + } + + // Generates a clone of the expand icon element to use for each tree item + private getExpandButtonIcon(status: 'expand' | 'collapse') { + const slot = status === 'expand' ? this.expandedIconSlot : this.collapsedIconSlot; + const icon = slot.assignedElements({ flatten: true })[0] as HTMLElement; + + // Clone it, remove ids, and slot it + if (icon) { + const clone = icon.cloneNode(true) as HTMLElement; + [clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id')); + clone.setAttribute('data-default', ''); + clone.slot = `${status}-icon`; + + return clone; + } + + return null; + } + + // Initializes new items by setting the `selectable` property and the expanded/collapsed icons if any + private initTreeItem = (item: SlTreeItem) => { + item.selectable = this.selection === 'multiple'; + + ['expand', 'collapse'] + .filter(status => !!this.querySelector(`[slot="${status}-icon"]`)) + .forEach((status: 'expand' | 'collapse') => { + const existingIcon = item.querySelector(`[slot="${status}-icon"]`); + + if (existingIcon === null) { + // No separator exists, add one + item.append(this.getExpandButtonIcon(status)!); + } else if (existingIcon.hasAttribute('data-default')) { + // A default separator exists, replace it + existingIcon.replaceWith(this.getExpandButtonIcon(status)!); + } else { + // The user provided a custom icon, leave it alone + } + }); + }; + + private handleTreeChanged = (mutations: MutationRecord[]) => { + for (const mutation of mutations) { + const addedNodes: SlTreeItem[] = [...mutation.addedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[]; + const removedNodes = [...mutation.removedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[]; + + addedNodes.forEach(this.initTreeItem); + + if (this.lastFocusedItem && removedNodes.includes(this.lastFocusedItem)) { + this.lastFocusedItem = null; + } + } + }; + + private syncTreeItems(selectedItem: SlTreeItem) { + const items = this.getAllTreeItems(); + + if (this.selection === 'multiple') { + syncCheckboxes(selectedItem); + } else { + for (const item of items) { + if (item !== selectedItem) { + item.selected = false; + } + } + } + } + + private selectItem(selectedItem: SlTreeItem) { + const previousSelection = [...this.selectedItems]; + + if (this.selection === 'multiple') { + selectedItem.selected = !selectedItem.selected; + if (selectedItem.lazy) { + selectedItem.expanded = true; + } + this.syncTreeItems(selectedItem); + } else if (this.selection === 'single' || selectedItem.isLeaf) { + selectedItem.expanded = !selectedItem.expanded; + selectedItem.selected = true; + + this.syncTreeItems(selectedItem); + } else if (this.selection === 'leaf') { + selectedItem.expanded = !selectedItem.expanded; + } + + const nextSelection = this.selectedItems; + + if ( + previousSelection.length !== nextSelection.length || + nextSelection.some(item => !previousSelection.includes(item)) + ) { + // Wait for the tree items' DOM to update before emitting + Promise.all(nextSelection.map(el => el.updateComplete)).then(() => { + this.emit('sl-selection-change', { detail: { selection: nextSelection } }); + }); + } + } + + private getAllTreeItems() { + return [...this.querySelectorAll('sl-tree-item')]; + } + + private focusItem(item?: SlTreeItem | null) { + item?.focus(); + } + + private handleKeyDown(event: KeyboardEvent) { + if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(event.key)) { + return; + } + + const items = this.getFocusableItems(); + const isLtr = this.localize.dir() === 'ltr'; + const isRtl = this.localize.dir() === 'rtl'; + + if (items.length > 0) { + event.preventDefault(); + const activeItemIndex = items.findIndex(item => item.matches(':focus')); + const activeItem: SlTreeItem | undefined = items[activeItemIndex]; + + const focusItemAt = (index: number) => { + const item = items[clamp(index, 0, items.length - 1)]; + this.focusItem(item); + }; + const toggleExpand = (expanded: boolean) => { + activeItem.expanded = expanded; + }; + + if (event.key === 'ArrowDown') { + // Moves focus to the next node that is focusable without opening or closing a node. + focusItemAt(activeItemIndex + 1); + } else if (event.key === 'ArrowUp') { + // Moves focus to the next node that is focusable without opening or closing a node. + focusItemAt(activeItemIndex - 1); + } else if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) { + // + // When focus is on a closed node, opens the node; focus does not move. + // When focus is on a open node, moves focus to the first child node. + // When focus is on an end node (a tree item with no children), does nothing. + // + if (!activeItem || activeItem.disabled || activeItem.expanded || (activeItem.isLeaf && !activeItem.lazy)) { + focusItemAt(activeItemIndex + 1); + } else { + toggleExpand(true); + } + } else if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) { + // + // When focus is on an open node, closes the node. + // When focus is on a child node that is also either an end node or a closed node, moves focus to its parent node. + // When focus is on a closed `tree`, does nothing. + // + if (!activeItem || activeItem.disabled || activeItem.isLeaf || !activeItem.expanded) { + focusItemAt(activeItemIndex - 1); + } else { + toggleExpand(false); + } + } else if (event.key === 'Home') { + // Moves focus to the first node in the tree without opening or closing a node. + focusItemAt(0); + } else if (event.key === 'End') { + // Moves focus to the last node in the tree that is focusable without opening the node. + focusItemAt(items.length - 1); + } else if (event.key === 'Enter' || event.key === ' ') { + // Selects the focused node. + if (!activeItem.disabled) { + this.selectItem(activeItem); + } + } + } + } + + private handleClick(event: Event) { + const target = event.target as SlTreeItem; + const treeItem = target.closest('sl-tree-item')!; + const isExpandButton = event + .composedPath() + .some((el: HTMLElement) => el?.classList?.contains('tree-item__expand-button')); + + // + // Don't Do anything if there's no tree item, if it's disabled, or if the click doesn't match the initial target + // from mousedown. The latter case prevents the user from starting a click on one item and ending it on another, + // causing the parent node to collapse. + // + // See https://github.com/shoelace-style/shoelace/issues/1082 + // + if (!treeItem || treeItem.disabled || target !== this.clickTarget) { + return; + } + + if (this.selection === 'multiple' && isExpandButton) { + treeItem.expanded = !treeItem.expanded; + } else { + this.selectItem(treeItem); + } + } + + handleMouseDown(event: MouseEvent) { + // Record the click target so we know which item the click initially targeted + this.clickTarget = event.target as SlTreeItem; + } + + private handleFocusOut = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement; + + // If the element that got the focus is not in the tree + if (!relatedTarget || !this.contains(relatedTarget)) { + this.tabIndex = 0; + } + }; + + private handleFocusIn = (event: FocusEvent) => { + const target = event.target as SlTreeItem; + + // If the tree has been focused, move the focus to the last focused item + if (event.target === this) { + this.focusItem(this.lastFocusedItem || this.getAllTreeItems()[0]); + } + + // If the target is a tree item, update the tabindex + if (SlTreeItem.isTreeItem(target) && !target.disabled) { + if (this.lastFocusedItem) { + this.lastFocusedItem.tabIndex = -1; + } + this.lastFocusedItem = target; + this.tabIndex = -1; + + target.tabIndex = 0; + } + }; + + private handleSlotChange() { + const items = this.getAllTreeItems(); + items.forEach(this.initTreeItem); + } + + @watch('selection') + async handleSelectionChange() { + const isSelectionMultiple = this.selection === 'multiple'; + const items = this.getAllTreeItems(); + + this.setAttribute('aria-multiselectable', isSelectionMultiple ? 'true' : 'false'); + + for (const item of items) { + item.selectable = isSelectionMultiple; + } + + if (isSelectionMultiple) { + await this.updateComplete; + + [...this.querySelectorAll(':scope > sl-tree-item')].forEach((treeItem: SlTreeItem) => + syncCheckboxes(treeItem, true) + ); + } + } + + /** @internal Returns the list of tree items that are selected in the tree. */ + get selectedItems(): SlTreeItem[] { + const items = this.getAllTreeItems(); + const isSelected = (item: SlTreeItem) => item.selected; + + return items.filter(isSelected); + } + + /** @internal Gets focusable tree items in the tree. */ + getFocusableItems() { + const items = this.getAllTreeItems(); + const collapsedItems = new Set(); + + return items.filter(item => { + // Exclude disabled elements + if (item.disabled) return false; + + // Exclude those whose parent is collapsed or loading + const parent: SlTreeItem | null | undefined = item.parentElement?.closest('[role=treeitem]'); + if (parent && (!parent.expanded || parent.loading || collapsedItems.has(parent))) { + collapsedItems.add(item); + } + + return !collapsedItems.has(item); + }); + } + + render() { + return html` +
+ + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree': SlTree; + } +} diff --git a/src/components/tree/tree.test.ts b/src/components/tree/tree.test.ts index e654df3602..6c52e23119 100644 --- a/src/components/tree/tree.test.ts +++ b/src/components/tree/tree.test.ts @@ -3,7 +3,7 @@ import { aTimeout, expect, fixture, html, triggerBlurFor, triggerFocusFor } from import { clickOnElement } from '../../internal/test.js'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; -import type SlTree from './tree.js'; +import type SlTree from './tree.component.js'; import type SlTreeItem from '../tree-item/tree-item.js'; describe('', () => { diff --git a/src/components/tree/tree.ts b/src/components/tree/tree.ts index 7522bc10a0..00dfef0ef5 100644 --- a/src/components/tree/tree.ts +++ b/src/components/tree/tree.ts @@ -1,422 +1,4 @@ -import { clamp } from '../../internal/math.js'; -import { customElement, property, query } 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 SlTreeItem from '../tree-item/tree-item.js'; -import styles from './tree.styles.js'; -import type { CSSResultGroup } from 'lit'; - -function syncCheckboxes(changedTreeItem: SlTreeItem, initialSync = false) { - function syncParentItem(treeItem: SlTreeItem) { - const children = treeItem.getChildrenItems({ includeDisabled: false }); - - if (children.length) { - const allChecked = children.every(item => item.selected); - const allUnchecked = children.every(item => !item.selected && !item.indeterminate); - - treeItem.selected = allChecked; - treeItem.indeterminate = !allChecked && !allUnchecked; - } - } - - function syncAncestors(treeItem: SlTreeItem) { - const parentItem: SlTreeItem | null = treeItem.parentElement as SlTreeItem; - - if (SlTreeItem.isTreeItem(parentItem)) { - syncParentItem(parentItem); - syncAncestors(parentItem); - } - } - - function syncDescendants(treeItem: SlTreeItem) { - for (const childItem of treeItem.getChildrenItems()) { - childItem.selected = initialSync - ? treeItem.selected || childItem.selected - : !childItem.disabled && treeItem.selected; - - syncDescendants(childItem); - } - - if (initialSync) { - syncParentItem(treeItem); - } - } - - syncDescendants(changedTreeItem); - syncAncestors(changedTreeItem); -} - -/** - * @summary Trees allow you to display a hierarchical list of selectable [tree items](/components/tree-item). Items with children can be expanded and collapsed as desired by the user. - * @documentation https://shoelace.style/components/tree - * @status stable - * @since 2.0 - * - * @event {{ selection: SlTreeItem[] }} sl-selection-change - Emitted when a tree item is selected or deselected. - * - * @slot - The default slot. - * @slot expand-icon - The icon to show when the tree item is expanded. Works best with ``. - * @slot collapse-icon - The icon to show when the tree item is collapsed. Works best with ``. - * - * @csspart base - The component's base wrapper. - * - * @cssproperty [--indent-size=var(--sl-spacing-medium)] - The size of the indentation for nested items. - * @cssproperty [--indent-guide-color=var(--sl-color-neutral-200)] - The color of the indentation line. - * @cssproperty [--indent-guide-offset=0] - The amount of vertical spacing to leave between the top and bottom of the - * indentation line's starting position. - * @cssproperty [--indent-guide-style=solid] - The style of the indentation line, e.g. solid, dotted, dashed. - * @cssproperty [--indent-guide-width=0] - The width of the indentation line. - */ -@customElement('sl-tree') -export default class SlTree extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - @query('slot:not([name])') defaultSlot: HTMLSlotElement; - @query('slot[name=expand-icon]') expandedIconSlot: HTMLSlotElement; - @query('slot[name=collapse-icon]') collapsedIconSlot: HTMLSlotElement; - - /** - * The selection behavior of the tree. Single selection allows only one node to be selected at a time. Multiple - * displays checkboxes and allows more than one node to be selected. Leaf allows only leaf nodes to be selected. - */ - @property() selection: 'single' | 'multiple' | 'leaf' = 'single'; - - // - // A collection of all the items in the tree, in the order they appear. The collection is live, meaning it is - // automatically updated when the underlying document is changed. - // - private lastFocusedItem: SlTreeItem | null; - private readonly localize = new LocalizeController(this); - private mutationObserver: MutationObserver; - private clickTarget: SlTreeItem | null = null; - - constructor() { - super(); - this.addEventListener('focusin', this.handleFocusIn); - this.addEventListener('focusout', this.handleFocusOut); - this.addEventListener('sl-lazy-change', this.handleSlotChange); - } - - async connectedCallback() { - super.connectedCallback(); - - this.setAttribute('role', 'tree'); - this.setAttribute('tabindex', '0'); - - await this.updateComplete; - - this.mutationObserver = new MutationObserver(this.handleTreeChanged); - this.mutationObserver.observe(this, { childList: true, subtree: true }); - } - - disconnectedCallback() { - super.disconnectedCallback(); - - this.mutationObserver.disconnect(); - } - - // Generates a clone of the expand icon element to use for each tree item - private getExpandButtonIcon(status: 'expand' | 'collapse') { - const slot = status === 'expand' ? this.expandedIconSlot : this.collapsedIconSlot; - const icon = slot.assignedElements({ flatten: true })[0] as HTMLElement; - - // Clone it, remove ids, and slot it - if (icon) { - const clone = icon.cloneNode(true) as HTMLElement; - [clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id')); - clone.setAttribute('data-default', ''); - clone.slot = `${status}-icon`; - - return clone; - } - - return null; - } - - // Initializes new items by setting the `selectable` property and the expanded/collapsed icons if any - private initTreeItem = (item: SlTreeItem) => { - item.selectable = this.selection === 'multiple'; - - ['expand', 'collapse'] - .filter(status => !!this.querySelector(`[slot="${status}-icon"]`)) - .forEach((status: 'expand' | 'collapse') => { - const existingIcon = item.querySelector(`[slot="${status}-icon"]`); - - if (existingIcon === null) { - // No separator exists, add one - item.append(this.getExpandButtonIcon(status)!); - } else if (existingIcon.hasAttribute('data-default')) { - // A default separator exists, replace it - existingIcon.replaceWith(this.getExpandButtonIcon(status)!); - } else { - // The user provided a custom icon, leave it alone - } - }); - }; - - private handleTreeChanged = (mutations: MutationRecord[]) => { - for (const mutation of mutations) { - const addedNodes: SlTreeItem[] = [...mutation.addedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[]; - const removedNodes = [...mutation.removedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[]; - - addedNodes.forEach(this.initTreeItem); - - if (this.lastFocusedItem && removedNodes.includes(this.lastFocusedItem)) { - this.lastFocusedItem = null; - } - } - }; - - private syncTreeItems(selectedItem: SlTreeItem) { - const items = this.getAllTreeItems(); - - if (this.selection === 'multiple') { - syncCheckboxes(selectedItem); - } else { - for (const item of items) { - if (item !== selectedItem) { - item.selected = false; - } - } - } - } - - private selectItem(selectedItem: SlTreeItem) { - const previousSelection = [...this.selectedItems]; - - if (this.selection === 'multiple') { - selectedItem.selected = !selectedItem.selected; - if (selectedItem.lazy) { - selectedItem.expanded = true; - } - this.syncTreeItems(selectedItem); - } else if (this.selection === 'single' || selectedItem.isLeaf) { - selectedItem.expanded = !selectedItem.expanded; - selectedItem.selected = true; - - this.syncTreeItems(selectedItem); - } else if (this.selection === 'leaf') { - selectedItem.expanded = !selectedItem.expanded; - } - - const nextSelection = this.selectedItems; - - if ( - previousSelection.length !== nextSelection.length || - nextSelection.some(item => !previousSelection.includes(item)) - ) { - // Wait for the tree items' DOM to update before emitting - Promise.all(nextSelection.map(el => el.updateComplete)).then(() => { - this.emit('sl-selection-change', { detail: { selection: nextSelection } }); - }); - } - } - - private getAllTreeItems() { - return [...this.querySelectorAll('sl-tree-item')]; - } - - private focusItem(item?: SlTreeItem | null) { - item?.focus(); - } - - private handleKeyDown(event: KeyboardEvent) { - if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(event.key)) { - return; - } - - const items = this.getFocusableItems(); - const isLtr = this.localize.dir() === 'ltr'; - const isRtl = this.localize.dir() === 'rtl'; - - if (items.length > 0) { - event.preventDefault(); - const activeItemIndex = items.findIndex(item => item.matches(':focus')); - const activeItem: SlTreeItem | undefined = items[activeItemIndex]; - - const focusItemAt = (index: number) => { - const item = items[clamp(index, 0, items.length - 1)]; - this.focusItem(item); - }; - const toggleExpand = (expanded: boolean) => { - activeItem.expanded = expanded; - }; - - if (event.key === 'ArrowDown') { - // Moves focus to the next node that is focusable without opening or closing a node. - focusItemAt(activeItemIndex + 1); - } else if (event.key === 'ArrowUp') { - // Moves focus to the next node that is focusable without opening or closing a node. - focusItemAt(activeItemIndex - 1); - } else if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) { - // - // When focus is on a closed node, opens the node; focus does not move. - // When focus is on a open node, moves focus to the first child node. - // When focus is on an end node (a tree item with no children), does nothing. - // - if (!activeItem || activeItem.disabled || activeItem.expanded || (activeItem.isLeaf && !activeItem.lazy)) { - focusItemAt(activeItemIndex + 1); - } else { - toggleExpand(true); - } - } else if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) { - // - // When focus is on an open node, closes the node. - // When focus is on a child node that is also either an end node or a closed node, moves focus to its parent node. - // When focus is on a closed `tree`, does nothing. - // - if (!activeItem || activeItem.disabled || activeItem.isLeaf || !activeItem.expanded) { - focusItemAt(activeItemIndex - 1); - } else { - toggleExpand(false); - } - } else if (event.key === 'Home') { - // Moves focus to the first node in the tree without opening or closing a node. - focusItemAt(0); - } else if (event.key === 'End') { - // Moves focus to the last node in the tree that is focusable without opening the node. - focusItemAt(items.length - 1); - } else if (event.key === 'Enter' || event.key === ' ') { - // Selects the focused node. - if (!activeItem.disabled) { - this.selectItem(activeItem); - } - } - } - } - - private handleClick(event: Event) { - const target = event.target as SlTreeItem; - const treeItem = target.closest('sl-tree-item')!; - const isExpandButton = event - .composedPath() - .some((el: HTMLElement) => el?.classList?.contains('tree-item__expand-button')); - - // - // Don't Do anything if there's no tree item, if it's disabled, or if the click doesn't match the initial target - // from mousedown. The latter case prevents the user from starting a click on one item and ending it on another, - // causing the parent node to collapse. - // - // See https://github.com/shoelace-style/shoelace/issues/1082 - // - if (!treeItem || treeItem.disabled || target !== this.clickTarget) { - return; - } - - if (this.selection === 'multiple' && isExpandButton) { - treeItem.expanded = !treeItem.expanded; - } else { - this.selectItem(treeItem); - } - } - - handleMouseDown(event: MouseEvent) { - // Record the click target so we know which item the click initially targeted - this.clickTarget = event.target as SlTreeItem; - } - - private handleFocusOut = (event: FocusEvent) => { - const relatedTarget = event.relatedTarget as HTMLElement; - - // If the element that got the focus is not in the tree - if (!relatedTarget || !this.contains(relatedTarget)) { - this.tabIndex = 0; - } - }; - - private handleFocusIn = (event: FocusEvent) => { - const target = event.target as SlTreeItem; - - // If the tree has been focused, move the focus to the last focused item - if (event.target === this) { - this.focusItem(this.lastFocusedItem || this.getAllTreeItems()[0]); - } - - // If the target is a tree item, update the tabindex - if (SlTreeItem.isTreeItem(target) && !target.disabled) { - if (this.lastFocusedItem) { - this.lastFocusedItem.tabIndex = -1; - } - this.lastFocusedItem = target; - this.tabIndex = -1; - - target.tabIndex = 0; - } - }; - - private handleSlotChange() { - const items = this.getAllTreeItems(); - items.forEach(this.initTreeItem); - } - - @watch('selection') - async handleSelectionChange() { - const isSelectionMultiple = this.selection === 'multiple'; - const items = this.getAllTreeItems(); - - this.setAttribute('aria-multiselectable', isSelectionMultiple ? 'true' : 'false'); - - for (const item of items) { - item.selectable = isSelectionMultiple; - } - - if (isSelectionMultiple) { - await this.updateComplete; - - [...this.querySelectorAll(':scope > sl-tree-item')].forEach((treeItem: SlTreeItem) => - syncCheckboxes(treeItem, true) - ); - } - } - - /** @internal Returns the list of tree items that are selected in the tree. */ - get selectedItems(): SlTreeItem[] { - const items = this.getAllTreeItems(); - const isSelected = (item: SlTreeItem) => item.selected; - - return items.filter(isSelected); - } - - /** @internal Gets focusable tree items in the tree. */ - getFocusableItems() { - const items = this.getAllTreeItems(); - const collapsedItems = new Set(); - - return items.filter(item => { - // Exclude disabled elements - if (item.disabled) return false; - - // Exclude those whose parent is collapsed or loading - const parent: SlTreeItem | null | undefined = item.parentElement?.closest('[role=treeitem]'); - if (parent && (!parent.expanded || parent.loading || collapsedItems.has(parent))) { - collapsedItems.add(item); - } - - return !collapsedItems.has(item); - }); - } - - render() { - return html` -
- - - -
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-tree': SlTree; - } -} +import SlTree from './tree.component.js'; +export * from './tree.component.js'; +export default SlTree; +SlTree.define('sl-tree'); diff --git a/src/components/visually-hidden/visually-hidden.component.ts b/src/components/visually-hidden/visually-hidden.component.ts new file mode 100644 index 0000000000..28e0d46477 --- /dev/null +++ b/src/components/visually-hidden/visually-hidden.component.ts @@ -0,0 +1,26 @@ +import { html } from 'lit'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './visually-hidden.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary The visually hidden utility makes content accessible to assistive devices without displaying it on the screen. + * @documentation https://shoelace.style/components/visually-hidden + * @status stable + * @since 2.0 + * + * @slot - The content to be visually hidden. + */ +export default class SlVisuallyHidden extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + render() { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-visually-hidden': SlVisuallyHidden; + } +} diff --git a/src/components/visually-hidden/visually-hidden.ts b/src/components/visually-hidden/visually-hidden.ts index 265e9efc0c..2d2df3e92d 100644 --- a/src/components/visually-hidden/visually-hidden.ts +++ b/src/components/visually-hidden/visually-hidden.ts @@ -1,28 +1,4 @@ -import { customElement } from 'lit/decorators.js'; -import { html } from 'lit'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import styles from './visually-hidden.styles.js'; -import type { CSSResultGroup } from 'lit'; - -/** - * @summary The visually hidden utility makes content accessible to assistive devices without displaying it on the screen. - * @documentation https://shoelace.style/components/visually-hidden - * @status stable - * @since 2.0 - * - * @slot - The content to be visually hidden. - */ -@customElement('sl-visually-hidden') -export default class SlVisuallyHidden extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - render() { - return html` `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'sl-visually-hidden': SlVisuallyHidden; - } -} +import SlVisuallyHidden from './visually-hidden.component.js'; +export * from './visually-hidden.component.js'; +export default SlVisuallyHidden; +SlVisuallyHidden.define('sl-visually-hidden'); diff --git a/src/internal/shoelace-element.test.ts b/src/internal/shoelace-element.test.ts new file mode 100644 index 0000000000..3199015efb --- /dev/null +++ b/src/internal/shoelace-element.test.ts @@ -0,0 +1,147 @@ +// TODO: Write logic around custom element definitions, no defs, conflicting defs, same defs, etc. +import { expect } from '@open-wc/testing'; +import { readFile } from '@web/test-runner-commands'; + +import SlButton from '../../dist/components/button/button.component.js'; + +// We don't use ShoelaceElement directly because it shouldn't exist in the final bundle. +/* eslint-disable */ +const ShoelaceElement = Object.getPrototypeOf(SlButton); +/* eslint-enable */ + +// @ts-expect-error Isn't written in TS. +import { getAllComponents } from '../../scripts/shared.js'; + +import Sinon from 'sinon'; + +const getMetadata = () => readFile({ path: '../../dist/custom-elements.json' }) as unknown as Promise; + +let counter = 0; + +// These tests all run in the same tab so they pollute the global custom element registry. +// Some tests use this stub to be able to just test registration. +function stubCustomElements() { + const map = new Map(); + + Sinon.stub(window.customElements, 'get').callsFake(str => { + return map.get(str); + }); + + const stub = Sinon.stub(window.customElements, 'define'); + stub.callsFake((str, ctor) => { + if (map.get(str)) { + return; + } + + // Assign it a random string so it doesnt pollute globally. + const randomTagName = str + '-' + counter.toString(); + counter++; + stub.wrappedMethod.apply(window.customElements, [randomTagName, ctor]); + map.set(str, ctor); + }); +} + +beforeEach(() => { + Sinon.restore(); + stubCustomElements(); +}); + +it('Should provide a console warning if attempting to register the same tag twice', () => { + class MyButton extends SlButton { + static version = '0.4.5'; + } + + const stub = Sinon.stub(console, 'warn'); + + expect(Boolean(window.customElements.get('sl-button'))).to.be.false; + /* eslint-disable */ + SlButton.define('sl-button'); + expect(Boolean(window.customElements.get('sl-button'))).to.be.true; + MyButton.define('sl-button'); + /* eslint-enable */ + + expect(stub).calledOnce; + + const warning = stub.getCall(0).args.join(''); + + expect(warning).to.match( + new RegExp( + /* eslint-disable */ + `Attempted to register v${MyButton.version}, but v${SlButton.version} has already been registered` + /* eslint-enable */ + ), + 'i' + ); +}); + +it('Should not provide a console warning if versions match', () => { + class MyButton extends SlButton {} + + const stub = Sinon.stub(console, 'warn'); + + /* eslint-disable */ + expect(Boolean(window.customElements.get('sl-button'))).to.be.false; + SlButton.define('sl-button'); + expect(Boolean(window.customElements.get('sl-button'))).to.be.true; + MyButton.define('sl-button'); + /* eslint-enable */ + + expect(stub).not.called; +}); + +it('Should register dependencies when the element is constructed the first time', () => { + /* eslint-disable */ + class MyElement extends ShoelaceElement { + static dependencies = { 'sl-button': SlButton }; + static version = 'random-version'; + } + /* eslint-enable */ + + expect(Boolean(window.customElements.get('sl-button'))).to.be.false; + + // eslint-disable + MyElement.define('sl-element'); + // eslint-enable + + // this should be false until the constructor is called via new + expect(Boolean(window.customElements.get('sl-button'))).to.be.false; + + // We can call it directly since we know its registered. + /* eslint-disable */ + // @ts-expect-error If its undefined, error. + new (window.customElements.get('sl-element'))(); + /* eslint-enable */ + + expect(Boolean(window.customElements.get('sl-button'))).to.be.true; +}); + +// This looks funky here. This grabs all of our components and tests for side effects. +// We "abuse" mocha and dynamically define tests. +before(async () => { + const metadata = JSON.parse(await getMetadata()) as Record; + + const tagNames: string[] = []; + + const relevantMetadata: { tagName: string; path: string }[] = getAllComponents(metadata).map( + (component: { tagName: string; path: string }) => { + const { tagName, path } = component; + tagNames.push(tagName); + + return { tagName, path }; + } + ); + + relevantMetadata.forEach(({ tagName, path }) => { + it(`Should not register any components: ${tagName}`, async () => { + // Check if importing the files automatically registers any components + await import('../../dist/' + path); + + const registeredTags = tagNames.filter(tag => Boolean(window.customElements.get(tag))); + + const errorMessage = + `Expected ${path} to not register any tags, but it registered the following tags: ` + + registeredTags.map(tag => tag).join(', '); + expect(registeredTags.length).to.equal(0, errorMessage); + }); + }); +}); diff --git a/src/internal/shoelace-element.ts b/src/internal/shoelace-element.ts index 962dcef98d..4aecf3d49a 100644 --- a/src/internal/shoelace-element.ts +++ b/src/internal/shoelace-element.ts @@ -92,6 +92,52 @@ export default class ShoelaceElement extends LitElement { return event as GetCustomEventType; } + + /* eslint-disable */ + // @ts-expect-error This is auto-injected at build time. + static version = __SHOELACE_VERSION__; + /* eslint-enable */ + + static define(name: string, elementConstructor = this, options: ElementDefinitionOptions = {}) { + const currentlyRegisteredConstructor = customElements.get(name) as + | CustomElementConstructor + | typeof ShoelaceElement; + + if (!currentlyRegisteredConstructor) { + customElements.define(name, class extends elementConstructor {} as unknown as CustomElementConstructor, options); + return; + } + + let newVersion = ' (unknown version)'; + let existingVersion = newVersion; + + if ('version' in elementConstructor && elementConstructor.version) { + newVersion = ' v' + elementConstructor.version; + } + + if ('version' in currentlyRegisteredConstructor && currentlyRegisteredConstructor.version) { + existingVersion = ' v' + currentlyRegisteredConstructor.version; + } + + // Need to make sure we're not working with null or empty strings before doing version comparisons. + if (newVersion && existingVersion && newVersion === existingVersion) { + // If versions match, we don't need to warn anyone. Carry on. + return; + } + + console.warn( + `Attempted to register <${name}>${newVersion}, but <${name}>${existingVersion} has already been registered.` + ); + } + + static dependencies: Record = {}; + + constructor() { + super(); + Object.entries((this.constructor as typeof ShoelaceElement).dependencies).forEach(([name, component]) => { + (this.constructor as typeof ShoelaceElement).define(name, component); + }); + } } export interface ShoelaceFormControl extends ShoelaceElement { diff --git a/tsconfig.json b/tsconfig.json index 49de6876b3..ff48e53aee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "dom.Iterable", "es2020" ], + "emitDeclarationOnly": true, "declaration": true, "rootDir": ".", "strict": true, @@ -23,7 +24,7 @@ "esModuleInterop": true, "experimentalDecorators": true, "useDefineForClassFields": false, /* See https://lit.dev/docs/components/properties/#avoiding-issues-with-class-fields */ - "removeComments": true, + "removeComments": false, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "useUnknownInCatchVariables": true,