mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
Create non-auto-registering routes (#1450)
* initial attempt at not auto defining * add files with - * continued work on removing auto-define * fix component definitions * update with new tag stuff * fix lots of things * fix improper scoped elements * working through side effects * continued react wrapper work * update changelog * formatting * fixes * update changelog * lint / formatting * fix version injection * fix version injection, work on test * fix version injection, work on test * fix merge conflicts * fix jsdoc null issue * fix templates * use exports * working on tests * working on registration mocking * fix customElements test * linting * fix some test stuff * clean up test * clean up comment * rename scopedElements to dependencies * linting / formatting * linting / formatting * mark all packages external and still bundle * set bundle false * set bundle true * dont minify * fix merge conflicts * use built shoelace-element * fix lint errors * prettier * appease eslint * appease eslint gods * appease eslint gods * appease eslint gods * appease eslint gods * add shoelace-autoloader * move it all into 1 function * add exportmaps note * prettier * add jsdelivr entrypoint * read as utf8 * update docs with .component.js importS * prettier
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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%/<path>` and npm imports use `/%NPMDIR%/<path>`. The `/%CDNDIR%` files are bundled separately from the `/%NPMDIR%` files. The `/%CDNDIR%` files come pre-bundled, which means all dependencies are inlined so you do not need to worry about loading additional libraries. The `/%NPMDIR%` files **DO NOT** come pre-bundled, allowing your bundler of choice to more efficiently deduplicate dependencies, resulting in smaller bundles and optimal code sharing.
|
||||
|
||||
@@ -14,10 +14,17 @@ New versions of Shoelace are released as-needed and generally occur when a criti
|
||||
|
||||
## Next
|
||||
|
||||
- Added JSDoc comments to React Wrappers for better documentation when hovering a component. [#1450]
|
||||
- Added `displayName` to React Wrappers for better debugging. [#1450]
|
||||
- Added non-auto-registering routes for Components to fix a number of issues around auto-registration. [#1450]
|
||||
- Added a console warning if you attempt to register the same Shoelace component twice. [#1450]
|
||||
- Added tests for `<sl-qr-code>` [#1416]
|
||||
- Added support for pressing [[Space]] to select/toggle selected `<sl-menu-item>` elements [#1429]
|
||||
- Added support for virtual elements in `<sl-popup>` [#1449]
|
||||
- Added the `spinner` part to `<sl-button>` [#1460]
|
||||
- Added a `shoelace.js` and `shoelace-autoloader.js` to exportmaps. [#1450]
|
||||
- Fixed React component treeshaking by introducing `sideEffects` key in `package.json`. [#1450]
|
||||
- Fixed a bug in `<sl-tree>` where it was auto-defining `<sl-tree-item>`. [#1450]
|
||||
- Fixed a bug in focus trapping of modal elements like `<sl-dialog>`. We now manually handle focus ordering as well as added `offsetParent()` check for tabbable boundaries in Safari. Test cases added for `<sl-dialog>` inside a shadowRoot [#1403]
|
||||
- Fixed a bug in `valueAsDate` on `<sl-input>` where it would always set `type="date"` for the underlying `<input>` element. It now falls back to the native browser implementation for the in-memory input. This may cause unexpected behavior if you're using `valueAsDate` on any input elements that aren't `type="date"`. [#1399]
|
||||
- Fixed a bug in `<sl-qr-code>` where the `background` attribute was never passed to the QR code [#1416]
|
||||
@@ -27,6 +34,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti
|
||||
- Fixed a bug in `<sl-tree>` that caused focus to be stolen when removing focused tree items [#1430]
|
||||
- Fixed a bug in `<sl-dialog>` and `<sl-drawer>` that caused nested modals to respond too eagerly to the [[Esc]] key [#1457]
|
||||
- Updated ESLint and related plugins to the latest versions
|
||||
- Changed the default entrypoint for jsDelivr to point to the autoloader. [#1450]
|
||||
|
||||
## 2.5.2
|
||||
|
||||
|
||||
@@ -367,7 +367,6 @@ Then use the following syntax for comments so they appear in the generated docs.
|
||||
* @cssproperty --color: The component's text color.
|
||||
* @cssproperty --background-color: The component's background color.
|
||||
*/
|
||||
@customElement('sl-example')
|
||||
export default class SlExample {
|
||||
// ...
|
||||
}
|
||||
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -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",
|
||||
|
||||
13
package.json
13
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/**/*((?<!(\\.component|\\.styles)))\\.js"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/shoelace.d.ts",
|
||||
"import": "./dist/shoelace.js"
|
||||
},
|
||||
"./dist/custom-elements.json": "./dist/custom-elements.json",
|
||||
"./dist/shoelace.js": "./dist/shoelace.js",
|
||||
"./dist/shoelace-autoloader.js": "./dist/shoelace-autoloader.js",
|
||||
"./dist/themes/*": "./dist/themes/*",
|
||||
"./dist/components/*": "./dist/components/*",
|
||||
"./dist/utilities/*": "./dist/utilities/*",
|
||||
@@ -96,6 +108,7 @@
|
||||
"del": "^7.0.0",
|
||||
"download": "^8.0.0",
|
||||
"esbuild": "^0.18.2",
|
||||
"esbuild-plugin-replace": "^1.4.0",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-plugin-chai-expect": "^3.0.0",
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
|
||||
@@ -11,6 +11,8 @@ import getPort, { portNumbers } from 'get-port';
|
||||
import ora from 'ora';
|
||||
import util from 'util';
|
||||
import * as path from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { replace } from 'esbuild-plugin-replace';
|
||||
|
||||
const { serve } = commandLineArgs([{ name: 'serve', type: Boolean }]);
|
||||
const outdir = 'dist';
|
||||
@@ -22,6 +24,8 @@ let childProcess;
|
||||
let buildResults;
|
||||
|
||||
const bundleDirectories = [cdndir, outdir];
|
||||
let packageData = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
|
||||
const shoelaceVersion = JSON.stringify(packageData.version.toString());
|
||||
|
||||
//
|
||||
// Runs 11ty and builds the docs. The returned promise resolves after the initial publish has completed. The child
|
||||
@@ -108,13 +112,18 @@ async function buildTheSource() {
|
||||
//
|
||||
external: alwaysExternal,
|
||||
splitting: true,
|
||||
plugins: []
|
||||
plugins: [
|
||||
replace({
|
||||
__SHOELACE_VERSION__: shoelaceVersion
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
const npmConfig = {
|
||||
...cdnConfig,
|
||||
bundle: false,
|
||||
external: undefined,
|
||||
minify: false,
|
||||
packages: 'external',
|
||||
outdir
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ components.map(component => {
|
||||
|
||||
fs.mkdirSync(componentDir, { recursive: true });
|
||||
|
||||
const jsDoc = component.jsDoc || '';
|
||||
|
||||
const source = prettier.format(
|
||||
`
|
||||
import * as React from 'react';
|
||||
@@ -45,14 +47,32 @@ components.map(component => {
|
||||
${eventNameImport}
|
||||
${eventImports}
|
||||
|
||||
export default createComponent({
|
||||
tagName: '${component.tagName}',
|
||||
const tagName = '${component.tagName}'
|
||||
|
||||
const component = createComponent({
|
||||
tagName,
|
||||
elementClass: Component,
|
||||
react: React,
|
||||
events: {
|
||||
${events}
|
||||
},
|
||||
displayName: "${component.name}"
|
||||
})
|
||||
|
||||
${jsDoc}
|
||||
class SlComponent extends React.Component<Parameters<typeof component>[0]> {
|
||||
constructor (...args: Parameters<typeof component>) {
|
||||
super(...args)
|
||||
Component.define(tagName)
|
||||
}
|
||||
});
|
||||
|
||||
render () {
|
||||
const { children, ...props } = this.props
|
||||
return React.createElement(component, props, children)
|
||||
}
|
||||
}
|
||||
|
||||
export default SlComponent;
|
||||
`,
|
||||
Object.assign(prettierConfig, {
|
||||
parser: 'babel-ts'
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
4
scripts/plop/templates/component/define.hbs
Normal file
4
scripts/plop/templates/component/define.hbs
Normal file
@@ -0,0 +1,4 @@
|
||||
import {{ properCase tag }} from './{{ tagWithoutPrefix tag }}.component.js';
|
||||
export * from './{{ tagWithoutPrefix tag }}.component.js';
|
||||
export default {{ properCase tag }};
|
||||
{{ properCase tag }}.define('{{ tag }}');
|
||||
247
src/components/alert/alert.component.ts
Normal file
247
src/components/alert/alert.component.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
import styles from './alert.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
|
||||
|
||||
/**
|
||||
* @summary Alerts are used to display important messages inline or as toast notifications.
|
||||
* @documentation https://shoelace.style/components/alert
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The alert's main content.
|
||||
* @slot icon - An icon to show in the alert. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the alert opens.
|
||||
* @event sl-after-show - Emitted after the alert opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the alert closes.
|
||||
* @event sl-after-hide - Emitted after the alert closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the optional icon.
|
||||
* @csspart message - The container that wraps the alert's main content.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
*
|
||||
* @animation alert.show - The animation to use when showing the alert.
|
||||
* @animation alert.hide - The animation to use when hiding the alert.
|
||||
*/
|
||||
export default class SlAlert extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon-button': SlIconButton };
|
||||
|
||||
private autoHideTimeout: number;
|
||||
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('[part~="base"]') base: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the alert's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** Enables a close button that allows the user to dismiss the alert. */
|
||||
@property({ type: Boolean, reflect: true }) closable = false;
|
||||
|
||||
/** The alert's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/**
|
||||
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
|
||||
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning
|
||||
* the alert will not close on its own.
|
||||
*/
|
||||
@property({ type: Number }) duration = Infinity;
|
||||
|
||||
firstUpdated() {
|
||||
this.base.hidden = !this.open;
|
||||
}
|
||||
|
||||
private restartAutoHide() {
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
if (this.open && this.duration < Infinity) {
|
||||
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCloseClick() {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private handleMouseMove() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
|
||||
if (this.duration < Infinity) {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
await stopAnimations(this.base);
|
||||
this.base.hidden = false;
|
||||
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
|
||||
await stopAnimations(this.base);
|
||||
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
this.base.hidden = true;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('duration')
|
||||
handleDurationChange() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
/** Shows the alert. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the alert */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when
|
||||
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
|
||||
* calling this method again. The returned promise will resolve after the alert is hidden.
|
||||
*/
|
||||
async toast() {
|
||||
return new Promise<void>(resolve => {
|
||||
if (toastStack.parentElement === null) {
|
||||
document.body.append(toastStack);
|
||||
}
|
||||
|
||||
toastStack.appendChild(this);
|
||||
|
||||
// Wait for the toast stack to render
|
||||
requestAnimationFrame(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
|
||||
this.clientWidth;
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.addEventListener(
|
||||
'sl-after-hide',
|
||||
() => {
|
||||
toastStack.removeChild(this);
|
||||
resolve();
|
||||
|
||||
// Remove the toast stack from the DOM when there are no more alerts
|
||||
if (toastStack.querySelector('sl-alert') === null) {
|
||||
toastStack.remove();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
alert: true,
|
||||
'alert--open': this.open,
|
||||
'alert--closable': this.closable,
|
||||
'alert--has-icon': this.hasSlotController.test('icon'),
|
||||
'alert--primary': this.variant === 'primary',
|
||||
'alert--success': this.variant === 'success',
|
||||
'alert--neutral': this.variant === 'neutral',
|
||||
'alert--warning': this.variant === 'warning',
|
||||
'alert--danger': this.variant === 'danger'
|
||||
})}
|
||||
role="alert"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
@mousemove=${this.handleMouseMove}
|
||||
>
|
||||
<div part="icon" class="alert__icon">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
|
||||
<div part="message" class="alert__message" aria-live="polite">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${this.closable
|
||||
? html`
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="alert__close-button"
|
||||
name="x-lg"
|
||||
library="system"
|
||||
label=${this.localize.term('close')}
|
||||
@click=${this.handleCloseClick}
|
||||
></sl-icon-button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('alert.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('alert.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-alert': SlAlert;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +1,4 @@
|
||||
import '../icon-button/icon-button.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './alert.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
|
||||
|
||||
/**
|
||||
* @summary Alerts are used to display important messages inline or as toast notifications.
|
||||
* @documentation https://shoelace.style/components/alert
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The alert's main content.
|
||||
* @slot icon - An icon to show in the alert. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the alert opens.
|
||||
* @event sl-after-show - Emitted after the alert opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the alert closes.
|
||||
* @event sl-after-hide - Emitted after the alert closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the optional icon.
|
||||
* @csspart message - The container that wraps the alert's main content.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
*
|
||||
* @animation alert.show - The animation to use when showing the alert.
|
||||
* @animation alert.hide - The animation to use when hiding the alert.
|
||||
*/
|
||||
|
||||
@customElement('sl-alert')
|
||||
export default class SlAlert extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private autoHideTimeout: number;
|
||||
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('[part~="base"]') base: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the alert's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** Enables a close button that allows the user to dismiss the alert. */
|
||||
@property({ type: Boolean, reflect: true }) closable = false;
|
||||
|
||||
/** The alert's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/**
|
||||
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
|
||||
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning
|
||||
* the alert will not close on its own.
|
||||
*/
|
||||
@property({ type: Number }) duration = Infinity;
|
||||
|
||||
firstUpdated() {
|
||||
this.base.hidden = !this.open;
|
||||
}
|
||||
|
||||
private restartAutoHide() {
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
if (this.open && this.duration < Infinity) {
|
||||
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCloseClick() {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private handleMouseMove() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
|
||||
if (this.duration < Infinity) {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
await stopAnimations(this.base);
|
||||
this.base.hidden = false;
|
||||
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
|
||||
await stopAnimations(this.base);
|
||||
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
this.base.hidden = true;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('duration')
|
||||
handleDurationChange() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
/** Shows the alert. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the alert */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when
|
||||
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
|
||||
* calling this method again. The returned promise will resolve after the alert is hidden.
|
||||
*/
|
||||
async toast() {
|
||||
return new Promise<void>(resolve => {
|
||||
if (toastStack.parentElement === null) {
|
||||
document.body.append(toastStack);
|
||||
}
|
||||
|
||||
toastStack.appendChild(this);
|
||||
|
||||
// Wait for the toast stack to render
|
||||
requestAnimationFrame(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
|
||||
this.clientWidth;
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.addEventListener(
|
||||
'sl-after-hide',
|
||||
() => {
|
||||
toastStack.removeChild(this);
|
||||
resolve();
|
||||
|
||||
// Remove the toast stack from the DOM when there are no more alerts
|
||||
if (toastStack.querySelector('sl-alert') === null) {
|
||||
toastStack.remove();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
alert: true,
|
||||
'alert--open': this.open,
|
||||
'alert--closable': this.closable,
|
||||
'alert--has-icon': this.hasSlotController.test('icon'),
|
||||
'alert--primary': this.variant === 'primary',
|
||||
'alert--success': this.variant === 'success',
|
||||
'alert--neutral': this.variant === 'neutral',
|
||||
'alert--warning': this.variant === 'warning',
|
||||
'alert--danger': this.variant === 'danger'
|
||||
})}
|
||||
role="alert"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
@mousemove=${this.handleMouseMove}
|
||||
>
|
||||
<div part="icon" class="alert__icon">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
|
||||
<div part="message" class="alert__message" aria-live="polite">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${this.closable
|
||||
? html`
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="alert__close-button"
|
||||
name="x-lg"
|
||||
library="system"
|
||||
label=${this.localize.term('close')}
|
||||
@click=${this.handleCloseClick}
|
||||
></sl-icon-button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('alert.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('alert.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-alert': SlAlert;
|
||||
}
|
||||
}
|
||||
import SlAlert from './alert.component.js';
|
||||
export * from './alert.component.js';
|
||||
export default SlAlert;
|
||||
SlAlert.define('sl-alert');
|
||||
|
||||
122
src/components/animated-image/animated-image.component.ts
Normal file
122
src/components/animated-image/animated-image.component.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './animated-image.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction.
|
||||
* @documentation https://shoelace.style/components/animated-image
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-load - Emitted when the image loads successfully.
|
||||
* @event sl-error - Emitted when the image fails to load.
|
||||
*
|
||||
* @slot play-icon - Optional play icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot pause-icon - Optional pause icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @part - control-box - The container that surrounds the pause/play icons and provides their background.
|
||||
*
|
||||
* @cssproperty --control-box-size - The size of the icon box.
|
||||
* @cssproperty --icon-size - The size of the play/pause icons.
|
||||
*/
|
||||
export default class SlAnimatedImage extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
@query('.animated-image__animated') animatedImage: HTMLImageElement;
|
||||
|
||||
@state() frozenFrame: string;
|
||||
@state() isLoaded = false;
|
||||
|
||||
/** The path to the image to load. */
|
||||
@property() src: string;
|
||||
|
||||
/** A description of the image used by assistive devices. */
|
||||
@property() alt: string;
|
||||
|
||||
/** Plays the animation. When this attribute is remove, the animation will pause. */
|
||||
@property({ type: Boolean, reflect: true }) play: boolean;
|
||||
|
||||
private handleClick() {
|
||||
this.play = !this.play;
|
||||
}
|
||||
|
||||
private handleLoad() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const { width, height } = this.animatedImage;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d')!.drawImage(this.animatedImage, 0, 0, width, height);
|
||||
this.frozenFrame = canvas.toDataURL('image/gif');
|
||||
|
||||
if (!this.isLoaded) {
|
||||
this.emit('sl-load');
|
||||
this.isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError() {
|
||||
this.emit('sl-error');
|
||||
}
|
||||
|
||||
@watch('play', { waitUntilFirstUpdate: true })
|
||||
handlePlayChange() {
|
||||
// When the animation starts playing, reset the src so it plays from the beginning. Since the src is cached, this
|
||||
// won't trigger another request.
|
||||
if (this.play) {
|
||||
this.animatedImage.src = '';
|
||||
this.animatedImage.src = this.src;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
handleSrcChange() {
|
||||
this.isLoaded = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="animated-image">
|
||||
<img
|
||||
class="animated-image__animated"
|
||||
src=${this.src}
|
||||
alt=${this.alt}
|
||||
crossorigin="anonymous"
|
||||
aria-hidden=${this.play ? 'false' : 'true'}
|
||||
@click=${this.handleClick}
|
||||
@load=${this.handleLoad}
|
||||
@error=${this.handleError}
|
||||
/>
|
||||
|
||||
${this.isLoaded
|
||||
? html`
|
||||
<img
|
||||
class="animated-image__frozen"
|
||||
src=${this.frozenFrame}
|
||||
alt=${this.alt}
|
||||
aria-hidden=${this.play ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
/>
|
||||
|
||||
<div part="control-box" class="animated-image__control-box">
|
||||
<slot name="play-icon"><sl-icon name="play-fill" library="system"></sl-icon></slot>
|
||||
<slot name="pause-icon"><sl-icon name="pause-fill" library="system"></sl-icon></slot>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animated-image': SlAnimatedImage;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './animated-image.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction.
|
||||
* @documentation https://shoelace.style/components/animated-image
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-load - Emitted when the image loads successfully.
|
||||
* @event sl-error - Emitted when the image fails to load.
|
||||
*
|
||||
* @slot play-icon - Optional play icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot pause-icon - Optional pause icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @part - control-box - The container that surrounds the pause/play icons and provides their background.
|
||||
*
|
||||
* @cssproperty --control-box-size - The size of the icon box.
|
||||
* @cssproperty --icon-size - The size of the play/pause icons.
|
||||
*/
|
||||
@customElement('sl-animated-image')
|
||||
export default class SlAnimatedImage extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('.animated-image__animated') animatedImage: HTMLImageElement;
|
||||
|
||||
@state() frozenFrame: string;
|
||||
@state() isLoaded = false;
|
||||
|
||||
/** The path to the image to load. */
|
||||
@property() src: string;
|
||||
|
||||
/** A description of the image used by assistive devices. */
|
||||
@property() alt: string;
|
||||
|
||||
/** Plays the animation. When this attribute is remove, the animation will pause. */
|
||||
@property({ type: Boolean, reflect: true }) play: boolean;
|
||||
|
||||
private handleClick() {
|
||||
this.play = !this.play;
|
||||
}
|
||||
|
||||
private handleLoad() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const { width, height } = this.animatedImage;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d')!.drawImage(this.animatedImage, 0, 0, width, height);
|
||||
this.frozenFrame = canvas.toDataURL('image/gif');
|
||||
|
||||
if (!this.isLoaded) {
|
||||
this.emit('sl-load');
|
||||
this.isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError() {
|
||||
this.emit('sl-error');
|
||||
}
|
||||
|
||||
@watch('play', { waitUntilFirstUpdate: true })
|
||||
handlePlayChange() {
|
||||
// When the animation starts playing, reset the src so it plays from the beginning. Since the src is cached, this
|
||||
// won't trigger another request.
|
||||
if (this.play) {
|
||||
this.animatedImage.src = '';
|
||||
this.animatedImage.src = this.src;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
handleSrcChange() {
|
||||
this.isLoaded = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="animated-image">
|
||||
<img
|
||||
class="animated-image__animated"
|
||||
src=${this.src}
|
||||
alt=${this.alt}
|
||||
crossorigin="anonymous"
|
||||
aria-hidden=${this.play ? 'false' : 'true'}
|
||||
@click=${this.handleClick}
|
||||
@load=${this.handleLoad}
|
||||
@error=${this.handleError}
|
||||
/>
|
||||
|
||||
${this.isLoaded
|
||||
? html`
|
||||
<img
|
||||
class="animated-image__frozen"
|
||||
src=${this.frozenFrame}
|
||||
alt=${this.alt}
|
||||
aria-hidden=${this.play ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
/>
|
||||
|
||||
<div part="control-box" class="animated-image__control-box">
|
||||
<slot name="play-icon"><sl-icon name="play-fill" library="system"></sl-icon></slot>
|
||||
<slot name="pause-icon"><sl-icon name="pause-fill" library="system"></sl-icon></slot>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animated-image': SlAnimatedImage;
|
||||
}
|
||||
}
|
||||
import SlAnimatedImage from './animated-image.component.js';
|
||||
export * from './animated-image.component.js';
|
||||
export default SlAnimatedImage;
|
||||
SlAnimatedImage.define('sl-animated-image');
|
||||
|
||||
226
src/components/animation/animation.component.ts
Normal file
226
src/components/animation/animation.component.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { animations } from './animations.js';
|
||||
import { html } from 'lit';
|
||||
import { property, queryAsync } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './animation.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Animate elements declaratively with nearly 100 baked-in presets, or roll your own with custom keyframes. Powered by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API).
|
||||
* @documentation https://shoelace.style/components/animation
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-cancel - Emitted when the animation is canceled.
|
||||
* @event sl-finish - Emitted when the animation finishes.
|
||||
* @event sl-start - Emitted when the animation starts or restarts.
|
||||
*
|
||||
* @slot - The element to animate. Avoid slotting in more than one element, as subsequent ones will be ignored. To
|
||||
* animate multiple elements, either wrap them in a single container or use multiple `<sl-animation>` elements.
|
||||
*/
|
||||
export default class SlAnimation extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private animation?: Animation;
|
||||
private hasStarted = false;
|
||||
|
||||
@queryAsync('slot') defaultSlot: Promise<HTMLSlotElement>;
|
||||
|
||||
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
|
||||
@property() name = 'none';
|
||||
|
||||
/**
|
||||
* Plays the animation. When omitted, the animation will be paused. This attribute will be automatically removed when
|
||||
* the animation finishes or gets canceled.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) play = false;
|
||||
|
||||
/** The number of milliseconds to delay the start of the animation. */
|
||||
@property({ type: Number }) delay = 0;
|
||||
|
||||
/**
|
||||
* Determines the direction of playback as well as the behavior when reaching the end of an iteration.
|
||||
* [Learn more](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-direction)
|
||||
*/
|
||||
@property() direction: PlaybackDirection = 'normal';
|
||||
|
||||
/** The number of milliseconds each iteration of the animation takes to complete. */
|
||||
@property({ type: Number }) duration = 1000;
|
||||
|
||||
/**
|
||||
* The easing function to use for the animation. This can be a Shoelace easing function or a custom easing function
|
||||
* such as `cubic-bezier(0, 1, .76, 1.14)`.
|
||||
*/
|
||||
@property() easing = 'linear';
|
||||
|
||||
/** The number of milliseconds to delay after the active period of an animation sequence. */
|
||||
@property({ attribute: 'end-delay', type: Number }) endDelay = 0;
|
||||
|
||||
/** Sets how the animation applies styles to its target before and after its execution. */
|
||||
@property() fill: FillMode = 'auto';
|
||||
|
||||
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
|
||||
@property({ type: Number }) iterations = Infinity;
|
||||
|
||||
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
|
||||
@property({ attribute: 'iteration-start', type: Number }) iterationStart = 0;
|
||||
|
||||
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
|
||||
@property({ attribute: false }) keyframes?: Keyframe[];
|
||||
|
||||
/**
|
||||
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
|
||||
* to `2`, for example, will double the animation's speed. A negative value can be used to reverse the animation. This
|
||||
* value can be changed without causing the animation to restart.
|
||||
*/
|
||||
@property({ attribute: 'playback-rate', type: Number }) playbackRate = 1;
|
||||
|
||||
/** Gets and sets the current animation time. */
|
||||
get currentTime(): CSSNumberish {
|
||||
return this.animation?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
set currentTime(time: number) {
|
||||
if (this.animation) {
|
||||
this.animation.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.destroyAnimation();
|
||||
}
|
||||
|
||||
private handleAnimationFinish = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-finish');
|
||||
};
|
||||
|
||||
private handleAnimationCancel = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-cancel');
|
||||
};
|
||||
|
||||
private handleSlotChange() {
|
||||
this.destroyAnimation();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
private async createAnimation() {
|
||||
const easing = animations.easings[this.easing] ?? this.easing;
|
||||
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
|
||||
const slot = await this.defaultSlot;
|
||||
const element = slot.assignedElements()[0] as HTMLElement | undefined;
|
||||
|
||||
if (!element || !keyframes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.destroyAnimation();
|
||||
this.animation = element.animate(keyframes, {
|
||||
delay: this.delay,
|
||||
direction: this.direction,
|
||||
duration: this.duration,
|
||||
easing,
|
||||
endDelay: this.endDelay,
|
||||
fill: this.fill,
|
||||
iterationStart: this.iterationStart,
|
||||
iterations: this.iterations
|
||||
});
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
this.animation.addEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.addEventListener('finish', this.handleAnimationFinish);
|
||||
|
||||
if (this.play) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private destroyAnimation() {
|
||||
if (this.animation) {
|
||||
this.animation.cancel();
|
||||
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.removeEventListener('finish', this.handleAnimationFinish);
|
||||
this.hasStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@watch([
|
||||
'name',
|
||||
'delay',
|
||||
'direction',
|
||||
'duration',
|
||||
'easing',
|
||||
'endDelay',
|
||||
'fill',
|
||||
'iterations',
|
||||
'iterationsStart',
|
||||
'keyframes'
|
||||
])
|
||||
handleAnimationChange() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
@watch('play')
|
||||
handlePlayChange() {
|
||||
if (this.animation) {
|
||||
if (this.play && !this.hasStarted) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
}
|
||||
|
||||
if (this.play) {
|
||||
this.animation.play();
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@watch('playbackRate')
|
||||
handlePlaybackRateChange() {
|
||||
if (this.animation) {
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears all keyframe effects caused by this animation and aborts its playback. */
|
||||
cancel() {
|
||||
this.animation?.cancel();
|
||||
}
|
||||
|
||||
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
|
||||
finish() {
|
||||
this.animation?.finish();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animation': SlAnimation;
|
||||
}
|
||||
}
|
||||
@@ -1,227 +1,4 @@
|
||||
import { animations } from './animations.js';
|
||||
import { customElement, property, queryAsync } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './animation.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Animate elements declaratively with nearly 100 baked-in presets, or roll your own with custom keyframes. Powered by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API).
|
||||
* @documentation https://shoelace.style/components/animation
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-cancel - Emitted when the animation is canceled.
|
||||
* @event sl-finish - Emitted when the animation finishes.
|
||||
* @event sl-start - Emitted when the animation starts or restarts.
|
||||
*
|
||||
* @slot - The element to animate. Avoid slotting in more than one element, as subsequent ones will be ignored. To
|
||||
* animate multiple elements, either wrap them in a single container or use multiple `<sl-animation>` elements.
|
||||
*/
|
||||
@customElement('sl-animation')
|
||||
export default class SlAnimation extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private animation?: Animation;
|
||||
private hasStarted = false;
|
||||
|
||||
@queryAsync('slot') defaultSlot: Promise<HTMLSlotElement>;
|
||||
|
||||
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
|
||||
@property() name = 'none';
|
||||
|
||||
/**
|
||||
* Plays the animation. When omitted, the animation will be paused. This attribute will be automatically removed when
|
||||
* the animation finishes or gets canceled.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) play = false;
|
||||
|
||||
/** The number of milliseconds to delay the start of the animation. */
|
||||
@property({ type: Number }) delay = 0;
|
||||
|
||||
/**
|
||||
* Determines the direction of playback as well as the behavior when reaching the end of an iteration.
|
||||
* [Learn more](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-direction)
|
||||
*/
|
||||
@property() direction: PlaybackDirection = 'normal';
|
||||
|
||||
/** The number of milliseconds each iteration of the animation takes to complete. */
|
||||
@property({ type: Number }) duration = 1000;
|
||||
|
||||
/**
|
||||
* The easing function to use for the animation. This can be a Shoelace easing function or a custom easing function
|
||||
* such as `cubic-bezier(0, 1, .76, 1.14)`.
|
||||
*/
|
||||
@property() easing = 'linear';
|
||||
|
||||
/** The number of milliseconds to delay after the active period of an animation sequence. */
|
||||
@property({ attribute: 'end-delay', type: Number }) endDelay = 0;
|
||||
|
||||
/** Sets how the animation applies styles to its target before and after its execution. */
|
||||
@property() fill: FillMode = 'auto';
|
||||
|
||||
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
|
||||
@property({ type: Number }) iterations = Infinity;
|
||||
|
||||
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
|
||||
@property({ attribute: 'iteration-start', type: Number }) iterationStart = 0;
|
||||
|
||||
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
|
||||
@property({ attribute: false }) keyframes?: Keyframe[];
|
||||
|
||||
/**
|
||||
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
|
||||
* to `2`, for example, will double the animation's speed. A negative value can be used to reverse the animation. This
|
||||
* value can be changed without causing the animation to restart.
|
||||
*/
|
||||
@property({ attribute: 'playback-rate', type: Number }) playbackRate = 1;
|
||||
|
||||
/** Gets and sets the current animation time. */
|
||||
get currentTime(): CSSNumberish {
|
||||
return this.animation?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
set currentTime(time: number) {
|
||||
if (this.animation) {
|
||||
this.animation.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.destroyAnimation();
|
||||
}
|
||||
|
||||
private handleAnimationFinish = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-finish');
|
||||
};
|
||||
|
||||
private handleAnimationCancel = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-cancel');
|
||||
};
|
||||
|
||||
private handleSlotChange() {
|
||||
this.destroyAnimation();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
private async createAnimation() {
|
||||
const easing = animations.easings[this.easing] ?? this.easing;
|
||||
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
|
||||
const slot = await this.defaultSlot;
|
||||
const element = slot.assignedElements()[0] as HTMLElement | undefined;
|
||||
|
||||
if (!element || !keyframes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.destroyAnimation();
|
||||
this.animation = element.animate(keyframes, {
|
||||
delay: this.delay,
|
||||
direction: this.direction,
|
||||
duration: this.duration,
|
||||
easing,
|
||||
endDelay: this.endDelay,
|
||||
fill: this.fill,
|
||||
iterationStart: this.iterationStart,
|
||||
iterations: this.iterations
|
||||
});
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
this.animation.addEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.addEventListener('finish', this.handleAnimationFinish);
|
||||
|
||||
if (this.play) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private destroyAnimation() {
|
||||
if (this.animation) {
|
||||
this.animation.cancel();
|
||||
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.removeEventListener('finish', this.handleAnimationFinish);
|
||||
this.hasStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@watch([
|
||||
'name',
|
||||
'delay',
|
||||
'direction',
|
||||
'duration',
|
||||
'easing',
|
||||
'endDelay',
|
||||
'fill',
|
||||
'iterations',
|
||||
'iterationsStart',
|
||||
'keyframes'
|
||||
])
|
||||
handleAnimationChange() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
@watch('play')
|
||||
handlePlayChange() {
|
||||
if (this.animation) {
|
||||
if (this.play && !this.hasStarted) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
}
|
||||
|
||||
if (this.play) {
|
||||
this.animation.play();
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@watch('playbackRate')
|
||||
handlePlaybackRateChange() {
|
||||
if (this.animation) {
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears all keyframe effects caused by this animation and aborts its playback. */
|
||||
cancel() {
|
||||
this.animation?.cancel();
|
||||
}
|
||||
|
||||
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
|
||||
finish() {
|
||||
this.animation?.finish();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animation': SlAnimation;
|
||||
}
|
||||
}
|
||||
import SlAnimation from './animation.component.js';
|
||||
export * from './animation.component.js';
|
||||
export default SlAnimation;
|
||||
SlAnimation.define('sl-animation');
|
||||
|
||||
104
src/components/avatar/avatar.component.ts
Normal file
104
src/components/avatar/avatar.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './avatar.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Avatars are used to represent a person or object.
|
||||
* @documentation https://shoelace.style/components/avatar
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot icon - The default icon to use when no image or initials are present. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the avatar's icon.
|
||||
* @csspart initials - The container that wraps the avatar's initials.
|
||||
* @csspart image - The avatar image. Only shown when the `image` attribute is set.
|
||||
*
|
||||
* @cssproperty --size - The size of the avatar.
|
||||
*/
|
||||
export default class SlAvatar extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon
|
||||
};
|
||||
|
||||
@state() private hasError = false;
|
||||
|
||||
/** The image source to use for the avatar. */
|
||||
@property() image = '';
|
||||
|
||||
/** A label to use to describe the avatar to assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
|
||||
@property() initials = '';
|
||||
|
||||
/** Indicates how the browser should load the image. */
|
||||
@property() loading: 'eager' | 'lazy' = 'eager';
|
||||
|
||||
/** The shape of the avatar. */
|
||||
@property({ reflect: true }) shape: 'circle' | 'square' | 'rounded' = 'circle';
|
||||
|
||||
@watch('image')
|
||||
handleImageChange() {
|
||||
// Reset the error when a new image is provided
|
||||
this.hasError = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const avatarWithImage = html`
|
||||
<img
|
||||
part="image"
|
||||
class="avatar__image"
|
||||
src="${this.image}"
|
||||
loading="${this.loading}"
|
||||
alt=""
|
||||
@error="${() => (this.hasError = true)}"
|
||||
/>
|
||||
`;
|
||||
|
||||
let avatarWithoutImage = html``;
|
||||
|
||||
if (this.initials) {
|
||||
avatarWithoutImage = html`<div part="initials" class="avatar__initials">${this.initials}</div>`;
|
||||
} else {
|
||||
avatarWithoutImage = html`
|
||||
<div part="icon" class="avatar__icon" aria-hidden="true">
|
||||
<slot name="icon">
|
||||
<sl-icon name="person-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
avatar: true,
|
||||
'avatar--circle': this.shape === 'circle',
|
||||
'avatar--rounded': this.shape === 'rounded',
|
||||
'avatar--square': this.shape === 'square'
|
||||
})}
|
||||
role="img"
|
||||
aria-label=${this.label}
|
||||
>
|
||||
${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-avatar': SlAvatar;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './avatar.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Avatars are used to represent a person or object.
|
||||
* @documentation https://shoelace.style/components/avatar
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot icon - The default icon to use when no image or initials are present. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the avatar's icon.
|
||||
* @csspart initials - The container that wraps the avatar's initials.
|
||||
* @csspart image - The avatar image. Only shown when the `image` attribute is set.
|
||||
*
|
||||
* @cssproperty --size - The size of the avatar.
|
||||
*/
|
||||
@customElement('sl-avatar')
|
||||
export default class SlAvatar extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@state() private hasError = false;
|
||||
|
||||
/** The image source to use for the avatar. */
|
||||
@property() image = '';
|
||||
|
||||
/** A label to use to describe the avatar to assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
|
||||
@property() initials = '';
|
||||
|
||||
/** Indicates how the browser should load the image. */
|
||||
@property() loading: 'eager' | 'lazy' = 'eager';
|
||||
|
||||
/** The shape of the avatar. */
|
||||
@property({ reflect: true }) shape: 'circle' | 'square' | 'rounded' = 'circle';
|
||||
|
||||
@watch('image')
|
||||
handleImageChange() {
|
||||
// Reset the error when a new image is provided
|
||||
this.hasError = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const avatarWithImage = html`
|
||||
<img
|
||||
part="image"
|
||||
class="avatar__image"
|
||||
src="${this.image}"
|
||||
loading="${this.loading}"
|
||||
alt=""
|
||||
@error="${() => (this.hasError = true)}"
|
||||
/>
|
||||
`;
|
||||
|
||||
let avatarWithoutImage = html``;
|
||||
|
||||
if (this.initials) {
|
||||
avatarWithoutImage = html`<div part="initials" class="avatar__initials">${this.initials}</div>`;
|
||||
} else {
|
||||
avatarWithoutImage = html`
|
||||
<div part="icon" class="avatar__icon" aria-hidden="true">
|
||||
<slot name="icon">
|
||||
<sl-icon name="person-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
avatar: true,
|
||||
'avatar--circle': this.shape === 'circle',
|
||||
'avatar--rounded': this.shape === 'rounded',
|
||||
'avatar--square': this.shape === 'square'
|
||||
})}
|
||||
role="img"
|
||||
aria-label=${this.label}
|
||||
>
|
||||
${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-avatar': SlAvatar;
|
||||
}
|
||||
}
|
||||
import SlAvatar from './avatar.component.js';
|
||||
export * from './avatar.component.js';
|
||||
export default SlAvatar;
|
||||
SlAvatar.define('sl-avatar');
|
||||
|
||||
56
src/components/badge/badge.component.ts
Normal file
56
src/components/badge/badge.component.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './badge.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Badges are used to draw attention and display statuses or counts.
|
||||
* @documentation https://shoelace.style/components/badge
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The badge's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlBadge extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** The badge's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/** Draws a pill-style badge with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** Makes the badge pulsate to draw attention. */
|
||||
@property({ type: Boolean, reflect: true }) pulse = false;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<span
|
||||
part="base"
|
||||
class=${classMap({
|
||||
badge: true,
|
||||
'badge--primary': this.variant === 'primary',
|
||||
'badge--success': this.variant === 'success',
|
||||
'badge--neutral': this.variant === 'neutral',
|
||||
'badge--warning': this.variant === 'warning',
|
||||
'badge--danger': this.variant === 'danger',
|
||||
'badge--pill': this.pill,
|
||||
'badge--pulse': this.pulse
|
||||
})}
|
||||
role="status"
|
||||
>
|
||||
<slot></slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-badge': SlBadge;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './badge.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Badges are used to draw attention and display statuses or counts.
|
||||
* @documentation https://shoelace.style/components/badge
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The badge's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-badge')
|
||||
export default class SlBadge extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** The badge's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/** Draws a pill-style badge with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** Makes the badge pulsate to draw attention. */
|
||||
@property({ type: Boolean, reflect: true }) pulse = false;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<span
|
||||
part="base"
|
||||
class=${classMap({
|
||||
badge: true,
|
||||
'badge--primary': this.variant === 'primary',
|
||||
'badge--success': this.variant === 'success',
|
||||
'badge--neutral': this.variant === 'neutral',
|
||||
'badge--warning': this.variant === 'warning',
|
||||
'badge--danger': this.variant === 'danger',
|
||||
'badge--pill': this.pill,
|
||||
'badge--pulse': this.pulse
|
||||
})}
|
||||
role="status"
|
||||
>
|
||||
<slot></slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-badge': SlBadge;
|
||||
}
|
||||
}
|
||||
import SlBadge from './badge.component.js';
|
||||
export * from './badge.component.js';
|
||||
export default SlBadge;
|
||||
SlBadge.define('sl-badge');
|
||||
|
||||
95
src/components/breadcrumb-item/breadcrumb-item.component.ts
Normal file
95
src/components/breadcrumb-item/breadcrumb-item.component.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './breadcrumb-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumb Items are used inside [breadcrumbs](/components/breadcrumb) to represent different links.
|
||||
* @documentation https://shoelace.style/components/breadcrumb-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The breadcrumb item's label.
|
||||
* @slot prefix - An optional prefix, usually an icon or icon button.
|
||||
* @slot suffix - An optional suffix, usually an icon or icon button.
|
||||
* @slot separator - The separator to use for the breadcrumb item. This will only change the separator for this item. If
|
||||
* you want to change it for all items in the group, set the separator on `<sl-breadcrumb>` instead.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The breadcrumb item's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart separator - The container that wraps the separator.
|
||||
*/
|
||||
export default class SlBreadcrumbItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
|
||||
|
||||
/**
|
||||
* Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered
|
||||
* internally. When unset, a button will be rendered instead.
|
||||
*/
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** The `rel` attribute to use on the link. Only used when `href` is set. */
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'breadcrumb-item': true,
|
||||
'breadcrumb-item--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'breadcrumb-item--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
>
|
||||
<span part="prefix" class="breadcrumb-item__prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</span>
|
||||
|
||||
${isLink
|
||||
? html`
|
||||
<a
|
||||
part="label"
|
||||
class="breadcrumb-item__label breadcrumb-item__label--link"
|
||||
href="${this.href!}"
|
||||
target="${ifDefined(this.target ? this.target : undefined)}"
|
||||
rel=${ifDefined(this.target ? this.rel : undefined)}
|
||||
>
|
||||
<slot></slot>
|
||||
</a>
|
||||
`
|
||||
: html`
|
||||
<button part="label" type="button" class="breadcrumb-item__label breadcrumb-item__label--button">
|
||||
<slot></slot>
|
||||
</button>
|
||||
`}
|
||||
|
||||
<span part="suffix" class="breadcrumb-item__suffix">
|
||||
<slot name="suffix"></slot>
|
||||
</span>
|
||||
|
||||
<span part="separator" class="breadcrumb-item__separator" aria-hidden="true">
|
||||
<slot name="separator"></slot>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb-item': SlBreadcrumbItem;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './breadcrumb-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumb Items are used inside [breadcrumbs](/components/breadcrumb) to represent different links.
|
||||
* @documentation https://shoelace.style/components/breadcrumb-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The breadcrumb item's label.
|
||||
* @slot prefix - An optional prefix, usually an icon or icon button.
|
||||
* @slot suffix - An optional suffix, usually an icon or icon button.
|
||||
* @slot separator - The separator to use for the breadcrumb item. This will only change the separator for this item. If
|
||||
* you want to change it for all items in the group, set the separator on `<sl-breadcrumb>` instead.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The breadcrumb item's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart separator - The container that wraps the separator.
|
||||
*/
|
||||
@customElement('sl-breadcrumb-item')
|
||||
export default class SlBreadcrumbItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
|
||||
|
||||
/**
|
||||
* Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered
|
||||
* internally. When unset, a button will be rendered instead.
|
||||
*/
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** The `rel` attribute to use on the link. Only used when `href` is set. */
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'breadcrumb-item': true,
|
||||
'breadcrumb-item--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'breadcrumb-item--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
>
|
||||
<span part="prefix" class="breadcrumb-item__prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</span>
|
||||
|
||||
${isLink
|
||||
? html`
|
||||
<a
|
||||
part="label"
|
||||
class="breadcrumb-item__label breadcrumb-item__label--link"
|
||||
href="${this.href!}"
|
||||
target="${ifDefined(this.target ? this.target : undefined)}"
|
||||
rel=${ifDefined(this.target ? this.rel : undefined)}
|
||||
>
|
||||
<slot></slot>
|
||||
</a>
|
||||
`
|
||||
: html`
|
||||
<button part="label" type="button" class="breadcrumb-item__label breadcrumb-item__label--button">
|
||||
<slot></slot>
|
||||
</button>
|
||||
`}
|
||||
|
||||
<span part="suffix" class="breadcrumb-item__suffix">
|
||||
<slot name="suffix"></slot>
|
||||
</span>
|
||||
|
||||
<span part="separator" class="breadcrumb-item__separator" aria-hidden="true">
|
||||
<slot name="separator"></slot>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb-item': SlBreadcrumbItem;
|
||||
}
|
||||
}
|
||||
import SlBreadcrumbItem from './breadcrumb-item.component.js';
|
||||
export * from './breadcrumb-item.component.js';
|
||||
export default SlBreadcrumbItem;
|
||||
SlBreadcrumbItem.define('sl-breadcrumb-item');
|
||||
|
||||
106
src/components/breadcrumb/breadcrumb.component.ts
Normal file
106
src/components/breadcrumb/breadcrumb.component.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './breadcrumb.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item.js';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumbs provide a group of links so users can easily navigate a website's hierarchy.
|
||||
* @documentation https://shoelace.style/components/breadcrumb
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more breadcrumb items to display.
|
||||
* @slot separator - The separator to use between breadcrumb items. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlBreadcrumb extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private separatorDir = this.localize.dir();
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
|
||||
|
||||
/**
|
||||
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
|
||||
* screen readers and other assistive devices to provide more context for users.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
// Generates a clone of the separator element to use for each breadcrumb item
|
||||
private getSeparator() {
|
||||
const separator = this.separatorSlot.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||
|
||||
// Clone it, remove ids, and slot it
|
||||
const clone = separator.cloneNode(true) as HTMLElement;
|
||||
[clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id'));
|
||||
clone.setAttribute('data-default', '');
|
||||
clone.slot = 'separator';
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
|
||||
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
|
||||
) as SlBreadcrumbItem[];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// Append separators to each item if they don't already have one
|
||||
const separator = item.querySelector('[slot="separator"]');
|
||||
if (separator === null) {
|
||||
// No separator exists, add one
|
||||
item.append(this.getSeparator());
|
||||
} else if (separator.hasAttribute('data-default')) {
|
||||
// A default separator exists, replace it
|
||||
separator.replaceWith(this.getSeparator());
|
||||
} else {
|
||||
// The user provided a custom separator, leave it alone
|
||||
}
|
||||
|
||||
// The last breadcrumb item is the "current page"
|
||||
if (index === items.length - 1) {
|
||||
item.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
item.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// We clone the separator and inject them into breadcrumb items, so we need to regenerate the default ones when
|
||||
// directionality changes. We do this by storing the current separator direction, waiting for render, then calling
|
||||
// the function that regenerates them.
|
||||
if (this.separatorDir !== this.localize.dir()) {
|
||||
this.separatorDir = this.localize.dir();
|
||||
this.updateComplete.then(() => this.handleSlotChange());
|
||||
}
|
||||
|
||||
return html`
|
||||
<nav part="base" class="breadcrumb" aria-label=${this.label}>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</nav>
|
||||
|
||||
<span hidden aria-hidden="true">
|
||||
<slot name="separator">
|
||||
<sl-icon name=${this.localize.dir() === 'rtl' ? 'chevron-left' : 'chevron-right'} library="system"></sl-icon>
|
||||
</slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb': SlBreadcrumb;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './breadcrumb.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item.js';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumbs provide a group of links so users can easily navigate a website's hierarchy.
|
||||
* @documentation https://shoelace.style/components/breadcrumb
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more breadcrumb items to display.
|
||||
* @slot separator - The separator to use between breadcrumb items. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-breadcrumb')
|
||||
export default class SlBreadcrumb extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private separatorDir = this.localize.dir();
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
|
||||
|
||||
/**
|
||||
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
|
||||
* screen readers and other assistive devices to provide more context for users.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
// Generates a clone of the separator element to use for each breadcrumb item
|
||||
private getSeparator() {
|
||||
const separator = this.separatorSlot.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||
|
||||
// Clone it, remove ids, and slot it
|
||||
const clone = separator.cloneNode(true) as HTMLElement;
|
||||
[clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id'));
|
||||
clone.setAttribute('data-default', '');
|
||||
clone.slot = 'separator';
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
|
||||
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
|
||||
) as SlBreadcrumbItem[];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// Append separators to each item if they don't already have one
|
||||
const separator = item.querySelector('[slot="separator"]');
|
||||
if (separator === null) {
|
||||
// No separator exists, add one
|
||||
item.append(this.getSeparator());
|
||||
} else if (separator.hasAttribute('data-default')) {
|
||||
// A default separator exists, replace it
|
||||
separator.replaceWith(this.getSeparator());
|
||||
} else {
|
||||
// The user provided a custom separator, leave it alone
|
||||
}
|
||||
|
||||
// The last breadcrumb item is the "current page"
|
||||
if (index === items.length - 1) {
|
||||
item.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
item.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// We clone the separator and inject them into breadcrumb items, so we need to regenerate the default ones when
|
||||
// directionality changes. We do this by storing the current separator direction, waiting for render, then calling
|
||||
// the function that regenerates them.
|
||||
if (this.separatorDir !== this.localize.dir()) {
|
||||
this.separatorDir = this.localize.dir();
|
||||
this.updateComplete.then(() => this.handleSlotChange());
|
||||
}
|
||||
|
||||
return html`
|
||||
<nav part="base" class="breadcrumb" aria-label=${this.label}>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</nav>
|
||||
|
||||
<span hidden aria-hidden="true">
|
||||
<slot name="separator">
|
||||
<sl-icon name=${this.localize.dir() === 'rtl' ? 'chevron-left' : 'chevron-right'} library="system"></sl-icon>
|
||||
</slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb': SlBreadcrumb;
|
||||
}
|
||||
}
|
||||
import SlBreadcrumb from './breadcrumb.component.js';
|
||||
export * from './breadcrumb.component.js';
|
||||
export default SlBreadcrumb;
|
||||
SlBreadcrumb.define('sl-breadcrumb');
|
||||
|
||||
97
src/components/button-group/button-group.component.ts
Normal file
97
src/components/button-group/button-group.component.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './button-group.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Button groups can be used to group related buttons into sections.
|
||||
* @documentation https://shoelace.style/components/button-group
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more `<sl-button>` elements to display in the button group.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlButtonGroup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() disableRole = false;
|
||||
|
||||
/**
|
||||
* A label to use for the button group. This won't be displayed on the screen, but it will be announced by assistive
|
||||
* devices when interacting with the control and is strongly recommended.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
private handleFocus(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleBlur(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleMouseOver(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleMouseOut(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
|
||||
|
||||
slottedElements.forEach(el => {
|
||||
const index = slottedElements.indexOf(el);
|
||||
const button = findButton(el);
|
||||
|
||||
if (button !== null) {
|
||||
button.classList.add('sl-button-group__button');
|
||||
button.classList.toggle('sl-button-group__button--first', index === 0);
|
||||
button.classList.toggle('sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--last', index === slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--radio', button.tagName.toLowerCase() === 'sl-radio-button');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line lit-a11y/mouse-events-have-key-events
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class="button-group"
|
||||
role="${this.disableRole ? 'presentation' : 'group'}"
|
||||
aria-label=${this.label}
|
||||
@focusout=${this.handleBlur}
|
||||
@focusin=${this.handleFocus}
|
||||
@mouseover=${this.handleMouseOver}
|
||||
@mouseout=${this.handleMouseOut}
|
||||
>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function findButton(el: HTMLElement) {
|
||||
const selector = 'sl-button, sl-radio-button';
|
||||
|
||||
// The button could be the target element or a child of it (e.g. a dropdown or tooltip anchor)
|
||||
return el.closest(selector) ?? el.querySelector(selector);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button-group': SlButtonGroup;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +1,4 @@
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './button-group.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Button groups can be used to group related buttons into sections.
|
||||
* @documentation https://shoelace.style/components/button-group
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more `<sl-button>` elements to display in the button group.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-button-group')
|
||||
export default class SlButtonGroup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() disableRole = false;
|
||||
|
||||
/**
|
||||
* A label to use for the button group. This won't be displayed on the screen, but it will be announced by assistive
|
||||
* devices when interacting with the control and is strongly recommended.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
private handleFocus(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleBlur(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleMouseOver(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleMouseOut(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
|
||||
|
||||
slottedElements.forEach(el => {
|
||||
const index = slottedElements.indexOf(el);
|
||||
const button = findButton(el);
|
||||
|
||||
if (button !== null) {
|
||||
button.classList.add('sl-button-group__button');
|
||||
button.classList.toggle('sl-button-group__button--first', index === 0);
|
||||
button.classList.toggle('sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--last', index === slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--radio', button.tagName.toLowerCase() === 'sl-radio-button');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line lit-a11y/mouse-events-have-key-events
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class="button-group"
|
||||
role="${this.disableRole ? 'presentation' : 'group'}"
|
||||
aria-label=${this.label}
|
||||
@focusout=${this.handleBlur}
|
||||
@focusin=${this.handleFocus}
|
||||
@mouseover=${this.handleMouseOver}
|
||||
@mouseout=${this.handleMouseOut}
|
||||
>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function findButton(el: HTMLElement) {
|
||||
const selector = 'sl-button, sl-radio-button';
|
||||
|
||||
// The button could be the target element or a child of it (e.g. a dropdown or tooltip anchor)
|
||||
return el.closest(selector) ?? el.querySelector(selector);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button-group': SlButtonGroup;
|
||||
}
|
||||
}
|
||||
import SlButtonGroup from './button-group.component.js';
|
||||
export * from './button-group.component.js';
|
||||
export default SlButtonGroup;
|
||||
SlButtonGroup.define('sl-button-group');
|
||||
|
||||
336
src/components/button/button.component.ts
Normal file
336
src/components/button/button.component.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { FormControlController, validValidityState } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import SlSpinner from '../spinner/spinner.component.js';
|
||||
import styles from './button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Buttons represent actions that are available to the user.
|
||||
* @documentation https://shoelace.style/components/button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
* @dependency sl-spinner
|
||||
*
|
||||
* @event sl-blur - Emitted when the button loses focus.
|
||||
* @event sl-focus - Emitted when the button gains focus.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @slot - The button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
* @slot suffix - A presentational suffix icon or similar element.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart label - The button's label.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart caret - The button's caret icon, an `<sl-icon>` element.
|
||||
* @csspart spinner - The spinner that shows when the button is in the loading state.
|
||||
*/
|
||||
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon,
|
||||
'sl-spinner': SlSpinner
|
||||
};
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
form: input => {
|
||||
// Buttons support a form attribute that points to an arbitrary form, so if this attribute is set we need to query
|
||||
// the form from the same root using its id
|
||||
if (input.hasAttribute('form')) {
|
||||
const doc = input.getRootNode() as Document | ShadowRoot;
|
||||
const formId = input.getAttribute('form')!;
|
||||
return doc.getElementById(formId) as HTMLFormElement;
|
||||
}
|
||||
|
||||
// Fall back to the closest containing form
|
||||
return input.closest('form');
|
||||
},
|
||||
assumeInteractionOn: ['click']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() invalid = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The button's theme variant. */
|
||||
@property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' =
|
||||
'default';
|
||||
|
||||
/** The button's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws the button with a caret. Used to indicate that the button triggers a dropdown menu or similar behavior. */
|
||||
@property({ type: Boolean, reflect: true }) caret = false;
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the button in a loading state. */
|
||||
@property({ type: Boolean, reflect: true }) loading = false;
|
||||
|
||||
/** Draws an outlined button. */
|
||||
@property({ type: Boolean, reflect: true }) outline = false;
|
||||
|
||||
/** Draws a pill-style button with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/**
|
||||
* Draws a circular icon button. When this attribute is present, the button expects a single `<sl-icon>` in the
|
||||
* default slot.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) circle = false;
|
||||
|
||||
/**
|
||||
* The type of button. Note that the default value is `button` instead of `submit`, which is opposite of how native
|
||||
* `<button>` elements behave. When the type is `submit`, the button will submit the surrounding form.
|
||||
*/
|
||||
@property() type: 'button' | 'submit' | 'reset' = 'button';
|
||||
|
||||
/**
|
||||
* The name of the button, submitted as a name/value pair with form data, but only when this button is the submitter.
|
||||
* This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() name = '';
|
||||
|
||||
/**
|
||||
* The value of the button, submitted as a pair with the button's name as part of the form data, but only when this
|
||||
* button is the submitter. This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() value = '';
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href = '';
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is present. */
|
||||
@property() target: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/**
|
||||
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
|
||||
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
|
||||
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
|
||||
* setting the attribute to an empty string or a value of your choice, respectively.
|
||||
*/
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* The "form owner" to associate the button with. If omitted, the closest containing form will be used instead. The
|
||||
* value of this attribute must be an id of a form in the same document or shadow root as the button.
|
||||
*/
|
||||
@property() form: string;
|
||||
|
||||
/** Used to override the form owner's `action` attribute. */
|
||||
@property({ attribute: 'formaction' }) formAction: string;
|
||||
|
||||
/** Used to override the form owner's `enctype` attribute. */
|
||||
@property({ attribute: 'formenctype' })
|
||||
formEnctype: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
|
||||
|
||||
/** Used to override the form owner's `method` attribute. */
|
||||
@property({ attribute: 'formmethod' }) formMethod: 'post' | 'get';
|
||||
|
||||
/** Used to override the form owner's `novalidate` attribute. */
|
||||
@property({ attribute: 'formnovalidate', type: Boolean }) formNoValidate: boolean;
|
||||
|
||||
/** Used to override the form owner's `target` attribute. */
|
||||
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validity;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
if (this.isButton()) {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.type === 'submit') {
|
||||
this.formControlController.submit(this);
|
||||
}
|
||||
|
||||
if (this.type === 'reset') {
|
||||
this.formControlController.reset(this);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private isButton() {
|
||||
return this.href ? false : true;
|
||||
}
|
||||
|
||||
private isLink() {
|
||||
return this.href ? true : false;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
if (this.isButton()) {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).checkValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).reportValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
if (this.isButton()) {
|
||||
(this.button as HTMLButtonElement).setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.isLink();
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/no-invalid-html */
|
||||
/* eslint-disable lit/binding-positions */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
button: true,
|
||||
'button--default': this.variant === 'default',
|
||||
'button--primary': this.variant === 'primary',
|
||||
'button--success': this.variant === 'success',
|
||||
'button--neutral': this.variant === 'neutral',
|
||||
'button--warning': this.variant === 'warning',
|
||||
'button--danger': this.variant === 'danger',
|
||||
'button--text': this.variant === 'text',
|
||||
'button--small': this.size === 'small',
|
||||
'button--medium': this.size === 'medium',
|
||||
'button--large': this.size === 'large',
|
||||
'button--caret': this.caret,
|
||||
'button--circle': this.circle,
|
||||
'button--disabled': this.disabled,
|
||||
'button--focused': this.hasFocus,
|
||||
'button--loading': this.loading,
|
||||
'button--standard': !this.outline,
|
||||
'button--outline': this.outline,
|
||||
'button--pill': this.pill,
|
||||
'button--rtl': this.localize.dir() === 'rtl',
|
||||
'button--has-label': this.hasSlotController.test('[default]'),
|
||||
'button--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'button--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : this.type)}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(isLink ? undefined : this.name)}
|
||||
value=${ifDefined(isLink ? undefined : this.value)}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink ? this.rel : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@invalid=${this.isButton() ? this.handleInvalid : null}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="button__prefix"></slot>
|
||||
<slot part="label" class="button__label"></slot>
|
||||
<slot name="suffix" part="suffix" class="button__suffix"></slot>
|
||||
${
|
||||
this.caret ? html` <sl-icon part="caret" class="button__caret" library="system" name="caret"></sl-icon> ` : ''
|
||||
}
|
||||
${this.loading ? html`<sl-spinner part="spinner"></sl-spinner>` : ''}
|
||||
</${tag}>
|
||||
`;
|
||||
/* eslint-enable lit/no-invalid-html */
|
||||
/* eslint-enable lit/binding-positions */
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button': SlButton;
|
||||
}
|
||||
}
|
||||
@@ -1,333 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import '../spinner/spinner.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { FormControlController, validValidityState } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Buttons represent actions that are available to the user.
|
||||
* @documentation https://shoelace.style/components/button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
* @dependency sl-spinner
|
||||
*
|
||||
* @event sl-blur - Emitted when the button loses focus.
|
||||
* @event sl-focus - Emitted when the button gains focus.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @slot - The button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
* @slot suffix - A presentational suffix icon or similar element.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart label - The button's label.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart caret - The button's caret icon, an `<sl-icon>` element.
|
||||
* @csspart spinner - The spinner that shows when the button is in the loading state.
|
||||
*/
|
||||
@customElement('sl-button')
|
||||
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
form: input => {
|
||||
// Buttons support a form attribute that points to an arbitrary form, so if this attribute is set we need to query
|
||||
// the form from the same root using its id
|
||||
if (input.hasAttribute('form')) {
|
||||
const doc = input.getRootNode() as Document | ShadowRoot;
|
||||
const formId = input.getAttribute('form')!;
|
||||
return doc.getElementById(formId) as HTMLFormElement;
|
||||
}
|
||||
|
||||
// Fall back to the closest containing form
|
||||
return input.closest('form');
|
||||
},
|
||||
assumeInteractionOn: ['click']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() invalid = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The button's theme variant. */
|
||||
@property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' =
|
||||
'default';
|
||||
|
||||
/** The button's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws the button with a caret. Used to indicate that the button triggers a dropdown menu or similar behavior. */
|
||||
@property({ type: Boolean, reflect: true }) caret = false;
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the button in a loading state. */
|
||||
@property({ type: Boolean, reflect: true }) loading = false;
|
||||
|
||||
/** Draws an outlined button. */
|
||||
@property({ type: Boolean, reflect: true }) outline = false;
|
||||
|
||||
/** Draws a pill-style button with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/**
|
||||
* Draws a circular icon button. When this attribute is present, the button expects a single `<sl-icon>` in the
|
||||
* default slot.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) circle = false;
|
||||
|
||||
/**
|
||||
* The type of button. Note that the default value is `button` instead of `submit`, which is opposite of how native
|
||||
* `<button>` elements behave. When the type is `submit`, the button will submit the surrounding form.
|
||||
*/
|
||||
@property() type: 'button' | 'submit' | 'reset' = 'button';
|
||||
|
||||
/**
|
||||
* The name of the button, submitted as a name/value pair with form data, but only when this button is the submitter.
|
||||
* This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() name = '';
|
||||
|
||||
/**
|
||||
* The value of the button, submitted as a pair with the button's name as part of the form data, but only when this
|
||||
* button is the submitter. This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() value = '';
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href = '';
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is present. */
|
||||
@property() target: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/**
|
||||
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
|
||||
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
|
||||
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
|
||||
* setting the attribute to an empty string or a value of your choice, respectively.
|
||||
*/
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* The "form owner" to associate the button with. If omitted, the closest containing form will be used instead. The
|
||||
* value of this attribute must be an id of a form in the same document or shadow root as the button.
|
||||
*/
|
||||
@property() form: string;
|
||||
|
||||
/** Used to override the form owner's `action` attribute. */
|
||||
@property({ attribute: 'formaction' }) formAction: string;
|
||||
|
||||
/** Used to override the form owner's `enctype` attribute. */
|
||||
@property({ attribute: 'formenctype' })
|
||||
formEnctype: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
|
||||
|
||||
/** Used to override the form owner's `method` attribute. */
|
||||
@property({ attribute: 'formmethod' }) formMethod: 'post' | 'get';
|
||||
|
||||
/** Used to override the form owner's `novalidate` attribute. */
|
||||
@property({ attribute: 'formnovalidate', type: Boolean }) formNoValidate: boolean;
|
||||
|
||||
/** Used to override the form owner's `target` attribute. */
|
||||
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validity;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
if (this.isButton()) {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.type === 'submit') {
|
||||
this.formControlController.submit(this);
|
||||
}
|
||||
|
||||
if (this.type === 'reset') {
|
||||
this.formControlController.reset(this);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private isButton() {
|
||||
return this.href ? false : true;
|
||||
}
|
||||
|
||||
private isLink() {
|
||||
return this.href ? true : false;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
if (this.isButton()) {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).checkValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).reportValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
if (this.isButton()) {
|
||||
(this.button as HTMLButtonElement).setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.isLink();
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/no-invalid-html */
|
||||
/* eslint-disable lit/binding-positions */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
button: true,
|
||||
'button--default': this.variant === 'default',
|
||||
'button--primary': this.variant === 'primary',
|
||||
'button--success': this.variant === 'success',
|
||||
'button--neutral': this.variant === 'neutral',
|
||||
'button--warning': this.variant === 'warning',
|
||||
'button--danger': this.variant === 'danger',
|
||||
'button--text': this.variant === 'text',
|
||||
'button--small': this.size === 'small',
|
||||
'button--medium': this.size === 'medium',
|
||||
'button--large': this.size === 'large',
|
||||
'button--caret': this.caret,
|
||||
'button--circle': this.circle,
|
||||
'button--disabled': this.disabled,
|
||||
'button--focused': this.hasFocus,
|
||||
'button--loading': this.loading,
|
||||
'button--standard': !this.outline,
|
||||
'button--outline': this.outline,
|
||||
'button--pill': this.pill,
|
||||
'button--rtl': this.localize.dir() === 'rtl',
|
||||
'button--has-label': this.hasSlotController.test('[default]'),
|
||||
'button--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'button--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : this.type)}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(isLink ? undefined : this.name)}
|
||||
value=${ifDefined(isLink ? undefined : this.value)}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink ? this.rel : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@invalid=${this.isButton() ? this.handleInvalid : null}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="button__prefix"></slot>
|
||||
<slot part="label" class="button__label"></slot>
|
||||
<slot name="suffix" part="suffix" class="button__suffix"></slot>
|
||||
${
|
||||
this.caret ? html` <sl-icon part="caret" class="button__caret" library="system" name="caret"></sl-icon> ` : ''
|
||||
}
|
||||
${this.loading ? html`<sl-spinner part="spinner"></sl-spinner>` : ''}
|
||||
</${tag}>
|
||||
`;
|
||||
/* eslint-enable lit/no-invalid-html */
|
||||
/* eslint-enable lit/binding-positions */
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button': SlButton;
|
||||
}
|
||||
}
|
||||
import SlButton from './button.component.js';
|
||||
export * from './button.component.js';
|
||||
export default SlButton;
|
||||
SlButton.define('sl-button');
|
||||
|
||||
59
src/components/card/card.component.ts
Normal file
59
src/components/card/card.component.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './card.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Cards can be used to group related subjects in a container.
|
||||
* @documentation https://shoelace.style/components/card
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The card's main content.
|
||||
* @slot header - An optional header for the card.
|
||||
* @slot footer - An optional footer for the card.
|
||||
* @slot image - An optional image to render at the start of the card.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart image - The container that wraps the card's image.
|
||||
* @csspart header - The container that wraps the card's header.
|
||||
* @csspart body - The container that wraps the card's main content.
|
||||
* @csspart footer - The container that wraps the card's footer.
|
||||
*
|
||||
* @cssproperty --border-color - The card's border color, including borders that occur inside the card.
|
||||
* @cssproperty --border-radius - The border radius for the card's edges.
|
||||
* @cssproperty --border-width - The width of the card's borders.
|
||||
* @cssproperty --padding - The padding to use for the card's sections.
|
||||
*/
|
||||
export default class SlCard extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
card: true,
|
||||
'card--has-footer': this.hasSlotController.test('footer'),
|
||||
'card--has-image': this.hasSlotController.test('image'),
|
||||
'card--has-header': this.hasSlotController.test('header')
|
||||
})}
|
||||
>
|
||||
<slot name="image" part="image" class="card__image"></slot>
|
||||
<slot name="header" part="header" class="card__header"></slot>
|
||||
<slot part="body" class="card__body"></slot>
|
||||
<slot name="footer" part="footer" class="card__footer"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-card': SlCard;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './card.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Cards can be used to group related subjects in a container.
|
||||
* @documentation https://shoelace.style/components/card
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The card's main content.
|
||||
* @slot header - An optional header for the card.
|
||||
* @slot footer - An optional footer for the card.
|
||||
* @slot image - An optional image to render at the start of the card.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart image - The container that wraps the card's image.
|
||||
* @csspart header - The container that wraps the card's header.
|
||||
* @csspart body - The container that wraps the card's main content.
|
||||
* @csspart footer - The container that wraps the card's footer.
|
||||
*
|
||||
* @cssproperty --border-color - The card's border color, including borders that occur inside the card.
|
||||
* @cssproperty --border-radius - The border radius for the card's edges.
|
||||
* @cssproperty --border-width - The width of the card's borders.
|
||||
* @cssproperty --padding - The padding to use for the card's sections.
|
||||
*/
|
||||
@customElement('sl-card')
|
||||
export default class SlCard extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
card: true,
|
||||
'card--has-footer': this.hasSlotController.test('footer'),
|
||||
'card--has-image': this.hasSlotController.test('image'),
|
||||
'card--has-header': this.hasSlotController.test('header')
|
||||
})}
|
||||
>
|
||||
<slot name="image" part="image" class="card__image"></slot>
|
||||
<slot name="header" part="header" class="card__header"></slot>
|
||||
<slot part="body" class="card__body"></slot>
|
||||
<slot name="footer" part="footer" class="card__footer"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-card': SlCard;
|
||||
}
|
||||
}
|
||||
import SlCard from './card.component.js';
|
||||
export * from './card.component.js';
|
||||
export default SlCard;
|
||||
SlCard.define('sl-card');
|
||||
|
||||
38
src/components/carousel-item/carousel-item.component.ts
Normal file
38
src/components/carousel-item/carousel-item.component.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './carousel-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A carousel item represent a slide within a [carousel](/components/carousel).
|
||||
*
|
||||
* @since 2.0
|
||||
* @status experimental
|
||||
*
|
||||
* @slot - The carousel item's content..
|
||||
*
|
||||
* @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default.
|
||||
*
|
||||
*/
|
||||
export default class SlCarouselItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static isCarouselItem(node: Node) {
|
||||
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'group');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel-item': SlCarouselItem;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,4 @@
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './carousel-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A carousel item represent a slide within a [carousel](/components/carousel).
|
||||
*
|
||||
* @since 2.0
|
||||
* @status experimental
|
||||
*
|
||||
* @slot - The carousel item's content..
|
||||
*
|
||||
* @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default.
|
||||
*
|
||||
*/
|
||||
@customElement('sl-carousel-item')
|
||||
export default class SlCarouselItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static isCarouselItem(node: Node) {
|
||||
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'group');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel-item': SlCarouselItem;
|
||||
}
|
||||
}
|
||||
import SlCarouselItem from './carousel-item.component.js';
|
||||
export * from './carousel-item.component.js';
|
||||
export default SlCarouselItem;
|
||||
SlCarouselItem.define('sl-carousel-item');
|
||||
|
||||
479
src/components/carousel/carousel.component.ts
Normal file
479
src/components/carousel/carousel.component.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { AutoplayController } from './autoplay-controller.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { map } from 'lit/directives/map.js';
|
||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { range } from 'lit/directives/range.js';
|
||||
import { ScrollController } from './scroll-controller.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlCarouselItem from '../carousel-item/carousel-item.component.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './carousel.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
||||
*
|
||||
* @since 2.2
|
||||
* @status experimental
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes.
|
||||
*
|
||||
* @slot - The carousel's main content, one or more `<sl-carousel-item>` elements.
|
||||
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The carousel's internal wrapper.
|
||||
* @csspart scroll-container - The scroll container that wraps the slides.
|
||||
* @csspart pagination - The pagination indicators wrapper.
|
||||
* @csspart pagination-item - The pagination indicator.
|
||||
* @csspart pagination-item--active - Applied when the item is active.
|
||||
* @csspart navigation - The navigation wrapper.
|
||||
* @csspart navigation-button - The navigation button.
|
||||
* @csspart navigation-button--previous - Applied to the previous button.
|
||||
* @csspart navigation-button--next - Applied to the next button.
|
||||
*
|
||||
* @cssproperty --slide-gap - The space between each slide.
|
||||
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
|
||||
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
|
||||
* partially visible as a scroll hint.
|
||||
*/
|
||||
export default class SlCarousel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
|
||||
@property({ type: Boolean, reflect: true }) loop = false;
|
||||
|
||||
/** When set, show the carousel's navigation. */
|
||||
@property({ type: Boolean, reflect: true }) navigation = false;
|
||||
|
||||
/** When set, show the carousel's pagination indicators. */
|
||||
@property({ type: Boolean, reflect: true }) pagination = false;
|
||||
|
||||
/** When set, the slides will scroll automatically when the user is not interacting with them. */
|
||||
@property({ type: Boolean, reflect: true }) autoplay = false;
|
||||
|
||||
/** Specifies the amount of time, in milliseconds, between each automatic scroll. */
|
||||
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
|
||||
|
||||
/** Specifies how many slides should be shown at a given time. */
|
||||
@property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1;
|
||||
|
||||
/**
|
||||
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
|
||||
* greater than one.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||
|
||||
/** Specifies the orientation in which the carousel will lay out. */
|
||||
@property() orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
|
||||
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.carousel__slides') scrollContainer: HTMLElement;
|
||||
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
||||
|
||||
// The index of the active slide
|
||||
@state() activeSlide = 0;
|
||||
|
||||
private autoplayController = new AutoplayController(this, () => this.next());
|
||||
private scrollController = new ScrollController(this);
|
||||
private readonly slides = this.getElementsByTagName('sl-carousel-item');
|
||||
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
|
||||
// A map containing the state of all the slides
|
||||
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'region');
|
||||
this.setAttribute('aria-label', this.localize.term('carousel'));
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach(entry => {
|
||||
// Store all the entries in a map to be processed when scrolling ends
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
|
||||
const slide = entry.target;
|
||||
slide.toggleAttribute('inert', !entry.isIntersecting);
|
||||
slide.classList.toggle('--in-view', entry.isIntersecting);
|
||||
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
|
||||
});
|
||||
},
|
||||
{
|
||||
root: this,
|
||||
threshold: 0.6
|
||||
}
|
||||
);
|
||||
this.intersectionObserver = intersectionObserver;
|
||||
|
||||
// Store the initial state of each slide
|
||||
intersectionObserver.takeRecords().forEach(entry => {
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.intersectionObserver.disconnect();
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this.initializeSlides();
|
||||
this.mutationObserver = new MutationObserver(this.handleSlotChange);
|
||||
this.mutationObserver.observe(this, { childList: true, subtree: false });
|
||||
}
|
||||
|
||||
private getPageCount() {
|
||||
return Math.ceil(this.getSlides().length / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getCurrentPage() {
|
||||
return Math.ceil(this.activeSlide / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||
const target = event.target as HTMLElement;
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
|
||||
const isNext =
|
||||
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
|
||||
const isPrevious =
|
||||
event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (isPrevious) {
|
||||
this.previous();
|
||||
}
|
||||
|
||||
if (isNext) {
|
||||
this.next();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
this.goToSlide(0);
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
this.goToSlide(this.getSlides().length - 1);
|
||||
}
|
||||
|
||||
if (isFocusInPagination) {
|
||||
this.updateComplete.then(() => {
|
||||
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
|
||||
'[part~="pagination-item--active"]'
|
||||
);
|
||||
|
||||
if (activePaginationItem) {
|
||||
activePaginationItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleScrollEnd() {
|
||||
const slides = this.getSlides();
|
||||
const entries = [...this.intersectionObserverEntries.values()];
|
||||
|
||||
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
|
||||
|
||||
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
|
||||
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
|
||||
|
||||
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
||||
this.goToSlide(clonePosition, 'auto');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate the first intersecting slide
|
||||
if (firstIntersecting) {
|
||||
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange = (mutations: MutationRecord[]) => {
|
||||
const needsInitialization = mutations.some(mutation =>
|
||||
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
||||
)
|
||||
);
|
||||
|
||||
// Reinitialize the carousel if a carousel item has been added or removed
|
||||
if (needsInitialization) {
|
||||
this.initializeSlides();
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@watch('loop', { waitUntilFirstUpdate: true })
|
||||
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||
initializeSlides() {
|
||||
const slides = this.getSlides();
|
||||
const intersectionObserver = this.intersectionObserver;
|
||||
|
||||
this.intersectionObserverEntries.clear();
|
||||
|
||||
// Removes all the cloned elements from the carousel
|
||||
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
|
||||
intersectionObserver.unobserve(slide);
|
||||
|
||||
slide.classList.remove('--in-view');
|
||||
slide.classList.remove('--is-active');
|
||||
slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1));
|
||||
|
||||
if (slide.hasAttribute('data-clone')) {
|
||||
slide.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.loop) {
|
||||
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
||||
const slidesPerPage = this.slidesPerPage;
|
||||
const lastSlides = slides.slice(-slidesPerPage);
|
||||
const firstSlides = slides.slice(0, slidesPerPage);
|
||||
|
||||
lastSlides.reverse().forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(slides.length - i - 1));
|
||||
this.prepend(clone);
|
||||
});
|
||||
|
||||
firstSlides.forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(i));
|
||||
this.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
intersectionObserver.observe(slide);
|
||||
});
|
||||
|
||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||
this.goToSlide(this.activeSlide, 'auto');
|
||||
}
|
||||
|
||||
@watch('activeSlide')
|
||||
handelSlideChange() {
|
||||
const slides = this.getSlides();
|
||||
slides.forEach((slide, i) => {
|
||||
slide.classList.toggle('--is-active', i === this.activeSlide);
|
||||
});
|
||||
|
||||
// Do not emit an event on first render
|
||||
if (this.hasUpdated) {
|
||||
this.emit('sl-slide-change', {
|
||||
detail: {
|
||||
index: this.activeSlide,
|
||||
slide: slides[this.activeSlide]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@watch('slidesPerMove')
|
||||
handleSlidesPerMoveChange() {
|
||||
const slides = this.getSlides({ excludeClones: false });
|
||||
|
||||
const slidesPerMove = this.slidesPerMove;
|
||||
slides.forEach((slide, i) => {
|
||||
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
||||
if (shouldSnap) {
|
||||
slide.style.removeProperty('scroll-snap-align');
|
||||
} else {
|
||||
slide.style.setProperty('scroll-snap-align', 'none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@watch('autoplay')
|
||||
handleAutoplayChange() {
|
||||
this.autoplayController.stop();
|
||||
if (this.autoplay) {
|
||||
this.autoplayController.start(this.autoplayInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@watch('mouseDragging')
|
||||
handleMouseDraggingChange() {
|
||||
this.scrollController.mouseDragging = this.mouseDragging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel backward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
previous(behavior: ScrollBehavior = 'smooth') {
|
||||
let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove;
|
||||
let canSnap = false;
|
||||
|
||||
while (!canSnap && previousIndex > 0) {
|
||||
previousIndex -= 1;
|
||||
canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0;
|
||||
}
|
||||
|
||||
this.goToSlide(previousIndex, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel forward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
next(behavior: ScrollBehavior = 'smooth') {
|
||||
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the carousel to the slide specified by `index`.
|
||||
*
|
||||
* @param index - The slide index.
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
|
||||
const { slidesPerPage, loop, scrollContainer } = this;
|
||||
|
||||
const slides = this.getSlides();
|
||||
const slidesWithClones = this.getSlides({ excludeClones: false });
|
||||
|
||||
// Sets the next index without taking into account clones, if any.
|
||||
const newActiveSlide = (index + slides.length) % slides.length;
|
||||
this.activeSlide = newActiveSlide;
|
||||
|
||||
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
||||
// to normalize the starting index in order to ignore the first nth clones.
|
||||
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
|
||||
const nextSlide = slidesWithClones[nextSlideIndex];
|
||||
|
||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const nextSlideRect = nextSlide.getBoundingClientRect();
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
|
||||
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
|
||||
behavior: prefersReducedMotion() ? 'auto' : behavior
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { scrollController, slidesPerPage } = this;
|
||||
const pagesCount = this.getPageCount();
|
||||
const currentPage = this.getCurrentPage();
|
||||
const prevEnabled = this.loop || currentPage > 0;
|
||||
const nextEnabled = this.loop || currentPage < pagesCount - 1;
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
return html`
|
||||
<div part="base" class="carousel">
|
||||
<div
|
||||
id="scroll-container"
|
||||
part="scroll-container"
|
||||
class="${classMap({
|
||||
carousel__slides: true,
|
||||
'carousel__slides--horizontal': this.orientation === 'horizontal',
|
||||
'carousel__slides--vertical': this.orientation === 'vertical'
|
||||
})}"
|
||||
style="--slides-per-page: ${this.slidesPerPage};"
|
||||
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
|
||||
aria-atomic="true"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeyDown}
|
||||
@scrollend=${this.handleScrollEnd}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${this.navigation
|
||||
? html`
|
||||
<div part="navigation" class="carousel__navigation">
|
||||
<button
|
||||
part="navigation-button navigation-button--previous"
|
||||
class="${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--previous': true,
|
||||
'carousel__navigation-button--disabled': !prevEnabled
|
||||
})}"
|
||||
aria-label="${this.localize.term('previousSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${prevEnabled ? 'false' : 'true'}"
|
||||
@click=${prevEnabled ? () => this.previous() : null}
|
||||
>
|
||||
<slot name="previous-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<button
|
||||
part="navigation-button navigation-button--next"
|
||||
class=${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--next': true,
|
||||
'carousel__navigation-button--disabled': !nextEnabled
|
||||
})}
|
||||
aria-label="${this.localize.term('nextSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${nextEnabled ? 'false' : 'true'}"
|
||||
@click=${nextEnabled ? () => this.next() : null}
|
||||
>
|
||||
<slot name="next-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${this.pagination
|
||||
? html`
|
||||
<div part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
|
||||
${map(range(pagesCount), index => {
|
||||
const isActive = index === currentPage;
|
||||
return html`
|
||||
<button
|
||||
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
|
||||
class="${classMap({
|
||||
'carousel__pagination-item': true,
|
||||
'carousel__pagination-item--active': isActive
|
||||
})}"
|
||||
role="tab"
|
||||
aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||
tabindex=${isActive ? '0' : '-1'}
|
||||
@click=${() => this.goToSlide(index * slidesPerPage)}
|
||||
@keydown=${this.handleKeyDown}
|
||||
></button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel': SlCarousel;
|
||||
}
|
||||
}
|
||||
@@ -1,479 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { AutoplayController } from './autoplay-controller.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { map } from 'lit/directives/map.js';
|
||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||
import { range } from 'lit/directives/range.js';
|
||||
import { ScrollController } from './scroll-controller.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlCarouselItem from '../carousel-item/carousel-item.js';
|
||||
import styles from './carousel.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
||||
*
|
||||
* @since 2.2
|
||||
* @status experimental
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes.
|
||||
*
|
||||
* @slot - The carousel's main content, one or more `<sl-carousel-item>` elements.
|
||||
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The carousel's internal wrapper.
|
||||
* @csspart scroll-container - The scroll container that wraps the slides.
|
||||
* @csspart pagination - The pagination indicators wrapper.
|
||||
* @csspart pagination-item - The pagination indicator.
|
||||
* @csspart pagination-item--active - Applied when the item is active.
|
||||
* @csspart navigation - The navigation wrapper.
|
||||
* @csspart navigation-button - The navigation button.
|
||||
* @csspart navigation-button--previous - Applied to the previous button.
|
||||
* @csspart navigation-button--next - Applied to the next button.
|
||||
*
|
||||
* @cssproperty --slide-gap - The space between each slide.
|
||||
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
|
||||
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
|
||||
* partially visible as a scroll hint.
|
||||
*/
|
||||
@customElement('sl-carousel')
|
||||
export default class SlCarousel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
|
||||
@property({ type: Boolean, reflect: true }) loop = false;
|
||||
|
||||
/** When set, show the carousel's navigation. */
|
||||
@property({ type: Boolean, reflect: true }) navigation = false;
|
||||
|
||||
/** When set, show the carousel's pagination indicators. */
|
||||
@property({ type: Boolean, reflect: true }) pagination = false;
|
||||
|
||||
/** When set, the slides will scroll automatically when the user is not interacting with them. */
|
||||
@property({ type: Boolean, reflect: true }) autoplay = false;
|
||||
|
||||
/** Specifies the amount of time, in milliseconds, between each automatic scroll. */
|
||||
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
|
||||
|
||||
/** Specifies how many slides should be shown at a given time. */
|
||||
@property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1;
|
||||
|
||||
/**
|
||||
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
|
||||
* greater than one.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||
|
||||
/** Specifies the orientation in which the carousel will lay out. */
|
||||
@property() orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
|
||||
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.carousel__slides') scrollContainer: HTMLElement;
|
||||
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
||||
|
||||
// The index of the active slide
|
||||
@state() activeSlide = 0;
|
||||
|
||||
private autoplayController = new AutoplayController(this, () => this.next());
|
||||
private scrollController = new ScrollController(this);
|
||||
private readonly slides = this.getElementsByTagName('sl-carousel-item');
|
||||
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
|
||||
// A map containing the state of all the slides
|
||||
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'region');
|
||||
this.setAttribute('aria-label', this.localize.term('carousel'));
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach(entry => {
|
||||
// Store all the entries in a map to be processed when scrolling ends
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
|
||||
const slide = entry.target;
|
||||
slide.toggleAttribute('inert', !entry.isIntersecting);
|
||||
slide.classList.toggle('--in-view', entry.isIntersecting);
|
||||
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
|
||||
});
|
||||
},
|
||||
{
|
||||
root: this,
|
||||
threshold: 0.6
|
||||
}
|
||||
);
|
||||
this.intersectionObserver = intersectionObserver;
|
||||
|
||||
// Store the initial state of each slide
|
||||
intersectionObserver.takeRecords().forEach(entry => {
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.intersectionObserver.disconnect();
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this.initializeSlides();
|
||||
this.mutationObserver = new MutationObserver(this.handleSlotChange);
|
||||
this.mutationObserver.observe(this, { childList: true, subtree: false });
|
||||
}
|
||||
|
||||
private getPageCount() {
|
||||
return Math.ceil(this.getSlides().length / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getCurrentPage() {
|
||||
return Math.ceil(this.activeSlide / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||
const target = event.target as HTMLElement;
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
|
||||
const isNext =
|
||||
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
|
||||
const isPrevious =
|
||||
event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (isPrevious) {
|
||||
this.previous();
|
||||
}
|
||||
|
||||
if (isNext) {
|
||||
this.next();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
this.goToSlide(0);
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
this.goToSlide(this.getSlides().length - 1);
|
||||
}
|
||||
|
||||
if (isFocusInPagination) {
|
||||
this.updateComplete.then(() => {
|
||||
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
|
||||
'[part~="pagination-item--active"]'
|
||||
);
|
||||
|
||||
if (activePaginationItem) {
|
||||
activePaginationItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleScrollEnd() {
|
||||
const slides = this.getSlides();
|
||||
const entries = [...this.intersectionObserverEntries.values()];
|
||||
|
||||
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
|
||||
|
||||
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
|
||||
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
|
||||
|
||||
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
||||
this.goToSlide(clonePosition, 'auto');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate the first intersecting slide
|
||||
if (firstIntersecting) {
|
||||
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange = (mutations: MutationRecord[]) => {
|
||||
const needsInitialization = mutations.some(mutation =>
|
||||
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
||||
)
|
||||
);
|
||||
|
||||
// Reinitialize the carousel if a carousel item has been added or removed
|
||||
if (needsInitialization) {
|
||||
this.initializeSlides();
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@watch('loop', { waitUntilFirstUpdate: true })
|
||||
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||
initializeSlides() {
|
||||
const slides = this.getSlides();
|
||||
const intersectionObserver = this.intersectionObserver;
|
||||
|
||||
this.intersectionObserverEntries.clear();
|
||||
|
||||
// Removes all the cloned elements from the carousel
|
||||
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
|
||||
intersectionObserver.unobserve(slide);
|
||||
|
||||
slide.classList.remove('--in-view');
|
||||
slide.classList.remove('--is-active');
|
||||
slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1));
|
||||
|
||||
if (slide.hasAttribute('data-clone')) {
|
||||
slide.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.loop) {
|
||||
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
||||
const slidesPerPage = this.slidesPerPage;
|
||||
const lastSlides = slides.slice(-slidesPerPage);
|
||||
const firstSlides = slides.slice(0, slidesPerPage);
|
||||
|
||||
lastSlides.reverse().forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(slides.length - i - 1));
|
||||
this.prepend(clone);
|
||||
});
|
||||
|
||||
firstSlides.forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(i));
|
||||
this.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
intersectionObserver.observe(slide);
|
||||
});
|
||||
|
||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||
this.goToSlide(this.activeSlide, 'auto');
|
||||
}
|
||||
|
||||
@watch('activeSlide')
|
||||
handelSlideChange() {
|
||||
const slides = this.getSlides();
|
||||
slides.forEach((slide, i) => {
|
||||
slide.classList.toggle('--is-active', i === this.activeSlide);
|
||||
});
|
||||
|
||||
// Do not emit an event on first render
|
||||
if (this.hasUpdated) {
|
||||
this.emit('sl-slide-change', {
|
||||
detail: {
|
||||
index: this.activeSlide,
|
||||
slide: slides[this.activeSlide]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@watch('slidesPerMove')
|
||||
handleSlidesPerMoveChange() {
|
||||
const slides = this.getSlides({ excludeClones: false });
|
||||
|
||||
const slidesPerMove = this.slidesPerMove;
|
||||
slides.forEach((slide, i) => {
|
||||
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
||||
if (shouldSnap) {
|
||||
slide.style.removeProperty('scroll-snap-align');
|
||||
} else {
|
||||
slide.style.setProperty('scroll-snap-align', 'none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@watch('autoplay')
|
||||
handleAutoplayChange() {
|
||||
this.autoplayController.stop();
|
||||
if (this.autoplay) {
|
||||
this.autoplayController.start(this.autoplayInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@watch('mouseDragging')
|
||||
handleMouseDraggingChange() {
|
||||
this.scrollController.mouseDragging = this.mouseDragging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel backward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
previous(behavior: ScrollBehavior = 'smooth') {
|
||||
let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove;
|
||||
let canSnap = false;
|
||||
|
||||
while (!canSnap && previousIndex > 0) {
|
||||
previousIndex -= 1;
|
||||
canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0;
|
||||
}
|
||||
|
||||
this.goToSlide(previousIndex, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel forward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
next(behavior: ScrollBehavior = 'smooth') {
|
||||
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the carousel to the slide specified by `index`.
|
||||
*
|
||||
* @param index - The slide index.
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
|
||||
const { slidesPerPage, loop, scrollContainer } = this;
|
||||
|
||||
const slides = this.getSlides();
|
||||
const slidesWithClones = this.getSlides({ excludeClones: false });
|
||||
|
||||
// Sets the next index without taking into account clones, if any.
|
||||
const newActiveSlide = (index + slides.length) % slides.length;
|
||||
this.activeSlide = newActiveSlide;
|
||||
|
||||
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
||||
// to normalize the starting index in order to ignore the first nth clones.
|
||||
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
|
||||
const nextSlide = slidesWithClones[nextSlideIndex];
|
||||
|
||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const nextSlideRect = nextSlide.getBoundingClientRect();
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
|
||||
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
|
||||
behavior: prefersReducedMotion() ? 'auto' : behavior
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { scrollController, slidesPerPage } = this;
|
||||
const pagesCount = this.getPageCount();
|
||||
const currentPage = this.getCurrentPage();
|
||||
const prevEnabled = this.loop || currentPage > 0;
|
||||
const nextEnabled = this.loop || currentPage < pagesCount - 1;
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
return html`
|
||||
<div part="base" class="carousel">
|
||||
<div
|
||||
id="scroll-container"
|
||||
part="scroll-container"
|
||||
class="${classMap({
|
||||
carousel__slides: true,
|
||||
'carousel__slides--horizontal': this.orientation === 'horizontal',
|
||||
'carousel__slides--vertical': this.orientation === 'vertical'
|
||||
})}"
|
||||
style="--slides-per-page: ${this.slidesPerPage};"
|
||||
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
|
||||
aria-atomic="true"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeyDown}
|
||||
@scrollend=${this.handleScrollEnd}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${this.navigation
|
||||
? html`
|
||||
<div part="navigation" class="carousel__navigation">
|
||||
<button
|
||||
part="navigation-button navigation-button--previous"
|
||||
class="${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--previous': true,
|
||||
'carousel__navigation-button--disabled': !prevEnabled
|
||||
})}"
|
||||
aria-label="${this.localize.term('previousSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${prevEnabled ? 'false' : 'true'}"
|
||||
@click=${prevEnabled ? () => this.previous() : null}
|
||||
>
|
||||
<slot name="previous-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<button
|
||||
part="navigation-button navigation-button--next"
|
||||
class=${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--next': true,
|
||||
'carousel__navigation-button--disabled': !nextEnabled
|
||||
})}
|
||||
aria-label="${this.localize.term('nextSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${nextEnabled ? 'false' : 'true'}"
|
||||
@click=${nextEnabled ? () => this.next() : null}
|
||||
>
|
||||
<slot name="next-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${this.pagination
|
||||
? html`
|
||||
<div part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
|
||||
${map(range(pagesCount), index => {
|
||||
const isActive = index === currentPage;
|
||||
return html`
|
||||
<button
|
||||
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
|
||||
class="${classMap({
|
||||
'carousel__pagination-item': true,
|
||||
'carousel__pagination-item--active': isActive
|
||||
})}"
|
||||
role="tab"
|
||||
aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||
tabindex=${isActive ? '0' : '-1'}
|
||||
@click=${() => this.goToSlide(index * slidesPerPage)}
|
||||
@keydown=${this.handleKeyDown}
|
||||
></button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel': SlCarousel;
|
||||
}
|
||||
}
|
||||
import SlCarousel from './carousel.component.js';
|
||||
export * from './carousel.component.js';
|
||||
export default SlCarousel;
|
||||
SlCarousel.define('sl-carousel');
|
||||
|
||||
251
src/components/checkbox/checkbox.component.ts
Normal file
251
src/components/checkbox/checkbox.component.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './checkbox.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Checkboxes allow the user to toggle an option on or off.
|
||||
* @documentation https://shoelace.style/components/checkbox
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The checkbox's label.
|
||||
*
|
||||
* @event sl-blur - Emitted when the checkbox loses focus.
|
||||
* @event sl-change - Emitted when the checked state changes.
|
||||
* @event sl-focus - Emitted when the checkbox gains focus.
|
||||
* @event sl-input - Emitted when the checkbox receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The square container that wraps the checkbox's checked state.
|
||||
* @csspart control--checked - Matches the control part when the checkbox is checked.
|
||||
* @csspart control--indeterminate - Matches the control part when the checkbox is indeterminate.
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart indeterminate-icon - The indeterminate icon, an `<sl-icon>` element.
|
||||
* @csspart label - The container that wraps the checkbox's label.
|
||||
*/
|
||||
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
|
||||
defaultValue: (control: SlCheckbox) => control.defaultChecked,
|
||||
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
|
||||
});
|
||||
|
||||
@query('input[type="checkbox"]') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The name of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() value: string;
|
||||
|
||||
/** The checkbox's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Disables the checkbox. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the checkbox in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/**
|
||||
* Draws the checkbox in an indeterminate state. This is usually applied to checkboxes that represents a "select
|
||||
* all/none" behavior when associated checkboxes have a mix of checked and unchecked states.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) indeterminate = false;
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue('checked') defaultChecked = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the checkbox a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.checked = !this.checked;
|
||||
this.indeterminate = false;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true })
|
||||
handleStateChange() {
|
||||
this.input.checked = this.checked; // force a sync update
|
||||
this.input.indeterminate = this.indeterminate; // force a sync update
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Simulates a click on the checkbox. */
|
||||
click() {
|
||||
this.input.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the checkbox. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the checkbox. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom validation message. The value provided will be shown to the user when the form is submitted. To clear
|
||||
* the custom validation message, call this method with an empty string.
|
||||
*/
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
//
|
||||
// NOTE: we use a <div> around the label slot because of this Chrome bug.
|
||||
//
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
|
||||
//
|
||||
return html`
|
||||
<label
|
||||
part="base"
|
||||
class=${classMap({
|
||||
checkbox: true,
|
||||
'checkbox--checked': this.checked,
|
||||
'checkbox--disabled': this.disabled,
|
||||
'checkbox--focused': this.hasFocus,
|
||||
'checkbox--indeterminate': this.indeterminate,
|
||||
'checkbox--small': this.size === 'small',
|
||||
'checkbox--medium': this.size === 'medium',
|
||||
'checkbox--large': this.size === 'large'
|
||||
})}
|
||||
>
|
||||
<input
|
||||
class="checkbox__input"
|
||||
type="checkbox"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${this.name}
|
||||
value=${ifDefined(this.value)}
|
||||
.indeterminate=${live(this.indeterminate)}
|
||||
.checked=${live(this.checked)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
|
||||
<span
|
||||
part="control${this.checked ? ' control--checked' : ''}${this.indeterminate ? ' control--indeterminate' : ''}"
|
||||
class="checkbox__control"
|
||||
>
|
||||
${this.checked
|
||||
? html`
|
||||
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
${!this.checked && this.indeterminate
|
||||
? html`
|
||||
<sl-icon
|
||||
part="indeterminate-icon"
|
||||
class="checkbox__indeterminate-icon"
|
||||
library="system"
|
||||
name="indeterminate"
|
||||
></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<div part="label" class="checkbox__label">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-checkbox': SlCheckbox;
|
||||
}
|
||||
}
|
||||
@@ -1,251 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './checkbox.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Checkboxes allow the user to toggle an option on or off.
|
||||
* @documentation https://shoelace.style/components/checkbox
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The checkbox's label.
|
||||
*
|
||||
* @event sl-blur - Emitted when the checkbox loses focus.
|
||||
* @event sl-change - Emitted when the checked state changes.
|
||||
* @event sl-focus - Emitted when the checkbox gains focus.
|
||||
* @event sl-input - Emitted when the checkbox receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The square container that wraps the checkbox's checked state.
|
||||
* @csspart control--checked - Matches the control part when the checkbox is checked.
|
||||
* @csspart control--indeterminate - Matches the control part when the checkbox is indeterminate.
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart indeterminate-icon - The indeterminate icon, an `<sl-icon>` element.
|
||||
* @csspart label - The container that wraps the checkbox's label.
|
||||
*/
|
||||
@customElement('sl-checkbox')
|
||||
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
|
||||
defaultValue: (control: SlCheckbox) => control.defaultChecked,
|
||||
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
|
||||
});
|
||||
|
||||
@query('input[type="checkbox"]') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The name of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() value: string;
|
||||
|
||||
/** The checkbox's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Disables the checkbox. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the checkbox in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/**
|
||||
* Draws the checkbox in an indeterminate state. This is usually applied to checkboxes that represents a "select
|
||||
* all/none" behavior when associated checkboxes have a mix of checked and unchecked states.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) indeterminate = false;
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue('checked') defaultChecked = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the checkbox a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.checked = !this.checked;
|
||||
this.indeterminate = false;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true })
|
||||
handleStateChange() {
|
||||
this.input.checked = this.checked; // force a sync update
|
||||
this.input.indeterminate = this.indeterminate; // force a sync update
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Simulates a click on the checkbox. */
|
||||
click() {
|
||||
this.input.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the checkbox. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the checkbox. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom validation message. The value provided will be shown to the user when the form is submitted. To clear
|
||||
* the custom validation message, call this method with an empty string.
|
||||
*/
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
//
|
||||
// NOTE: we use a <div> around the label slot because of this Chrome bug.
|
||||
//
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
|
||||
//
|
||||
return html`
|
||||
<label
|
||||
part="base"
|
||||
class=${classMap({
|
||||
checkbox: true,
|
||||
'checkbox--checked': this.checked,
|
||||
'checkbox--disabled': this.disabled,
|
||||
'checkbox--focused': this.hasFocus,
|
||||
'checkbox--indeterminate': this.indeterminate,
|
||||
'checkbox--small': this.size === 'small',
|
||||
'checkbox--medium': this.size === 'medium',
|
||||
'checkbox--large': this.size === 'large'
|
||||
})}
|
||||
>
|
||||
<input
|
||||
class="checkbox__input"
|
||||
type="checkbox"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${this.name}
|
||||
value=${ifDefined(this.value)}
|
||||
.indeterminate=${live(this.indeterminate)}
|
||||
.checked=${live(this.checked)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
|
||||
<span
|
||||
part="control${this.checked ? ' control--checked' : ''}${this.indeterminate ? ' control--indeterminate' : ''}"
|
||||
class="checkbox__control"
|
||||
>
|
||||
${this.checked
|
||||
? html`
|
||||
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
${!this.checked && this.indeterminate
|
||||
? html`
|
||||
<sl-icon
|
||||
part="indeterminate-icon"
|
||||
class="checkbox__indeterminate-icon"
|
||||
library="system"
|
||||
name="indeterminate"
|
||||
></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<div part="label" class="checkbox__label">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-checkbox': SlCheckbox;
|
||||
}
|
||||
}
|
||||
import SlCheckbox from './checkbox.component.js';
|
||||
export * from './checkbox.component.js';
|
||||
export default SlCheckbox;
|
||||
SlCheckbox.define('sl-checkbox');
|
||||
|
||||
1073
src/components/color-picker/color-picker.component.ts
Normal file
1073
src/components/color-picker/color-picker.component.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
228
src/components/details/details.component.ts
Normal file
228
src/components/details/details.component.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './details.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Details show a brief summary and expand to show additional content.
|
||||
* @documentation https://shoelace.style/components/details
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The details' main content.
|
||||
* @slot summary - The details' summary. Alternatively, you can use the `summary` attribute.
|
||||
* @slot expand-icon - Optional expand icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the details opens.
|
||||
* @event sl-after-show - Emitted after the details opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the details closes.
|
||||
* @event sl-after-hide - Emitted after the details closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart header - The header that wraps both the summary and the expand/collapse icon.
|
||||
* @csspart summary - The container that wraps the summary.
|
||||
* @csspart summary-icon - The container that wraps the expand/collapse icons.
|
||||
* @csspart content - The details content.
|
||||
*
|
||||
* @animation details.show - The animation to use when showing details. You can use `height: auto` with this animation.
|
||||
* @animation details.hide - The animation to use when hiding details. You can use `height: auto` with this animation.
|
||||
*/
|
||||
export default class SlDetails extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon
|
||||
};
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.details') details: HTMLElement;
|
||||
@query('.details__header') header: HTMLElement;
|
||||
@query('.details__body') body: HTMLElement;
|
||||
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** The summary to show in the header. If you need to display HTML, use the `summary` slot instead. */
|
||||
@property() summary: string;
|
||||
|
||||
/** Disables the details so it can't be toggled. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.body.hidden = !this.open;
|
||||
this.body.style.height = this.open ? 'auto' : '0';
|
||||
}
|
||||
|
||||
private handleSummaryClick() {
|
||||
if (!this.disabled) {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
|
||||
this.header.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleSummaryKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
const slShow = this.emit('sl-show', { cancelable: true });
|
||||
if (slShow.defaultPrevented) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
this.body.hidden = false;
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
const slHide = this.emit('sl-hide', { cancelable: true });
|
||||
if (slHide.defaultPrevented) {
|
||||
this.open = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.hidden = true;
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the details. */
|
||||
async show() {
|
||||
if (this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the details */
|
||||
async hide() {
|
||||
if (!this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
details: true,
|
||||
'details--open': this.open,
|
||||
'details--disabled': this.disabled,
|
||||
'details--rtl': isRtl
|
||||
})}
|
||||
>
|
||||
<div
|
||||
part="header"
|
||||
id="header"
|
||||
class="details__header"
|
||||
role="button"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-controls="content"
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@click=${this.handleSummaryClick}
|
||||
@keydown=${this.handleSummaryKeyDown}
|
||||
>
|
||||
<slot name="summary" part="summary" class="details__summary">${this.summary}</slot>
|
||||
|
||||
<span part="summary-icon" class="details__summary-icon">
|
||||
<slot name="expand-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
<slot name="collapse-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="details__body" role="region" aria-labelledby="header">
|
||||
<slot part="content" id="content" class="details__content"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('details.show', {
|
||||
keyframes: [
|
||||
{ height: '0', opacity: '0' },
|
||||
{ height: 'auto', opacity: '1' }
|
||||
],
|
||||
options: { duration: 250, easing: 'linear' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('details.hide', {
|
||||
keyframes: [
|
||||
{ height: 'auto', opacity: '1' },
|
||||
{ height: '0', opacity: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'linear' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-details': SlDetails;
|
||||
}
|
||||
}
|
||||
@@ -1,225 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './details.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Details show a brief summary and expand to show additional content.
|
||||
* @documentation https://shoelace.style/components/details
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The details' main content.
|
||||
* @slot summary - The details' summary. Alternatively, you can use the `summary` attribute.
|
||||
* @slot expand-icon - Optional expand icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the details opens.
|
||||
* @event sl-after-show - Emitted after the details opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the details closes.
|
||||
* @event sl-after-hide - Emitted after the details closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart header - The header that wraps both the summary and the expand/collapse icon.
|
||||
* @csspart summary - The container that wraps the summary.
|
||||
* @csspart summary-icon - The container that wraps the expand/collapse icons.
|
||||
* @csspart content - The details content.
|
||||
*
|
||||
* @animation details.show - The animation to use when showing details. You can use `height: auto` with this animation.
|
||||
* @animation details.hide - The animation to use when hiding details. You can use `height: auto` with this animation.
|
||||
*/
|
||||
@customElement('sl-details')
|
||||
export default class SlDetails extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.details') details: HTMLElement;
|
||||
@query('.details__header') header: HTMLElement;
|
||||
@query('.details__body') body: HTMLElement;
|
||||
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** The summary to show in the header. If you need to display HTML, use the `summary` slot instead. */
|
||||
@property() summary: string;
|
||||
|
||||
/** Disables the details so it can't be toggled. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.body.hidden = !this.open;
|
||||
this.body.style.height = this.open ? 'auto' : '0';
|
||||
}
|
||||
|
||||
private handleSummaryClick() {
|
||||
if (!this.disabled) {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
|
||||
this.header.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleSummaryKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
const slShow = this.emit('sl-show', { cancelable: true });
|
||||
if (slShow.defaultPrevented) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
this.body.hidden = false;
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
const slHide = this.emit('sl-hide', { cancelable: true });
|
||||
if (slHide.defaultPrevented) {
|
||||
this.open = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.hidden = true;
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the details. */
|
||||
async show() {
|
||||
if (this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the details */
|
||||
async hide() {
|
||||
if (!this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
details: true,
|
||||
'details--open': this.open,
|
||||
'details--disabled': this.disabled,
|
||||
'details--rtl': isRtl
|
||||
})}
|
||||
>
|
||||
<div
|
||||
part="header"
|
||||
id="header"
|
||||
class="details__header"
|
||||
role="button"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-controls="content"
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@click=${this.handleSummaryClick}
|
||||
@keydown=${this.handleSummaryKeyDown}
|
||||
>
|
||||
<slot name="summary" part="summary" class="details__summary">${this.summary}</slot>
|
||||
|
||||
<span part="summary-icon" class="details__summary-icon">
|
||||
<slot name="expand-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
<slot name="collapse-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="details__body" role="region" aria-labelledby="header">
|
||||
<slot part="content" id="content" class="details__content"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('details.show', {
|
||||
keyframes: [
|
||||
{ height: '0', opacity: '0' },
|
||||
{ height: 'auto', opacity: '1' }
|
||||
],
|
||||
options: { duration: 250, easing: 'linear' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('details.hide', {
|
||||
keyframes: [
|
||||
{ height: 'auto', opacity: '1' },
|
||||
{ height: '0', opacity: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'linear' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-details': SlDetails;
|
||||
}
|
||||
}
|
||||
import SlDetails from './details.component.js';
|
||||
export * from './details.component.js';
|
||||
export default SlDetails;
|
||||
SlDetails.define('sl-details');
|
||||
|
||||
347
src/components/dialog/dialog.component.ts
Normal file
347
src/components/dialog/dialog.component.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
import styles from './dialog.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Dialogs, sometimes called "modals", appear above the page and require the user's immediate attention.
|
||||
* @documentation https://shoelace.style/components/dialog
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The dialog's main content.
|
||||
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the dialog opens.
|
||||
* @event sl-after-show - Emitted after the dialog opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dialog closes.
|
||||
* @event sl-after-hide - Emitted after the dialog closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the dialog opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the dialog by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the dialog open. Avoid using this unless closing the dialog will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the dialog.
|
||||
* @csspart panel - The dialog's panel (where the dialog and its content are rendered).
|
||||
* @csspart header - The dialog's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The dialog's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The dialog's body.
|
||||
* @csspart footer - The dialog's footer.
|
||||
*
|
||||
* @cssproperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --footer-spacing - The amount of padding to use for the footer.
|
||||
*
|
||||
* @animation dialog.show - The animation to use when showing the dialog.
|
||||
* @animation dialog.hide - The animation to use when hiding the dialog.
|
||||
* @animation dialog.denyClose - The animation to use when a request to close the dialog is denied.
|
||||
* @animation dialog.overlay.show - The animation to use when showing the dialog's overlay.
|
||||
* @animation dialog.overlay.hide - The animation to use when hiding the dialog's overlay.
|
||||
*/
|
||||
export default class SlDialog extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = {
|
||||
'sl-icon-button': SlIconButton
|
||||
};
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.dialog') dialog: HTMLElement;
|
||||
@query('.dialog__panel') panel: HTMLElement;
|
||||
@query('.dialog__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The dialog's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/**
|
||||
* Disables the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the dialog.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.dialog.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'dialog.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
|
||||
event.stopPropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
this.modal.activate();
|
||||
|
||||
lockBodyScrolling(this);
|
||||
|
||||
// When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause
|
||||
// the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call
|
||||
// `focus({ preventScroll: true })` ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
this.dialog.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
this.modal.deactivate();
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.dialog.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
unlockBodyScrolling(this);
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dialog. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dialog */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
dialog: true,
|
||||
'dialog--open': this.open,
|
||||
'dialog--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="dialog__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="dialog__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="-1"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="dialog__header">
|
||||
<h2 part="title" class="dialog__title" id="title">
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="dialog__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="dialog__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click="${() => this.requestClose('close-button')}"
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
${
|
||||
'' /* The tabindex="-1" is here because the body is technically scrollable if overflowing. However, if there's no focusable elements inside, you won't actually be able to scroll it via keyboard. */
|
||||
}
|
||||
<slot part="body" class="dialog__body" tabindex="-1"></slot>
|
||||
|
||||
<footer part="footer" class="dialog__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dialog.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.02 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.overlay.show', {
|
||||
keyframes: [{ opacity: 0 }, { opacity: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.overlay.hide', {
|
||||
keyframes: [{ opacity: 1 }, { opacity: 0 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-dialog': SlDialog;
|
||||
}
|
||||
}
|
||||
@@ -1,345 +1,4 @@
|
||||
import '../icon-button/icon-button.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './dialog.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Dialogs, sometimes called "modals", appear above the page and require the user's immediate attention.
|
||||
* @documentation https://shoelace.style/components/dialog
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The dialog's main content.
|
||||
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the dialog opens.
|
||||
* @event sl-after-show - Emitted after the dialog opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dialog closes.
|
||||
* @event sl-after-hide - Emitted after the dialog closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the dialog opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the dialog by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the dialog open. Avoid using this unless closing the dialog will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the dialog.
|
||||
* @csspart panel - The dialog's panel (where the dialog and its content are rendered).
|
||||
* @csspart header - The dialog's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The dialog's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The dialog's body.
|
||||
* @csspart footer - The dialog's footer.
|
||||
*
|
||||
* @cssproperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --footer-spacing - The amount of padding to use for the footer.
|
||||
*
|
||||
* @animation dialog.show - The animation to use when showing the dialog.
|
||||
* @animation dialog.hide - The animation to use when hiding the dialog.
|
||||
* @animation dialog.denyClose - The animation to use when a request to close the dialog is denied.
|
||||
* @animation dialog.overlay.show - The animation to use when showing the dialog's overlay.
|
||||
* @animation dialog.overlay.hide - The animation to use when hiding the dialog's overlay.
|
||||
*/
|
||||
@customElement('sl-dialog')
|
||||
export default class SlDialog extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.dialog') dialog: HTMLElement;
|
||||
@query('.dialog__panel') panel: HTMLElement;
|
||||
@query('.dialog__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The dialog's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/**
|
||||
* Disables the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the dialog.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.dialog.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'dialog.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
|
||||
event.stopPropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
this.modal.activate();
|
||||
|
||||
lockBodyScrolling(this);
|
||||
|
||||
// When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause
|
||||
// the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call
|
||||
// `focus({ preventScroll: true })` ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
this.dialog.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
this.modal.deactivate();
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.dialog.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
unlockBodyScrolling(this);
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dialog. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dialog */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
dialog: true,
|
||||
'dialog--open': this.open,
|
||||
'dialog--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="dialog__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="dialog__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="-1"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="dialog__header">
|
||||
<h2 part="title" class="dialog__title" id="title">
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="dialog__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="dialog__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click="${() => this.requestClose('close-button')}"
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
${
|
||||
'' /* The tabindex="-1" is here because the body is technically scrollable if overflowing. However, if there's no focusable elements inside, you won't actually be able to scroll it via keyboard. */
|
||||
}
|
||||
<slot part="body" class="dialog__body" tabindex="-1"></slot>
|
||||
|
||||
<footer part="footer" class="dialog__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dialog.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.02 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.overlay.show', {
|
||||
keyframes: [{ opacity: 0 }, { opacity: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.overlay.hide', {
|
||||
keyframes: [{ opacity: 1 }, { opacity: 0 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-dialog': SlDialog;
|
||||
}
|
||||
}
|
||||
import SlDialog from './dialog.component.js';
|
||||
export * from './dialog.component.js';
|
||||
export default SlDialog;
|
||||
SlDialog.define('sl-dialog');
|
||||
|
||||
38
src/components/divider/divider.component.ts
Normal file
38
src/components/divider/divider.component.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './divider.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Dividers are used to visually separate or group elements.
|
||||
* @documentation https://shoelace.style/components/divider
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @cssproperty --color - The color of the divider.
|
||||
* @cssproperty --width - The width of the divider.
|
||||
* @cssproperty --spacing - The spacing of the divider.
|
||||
*/
|
||||
export default class SlDivider extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** Draws the divider in a vertical orientation. */
|
||||
@property({ type: Boolean, reflect: true }) vertical = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'separator');
|
||||
}
|
||||
|
||||
@watch('vertical')
|
||||
handleVerticalChange() {
|
||||
this.setAttribute('aria-orientation', this.vertical ? 'vertical' : 'horizontal');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-divider': SlDivider;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
467
src/components/drawer/drawer.component.ts
Normal file
467
src/components/drawer/drawer.component.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { uppercaseFirstLetter } from '../../internal/string.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
import styles from './drawer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Drawers slide in from a container to expose additional options and information.
|
||||
* @documentation https://shoelace.style/components/drawer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The drawer's main content.
|
||||
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the drawer opens.
|
||||
* @event sl-after-show - Emitted after the drawer opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the drawer closes.
|
||||
* @event sl-after-hide - Emitted after the drawer closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the drawer opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the drawer by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the drawer open. Avoid using this unless closing the drawer will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the drawer.
|
||||
* @csspart panel - The drawer's panel (where the drawer and its content are rendered).
|
||||
* @csspart header - The drawer's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The drawer's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The drawer's body.
|
||||
* @csspart footer - The drawer's footer.
|
||||
*
|
||||
* @cssproperty --size - The preferred size of the drawer. This will be applied to the drawer's width or height
|
||||
* depending on its `placement`. Note that the drawer will shrink to accommodate smaller screens.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --footer-spacing - The amount of padding to use for the footer.
|
||||
*
|
||||
* @animation drawer.showTop - The animation to use when showing a drawer with `top` placement.
|
||||
* @animation drawer.showEnd - The animation to use when showing a drawer with `end` placement.
|
||||
* @animation drawer.showBottom - The animation to use when showing a drawer with `bottom` placement.
|
||||
* @animation drawer.showStart - The animation to use when showing a drawer with `start` placement.
|
||||
* @animation drawer.hideTop - The animation to use when hiding a drawer with `top` placement.
|
||||
* @animation drawer.hideEnd - The animation to use when hiding a drawer with `end` placement.
|
||||
* @animation drawer.hideBottom - The animation to use when hiding a drawer with `bottom` placement.
|
||||
* @animation drawer.hideStart - The animation to use when hiding a drawer with `start` placement.
|
||||
* @animation drawer.denyClose - The animation to use when a request to close the drawer is denied.
|
||||
* @animation drawer.overlay.show - The animation to use when showing the drawer's overlay.
|
||||
* @animation drawer.overlay.hide - The animation to use when hiding the drawer's overlay.
|
||||
*/
|
||||
export default class SlDrawer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon-button': SlIconButton };
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.drawer') drawer: HTMLElement;
|
||||
@query('.drawer__panel') panel: HTMLElement;
|
||||
@query('.drawer__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The drawer's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/** The direction from which the drawer will open. */
|
||||
@property({ reflect: true }) placement: 'top' | 'end' | 'bottom' | 'start' = 'end';
|
||||
|
||||
/**
|
||||
* By default, the drawer slides out of its containing block (usually the viewport). To make the drawer slide out of
|
||||
* its parent element, set this attribute and add `position: relative` to the parent.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) contained = false;
|
||||
|
||||
/**
|
||||
* Removes the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the drawer.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.drawer.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'drawer.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Contained drawers aren't modal and don't response to the escape key
|
||||
if (this.contained) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
|
||||
event.stopImmediatePropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
|
||||
// Lock body scrolling only if the drawer isn't contained
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
// When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the
|
||||
// drawer's animation to jitter, so we'll temporarily remove the attribute, call `focus({ preventScroll: true })`
|
||||
// ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
this.drawer.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, `drawer.show${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.drawer.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('contained', { waitUntilFirstUpdate: true })
|
||||
handleNoModalChange() {
|
||||
if (this.open && !this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
if (this.open && this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the drawer. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the drawer */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
drawer: true,
|
||||
'drawer--open': this.open,
|
||||
'drawer--top': this.placement === 'top',
|
||||
'drawer--end': this.placement === 'end',
|
||||
'drawer--bottom': this.placement === 'bottom',
|
||||
'drawer--start': this.placement === 'start',
|
||||
'drawer--contained': this.contained,
|
||||
'drawer--fixed': !this.contained,
|
||||
'drawer--rtl': this.localize.dir() === 'rtl',
|
||||
'drawer--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="drawer__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="drawer__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="0"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="drawer__header">
|
||||
<h2 part="title" class="drawer__title" id="title">
|
||||
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="drawer__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="drawer__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click=${() => this.requestClose('close-button')}
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<slot part="body" class="drawer__body"></slot>
|
||||
|
||||
<footer part="footer" class="drawer__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Top
|
||||
setDefaultAnimation('drawer.showTop', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 -100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideTop', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 -100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// End
|
||||
setDefaultAnimation('drawer.showEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Bottom
|
||||
setDefaultAnimation('drawer.showBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Start
|
||||
setDefaultAnimation('drawer.showStart', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideStart', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Deny close
|
||||
setDefaultAnimation('drawer.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.01 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
// Overlay
|
||||
setDefaultAnimation('drawer.overlay.show', {
|
||||
keyframes: [{ opacity: 0 }, { opacity: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.overlay.hide', {
|
||||
keyframes: [{ opacity: 1 }, { opacity: 0 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-drawer': SlDrawer;
|
||||
}
|
||||
}
|
||||
@@ -1,467 +1,4 @@
|
||||
import '../icon-button/icon-button.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { uppercaseFirstLetter } from '../../internal/string.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './drawer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Drawers slide in from a container to expose additional options and information.
|
||||
* @documentation https://shoelace.style/components/drawer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The drawer's main content.
|
||||
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the drawer opens.
|
||||
* @event sl-after-show - Emitted after the drawer opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the drawer closes.
|
||||
* @event sl-after-hide - Emitted after the drawer closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the drawer opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the drawer by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the drawer open. Avoid using this unless closing the drawer will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the drawer.
|
||||
* @csspart panel - The drawer's panel (where the drawer and its content are rendered).
|
||||
* @csspart header - The drawer's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The drawer's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The drawer's body.
|
||||
* @csspart footer - The drawer's footer.
|
||||
*
|
||||
* @cssproperty --size - The preferred size of the drawer. This will be applied to the drawer's width or height
|
||||
* depending on its `placement`. Note that the drawer will shrink to accommodate smaller screens.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --footer-spacing - The amount of padding to use for the footer.
|
||||
*
|
||||
* @animation drawer.showTop - The animation to use when showing a drawer with `top` placement.
|
||||
* @animation drawer.showEnd - The animation to use when showing a drawer with `end` placement.
|
||||
* @animation drawer.showBottom - The animation to use when showing a drawer with `bottom` placement.
|
||||
* @animation drawer.showStart - The animation to use when showing a drawer with `start` placement.
|
||||
* @animation drawer.hideTop - The animation to use when hiding a drawer with `top` placement.
|
||||
* @animation drawer.hideEnd - The animation to use when hiding a drawer with `end` placement.
|
||||
* @animation drawer.hideBottom - The animation to use when hiding a drawer with `bottom` placement.
|
||||
* @animation drawer.hideStart - The animation to use when hiding a drawer with `start` placement.
|
||||
* @animation drawer.denyClose - The animation to use when a request to close the drawer is denied.
|
||||
* @animation drawer.overlay.show - The animation to use when showing the drawer's overlay.
|
||||
* @animation drawer.overlay.hide - The animation to use when hiding the drawer's overlay.
|
||||
*/
|
||||
@customElement('sl-drawer')
|
||||
export default class SlDrawer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.drawer') drawer: HTMLElement;
|
||||
@query('.drawer__panel') panel: HTMLElement;
|
||||
@query('.drawer__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The drawer's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/** The direction from which the drawer will open. */
|
||||
@property({ reflect: true }) placement: 'top' | 'end' | 'bottom' | 'start' = 'end';
|
||||
|
||||
/**
|
||||
* By default, the drawer slides out of its containing block (usually the viewport). To make the drawer slide out of
|
||||
* its parent element, set this attribute and add `position: relative` to the parent.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) contained = false;
|
||||
|
||||
/**
|
||||
* Removes the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the drawer.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.drawer.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'drawer.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Contained drawers aren't modal and don't response to the escape key
|
||||
if (this.contained) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
|
||||
event.stopImmediatePropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
|
||||
// Lock body scrolling only if the drawer isn't contained
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
// When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the
|
||||
// drawer's animation to jitter, so we'll temporarily remove the attribute, call `focus({ preventScroll: true })`
|
||||
// ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
this.drawer.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, `drawer.show${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.drawer.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('contained', { waitUntilFirstUpdate: true })
|
||||
handleNoModalChange() {
|
||||
if (this.open && !this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
if (this.open && this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the drawer. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the drawer */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
drawer: true,
|
||||
'drawer--open': this.open,
|
||||
'drawer--top': this.placement === 'top',
|
||||
'drawer--end': this.placement === 'end',
|
||||
'drawer--bottom': this.placement === 'bottom',
|
||||
'drawer--start': this.placement === 'start',
|
||||
'drawer--contained': this.contained,
|
||||
'drawer--fixed': !this.contained,
|
||||
'drawer--rtl': this.localize.dir() === 'rtl',
|
||||
'drawer--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="drawer__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="drawer__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="0"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="drawer__header">
|
||||
<h2 part="title" class="drawer__title" id="title">
|
||||
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="drawer__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="drawer__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click=${() => this.requestClose('close-button')}
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<slot part="body" class="drawer__body"></slot>
|
||||
|
||||
<footer part="footer" class="drawer__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Top
|
||||
setDefaultAnimation('drawer.showTop', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 -100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideTop', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 -100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// End
|
||||
setDefaultAnimation('drawer.showEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Bottom
|
||||
setDefaultAnimation('drawer.showBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Start
|
||||
setDefaultAnimation('drawer.showStart', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideStart', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Deny close
|
||||
setDefaultAnimation('drawer.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.01 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
// Overlay
|
||||
setDefaultAnimation('drawer.overlay.show', {
|
||||
keyframes: [{ opacity: 0 }, { opacity: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.overlay.hide', {
|
||||
keyframes: [{ opacity: 1 }, { opacity: 0 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-drawer': SlDrawer;
|
||||
}
|
||||
}
|
||||
import SlDrawer from './drawer.component.js';
|
||||
export * from './drawer.component.js';
|
||||
export default SlDrawer;
|
||||
SlDrawer.define('sl-drawer');
|
||||
|
||||
441
src/components/dropdown/dropdown.component.ts
Normal file
441
src/components/dropdown/dropdown.component.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { getTabbableBoundary } from '../../internal/tabbable.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlPopup from '../popup/popup.component.js';
|
||||
import styles from './dropdown.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlButton from '../button/button.js';
|
||||
import type SlIconButton from '../icon-button/icon-button.js';
|
||||
import type SlMenu from '../menu/menu.js';
|
||||
import type SlSelectEvent from '../../events/sl-select.js';
|
||||
|
||||
/**
|
||||
* @summary Dropdowns expose additional content that "drops down" in a panel.
|
||||
* @documentation https://shoelace.style/components/dropdown
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-popup
|
||||
*
|
||||
* @slot - The dropdown's main content.
|
||||
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
|
||||
*
|
||||
* @event sl-show - Emitted when the dropdown opens.
|
||||
* @event sl-after-show - Emitted after the dropdown opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dropdown closes.
|
||||
* @event sl-after-hide - Emitted after the dropdown closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart trigger - The container that wraps the trigger.
|
||||
* @csspart panel - The panel that gets shown when the dropdown is open.
|
||||
*
|
||||
* @animation dropdown.show - The animation to use when showing the dropdown.
|
||||
* @animation dropdown.hide - The animation to use when hiding the dropdown.
|
||||
*/
|
||||
export default class SlDropdown extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-popup': SlPopup };
|
||||
|
||||
@query('.dropdown') popup: SlPopup;
|
||||
@query('.dropdown__trigger') trigger: HTMLSlotElement;
|
||||
@query('.dropdown__panel') panel: HTMLSlotElement;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
|
||||
* inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end' = 'bottom-start';
|
||||
|
||||
/** Disables the dropdown so the panel will not open. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
|
||||
* dropdowns that allow for multiple interactions.
|
||||
*/
|
||||
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
|
||||
|
||||
/**
|
||||
* The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
|
||||
* components that use a dropdown internally.
|
||||
*/
|
||||
@property({ attribute: false }) containingElement?: HTMLElement;
|
||||
|
||||
/** The distance in pixels from which to offset the panel away from its trigger. */
|
||||
@property({ type: Number }) distance = 0;
|
||||
|
||||
/** The distance in pixels from which to offset the panel along its trigger. */
|
||||
@property({ type: Number }) skidding = 0;
|
||||
|
||||
/**
|
||||
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
||||
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
|
||||
*/
|
||||
@property({ type: Boolean }) hoist = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.containingElement) {
|
||||
this.containingElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.panel.hidden = !this.open;
|
||||
|
||||
// If the dropdown is visible on init, update its position
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.popup.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeOpenListeners();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
focusOnTrigger() {
|
||||
const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
trigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
|
||||
| SlMenu
|
||||
| undefined;
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation
|
||||
// in case any ancestors are also listening for this key.
|
||||
if (this.open && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.stopPropagation();
|
||||
this.focusOnTrigger();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tabbing
|
||||
if (event.key === 'Tab') {
|
||||
// Tabbing within an open menu should close the dropdown and refocus the trigger
|
||||
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tabbing outside of the containing element closes the panel
|
||||
//
|
||||
// If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
|
||||
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
|
||||
setTimeout(() => {
|
||||
const activeElement =
|
||||
this.containingElement?.getRootNode() instanceof ShadowRoot
|
||||
? document.activeElement?.shadowRoot?.activeElement
|
||||
: document.activeElement;
|
||||
|
||||
if (
|
||||
!this.containingElement ||
|
||||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
|
||||
) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentMouseDown = (event: MouseEvent) => {
|
||||
// Close when clicking outside of the containing element
|
||||
const path = event.composedPath();
|
||||
if (this.containingElement && !path.includes(this.containingElement)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handlePanelSelect = (event: SlSelectEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Hide the dropdown when a menu item is selected
|
||||
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
handleTriggerClick() {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyDown(event: KeyboardEvent) {
|
||||
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
|
||||
// key again to hide the menu in case they don't want to make a selection.
|
||||
if ([' ', 'Enter'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
this.handleTriggerClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = this.getMenu();
|
||||
|
||||
if (menu) {
|
||||
const menuItems = menu.getAllItems();
|
||||
const firstMenuItem = menuItems[0];
|
||||
const lastMenuItem = menuItems[menuItems.length - 1];
|
||||
|
||||
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
|
||||
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
|
||||
// faster navigation.
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
|
||||
// Show the menu if it's not already open
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
// Focus on the first/last menu item after showing
|
||||
this.updateComplete.then(() => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Home') {
|
||||
menu.setCurrentItem(firstMenuItem);
|
||||
firstMenuItem.focus();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'End') {
|
||||
menu.setCurrentItem(lastMenuItem);
|
||||
lastMenuItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyUp(event: KeyboardEvent) {
|
||||
// Prevent space from triggering a click event in Firefox
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerSlotChange() {
|
||||
this.updateAccessibleTrigger();
|
||||
}
|
||||
|
||||
//
|
||||
// Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
|
||||
// `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
|
||||
// that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
|
||||
// a child of the slotted element, or an element in the slotted element's shadow root.
|
||||
//
|
||||
// For example, the accessible trigger of an <sl-button> is a <button> located inside its shadow root.
|
||||
//
|
||||
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
|
||||
//
|
||||
updateAccessibleTrigger() {
|
||||
const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
|
||||
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
|
||||
let target: HTMLElement;
|
||||
|
||||
if (accessibleTrigger) {
|
||||
switch (accessibleTrigger.tagName.toLowerCase()) {
|
||||
// Shoelace buttons have to update the internal button so it's announced correctly by screen readers
|
||||
case 'sl-button':
|
||||
case 'sl-icon-button':
|
||||
target = (accessibleTrigger as SlButton | SlIconButton).button;
|
||||
break;
|
||||
|
||||
default:
|
||||
target = accessibleTrigger;
|
||||
}
|
||||
|
||||
target.setAttribute('aria-haspopup', 'true');
|
||||
target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dropdown panel. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dropdown panel */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
|
||||
* is activated.
|
||||
*/
|
||||
reposition() {
|
||||
this.popup.reposition();
|
||||
}
|
||||
|
||||
addOpenListeners() {
|
||||
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
removeOpenListeners() {
|
||||
if (this.panel) {
|
||||
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.disabled) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAccessibleTrigger();
|
||||
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
this.panel.hidden = false;
|
||||
this.popup.active = true;
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
this.panel.hidden = true;
|
||||
this.popup.active = false;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<sl-popup
|
||||
part="base"
|
||||
id="dropdown"
|
||||
placement=${this.placement}
|
||||
distance=${this.distance}
|
||||
skidding=${this.skidding}
|
||||
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
||||
flip
|
||||
shift
|
||||
auto-size="vertical"
|
||||
auto-size-padding="10"
|
||||
class=${classMap({
|
||||
dropdown: true,
|
||||
'dropdown--open': this.open
|
||||
})}
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
slot="anchor"
|
||||
part="trigger"
|
||||
class="dropdown__trigger"
|
||||
@click=${this.handleTriggerClick}
|
||||
@keydown=${this.handleTriggerKeyDown}
|
||||
@keyup=${this.handleTriggerKeyUp}
|
||||
@slotchange=${this.handleTriggerSlotChange}
|
||||
></slot>
|
||||
|
||||
<div aria-hidden=${this.open ? 'false' : 'true'} aria-labelledby="dropdown">
|
||||
<slot part="panel" class="dropdown__panel"></slot>
|
||||
</div>
|
||||
</sl-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dropdown.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.9 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dropdown.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.9 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-dropdown': SlDropdown;
|
||||
}
|
||||
}
|
||||
@@ -1,442 +1,4 @@
|
||||
import '../popup/popup.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { getTabbableBoundary } from '../../internal/tabbable.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './dropdown.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlButton from '../button/button.js';
|
||||
import type SlIconButton from '../icon-button/icon-button.js';
|
||||
import type SlMenu from '../menu/menu.js';
|
||||
import type SlPopup from '../popup/popup.js';
|
||||
import type SlSelectEvent from '../../events/sl-select.js';
|
||||
|
||||
/**
|
||||
* @summary Dropdowns expose additional content that "drops down" in a panel.
|
||||
* @documentation https://shoelace.style/components/dropdown
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-popup
|
||||
*
|
||||
* @slot - The dropdown's main content.
|
||||
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
|
||||
*
|
||||
* @event sl-show - Emitted when the dropdown opens.
|
||||
* @event sl-after-show - Emitted after the dropdown opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dropdown closes.
|
||||
* @event sl-after-hide - Emitted after the dropdown closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart trigger - The container that wraps the trigger.
|
||||
* @csspart panel - The panel that gets shown when the dropdown is open.
|
||||
*
|
||||
* @animation dropdown.show - The animation to use when showing the dropdown.
|
||||
* @animation dropdown.hide - The animation to use when hiding the dropdown.
|
||||
*/
|
||||
@customElement('sl-dropdown')
|
||||
export default class SlDropdown extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('.dropdown') popup: SlPopup;
|
||||
@query('.dropdown__trigger') trigger: HTMLSlotElement;
|
||||
@query('.dropdown__panel') panel: HTMLSlotElement;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
|
||||
* inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end' = 'bottom-start';
|
||||
|
||||
/** Disables the dropdown so the panel will not open. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
|
||||
* dropdowns that allow for multiple interactions.
|
||||
*/
|
||||
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
|
||||
|
||||
/**
|
||||
* The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
|
||||
* components that use a dropdown internally.
|
||||
*/
|
||||
@property({ attribute: false }) containingElement?: HTMLElement;
|
||||
|
||||
/** The distance in pixels from which to offset the panel away from its trigger. */
|
||||
@property({ type: Number }) distance = 0;
|
||||
|
||||
/** The distance in pixels from which to offset the panel along its trigger. */
|
||||
@property({ type: Number }) skidding = 0;
|
||||
|
||||
/**
|
||||
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
||||
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
|
||||
*/
|
||||
@property({ type: Boolean }) hoist = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.containingElement) {
|
||||
this.containingElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.panel.hidden = !this.open;
|
||||
|
||||
// If the dropdown is visible on init, update its position
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.popup.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeOpenListeners();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
focusOnTrigger() {
|
||||
const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
trigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
|
||||
| SlMenu
|
||||
| undefined;
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation
|
||||
// in case any ancestors are also listening for this key.
|
||||
if (this.open && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.stopPropagation();
|
||||
this.focusOnTrigger();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tabbing
|
||||
if (event.key === 'Tab') {
|
||||
// Tabbing within an open menu should close the dropdown and refocus the trigger
|
||||
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tabbing outside of the containing element closes the panel
|
||||
//
|
||||
// If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
|
||||
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
|
||||
setTimeout(() => {
|
||||
const activeElement =
|
||||
this.containingElement?.getRootNode() instanceof ShadowRoot
|
||||
? document.activeElement?.shadowRoot?.activeElement
|
||||
: document.activeElement;
|
||||
|
||||
if (
|
||||
!this.containingElement ||
|
||||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
|
||||
) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentMouseDown = (event: MouseEvent) => {
|
||||
// Close when clicking outside of the containing element
|
||||
const path = event.composedPath();
|
||||
if (this.containingElement && !path.includes(this.containingElement)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handlePanelSelect = (event: SlSelectEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Hide the dropdown when a menu item is selected
|
||||
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
handleTriggerClick() {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyDown(event: KeyboardEvent) {
|
||||
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
|
||||
// key again to hide the menu in case they don't want to make a selection.
|
||||
if ([' ', 'Enter'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
this.handleTriggerClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = this.getMenu();
|
||||
|
||||
if (menu) {
|
||||
const menuItems = menu.getAllItems();
|
||||
const firstMenuItem = menuItems[0];
|
||||
const lastMenuItem = menuItems[menuItems.length - 1];
|
||||
|
||||
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
|
||||
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
|
||||
// faster navigation.
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
|
||||
// Show the menu if it's not already open
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
// Focus on the first/last menu item after showing
|
||||
this.updateComplete.then(() => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Home') {
|
||||
menu.setCurrentItem(firstMenuItem);
|
||||
firstMenuItem.focus();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'End') {
|
||||
menu.setCurrentItem(lastMenuItem);
|
||||
lastMenuItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyUp(event: KeyboardEvent) {
|
||||
// Prevent space from triggering a click event in Firefox
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerSlotChange() {
|
||||
this.updateAccessibleTrigger();
|
||||
}
|
||||
|
||||
//
|
||||
// Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
|
||||
// `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
|
||||
// that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
|
||||
// a child of the slotted element, or an element in the slotted element's shadow root.
|
||||
//
|
||||
// For example, the accessible trigger of an <sl-button> is a <button> located inside its shadow root.
|
||||
//
|
||||
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
|
||||
//
|
||||
updateAccessibleTrigger() {
|
||||
const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
|
||||
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
|
||||
let target: HTMLElement;
|
||||
|
||||
if (accessibleTrigger) {
|
||||
switch (accessibleTrigger.tagName.toLowerCase()) {
|
||||
// Shoelace buttons have to update the internal button so it's announced correctly by screen readers
|
||||
case 'sl-button':
|
||||
case 'sl-icon-button':
|
||||
target = (accessibleTrigger as SlButton | SlIconButton).button;
|
||||
break;
|
||||
|
||||
default:
|
||||
target = accessibleTrigger;
|
||||
}
|
||||
|
||||
target.setAttribute('aria-haspopup', 'true');
|
||||
target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dropdown panel. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dropdown panel */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
|
||||
* is activated.
|
||||
*/
|
||||
reposition() {
|
||||
this.popup.reposition();
|
||||
}
|
||||
|
||||
addOpenListeners() {
|
||||
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
removeOpenListeners() {
|
||||
if (this.panel) {
|
||||
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.disabled) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAccessibleTrigger();
|
||||
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
this.panel.hidden = false;
|
||||
this.popup.active = true;
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
this.panel.hidden = true;
|
||||
this.popup.active = false;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<sl-popup
|
||||
part="base"
|
||||
id="dropdown"
|
||||
placement=${this.placement}
|
||||
distance=${this.distance}
|
||||
skidding=${this.skidding}
|
||||
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
||||
flip
|
||||
shift
|
||||
auto-size="vertical"
|
||||
auto-size-padding="10"
|
||||
class=${classMap({
|
||||
dropdown: true,
|
||||
'dropdown--open': this.open
|
||||
})}
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
slot="anchor"
|
||||
part="trigger"
|
||||
class="dropdown__trigger"
|
||||
@click=${this.handleTriggerClick}
|
||||
@keydown=${this.handleTriggerKeyDown}
|
||||
@keyup=${this.handleTriggerKeyUp}
|
||||
@slotchange=${this.handleTriggerSlotChange}
|
||||
></slot>
|
||||
|
||||
<div aria-hidden=${this.open ? 'false' : 'true'} aria-labelledby="dropdown">
|
||||
<slot part="panel" class="dropdown__panel"></slot>
|
||||
</div>
|
||||
</sl-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dropdown.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.9 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dropdown.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.9 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-dropdown': SlDropdown;
|
||||
}
|
||||
}
|
||||
import SlDropdown from './dropdown.component.js';
|
||||
export * from './dropdown.component.js';
|
||||
export default SlDropdown;
|
||||
SlDropdown.define('sl-dropdown');
|
||||
|
||||
47
src/components/format-bytes/format-bytes.component.ts
Normal file
47
src/components/format-bytes/format-bytes.component.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number as a human readable bytes value.
|
||||
* @documentation https://shoelace.style/components/format-bytes
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
export default class SlFormatBytes extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format in bytes. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The type of unit to display. */
|
||||
@property() unit: 'byte' | 'bit' = 'byte';
|
||||
|
||||
/** Determines how to display the result, e.g. "100 bytes", "100 b", or "100b". */
|
||||
@property() display: 'long' | 'short' | 'narrow' = 'short';
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bitPrefixes = ['', 'kilo', 'mega', 'giga', 'tera']; // petabit isn't a supported unit
|
||||
const bytePrefixes = ['', 'kilo', 'mega', 'giga', 'tera', 'peta'];
|
||||
const prefix = this.unit === 'bit' ? bitPrefixes : bytePrefixes;
|
||||
const index = Math.max(0, Math.min(Math.floor(Math.log10(this.value) / 3), prefix.length - 1));
|
||||
const unit = prefix[index] + this.unit;
|
||||
const valueToFormat = parseFloat((this.value / Math.pow(1000, index)).toPrecision(3));
|
||||
|
||||
return this.localize.number(valueToFormat, {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: this.display
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-bytes': SlFormatBytes;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number as a human readable bytes value.
|
||||
* @documentation https://shoelace.style/components/format-bytes
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
@customElement('sl-format-bytes')
|
||||
export default class SlFormatBytes extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format in bytes. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The type of unit to display. */
|
||||
@property() unit: 'byte' | 'bit' = 'byte';
|
||||
|
||||
/** Determines how to display the result, e.g. "100 bytes", "100 b", or "100b". */
|
||||
@property() display: 'long' | 'short' | 'narrow' = 'short';
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bitPrefixes = ['', 'kilo', 'mega', 'giga', 'tera']; // petabit isn't a supported unit
|
||||
const bytePrefixes = ['', 'kilo', 'mega', 'giga', 'tera', 'peta'];
|
||||
const prefix = this.unit === 'bit' ? bitPrefixes : bytePrefixes;
|
||||
const index = Math.max(0, Math.min(Math.floor(Math.log10(this.value) / 3), prefix.length - 1));
|
||||
const unit = prefix[index] + this.unit;
|
||||
const valueToFormat = parseFloat((this.value / Math.pow(1000, index)).toPrecision(3));
|
||||
|
||||
return this.localize.number(valueToFormat, {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: this.display
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-bytes': SlFormatBytes;
|
||||
}
|
||||
}
|
||||
import SlFormatBytes from './format-bytes.component.js';
|
||||
export * from './format-bytes.component.js';
|
||||
export default SlFormatBytes;
|
||||
SlFormatBytes.define('sl-format-bytes');
|
||||
|
||||
88
src/components/format-date/format-date.component.ts
Normal file
88
src/components/format-date/format-date.component.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a date/time using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-date
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
export default class SlFormatDate extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* The date/time to format. If not set, the current date and time will be used. When passing a string, it's strongly
|
||||
* recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert a date to this format
|
||||
* in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
|
||||
*/
|
||||
@property() date: Date | string = new Date();
|
||||
|
||||
/** The format for displaying the weekday. */
|
||||
@property() weekday: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the era. */
|
||||
@property() era: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the year. */
|
||||
@property() year: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the month. */
|
||||
@property() month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the day. */
|
||||
@property() day: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property() hour: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the minute. */
|
||||
@property() minute: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the second. */
|
||||
@property() second: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the time. */
|
||||
@property({ attribute: 'time-zone-name' }) timeZoneName: 'short' | 'long';
|
||||
|
||||
/** The time zone to express the time in. */
|
||||
@property({ attribute: 'time-zone' }) timeZone: string;
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property({ attribute: 'hour-format' }) hourFormat: 'auto' | '12' | '24' = 'auto';
|
||||
|
||||
render() {
|
||||
const date = new Date(this.date);
|
||||
const hour12 = this.hourFormat === 'auto' ? undefined : this.hourFormat === '12';
|
||||
|
||||
// Check for an invalid date
|
||||
if (isNaN(date.getMilliseconds())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`
|
||||
<time datetime=${date.toISOString()}>
|
||||
${this.localize.date(date, {
|
||||
weekday: this.weekday,
|
||||
era: this.era,
|
||||
year: this.year,
|
||||
month: this.month,
|
||||
day: this.day,
|
||||
hour: this.hour,
|
||||
minute: this.minute,
|
||||
second: this.second,
|
||||
timeZoneName: this.timeZoneName,
|
||||
timeZone: this.timeZone,
|
||||
hour12: hour12
|
||||
})}
|
||||
</time>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-date': SlFormatDate;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a date/time using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-date
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
@customElement('sl-format-date')
|
||||
export default class SlFormatDate extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* The date/time to format. If not set, the current date and time will be used. When passing a string, it's strongly
|
||||
* recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert a date to this format
|
||||
* in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
|
||||
*/
|
||||
@property() date: Date | string = new Date();
|
||||
|
||||
/** The format for displaying the weekday. */
|
||||
@property() weekday: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the era. */
|
||||
@property() era: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the year. */
|
||||
@property() year: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the month. */
|
||||
@property() month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the day. */
|
||||
@property() day: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property() hour: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the minute. */
|
||||
@property() minute: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the second. */
|
||||
@property() second: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the time. */
|
||||
@property({ attribute: 'time-zone-name' }) timeZoneName: 'short' | 'long';
|
||||
|
||||
/** The time zone to express the time in. */
|
||||
@property({ attribute: 'time-zone' }) timeZone: string;
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property({ attribute: 'hour-format' }) hourFormat: 'auto' | '12' | '24' = 'auto';
|
||||
|
||||
render() {
|
||||
const date = new Date(this.date);
|
||||
const hour12 = this.hourFormat === 'auto' ? undefined : this.hourFormat === '12';
|
||||
|
||||
// Check for an invalid date
|
||||
if (isNaN(date.getMilliseconds())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`
|
||||
<time datetime=${date.toISOString()}>
|
||||
${this.localize.date(date, {
|
||||
weekday: this.weekday,
|
||||
era: this.era,
|
||||
year: this.year,
|
||||
month: this.month,
|
||||
day: this.day,
|
||||
hour: this.hour,
|
||||
minute: this.minute,
|
||||
second: this.second,
|
||||
timeZoneName: this.timeZoneName,
|
||||
timeZone: this.timeZone,
|
||||
hour12: hour12
|
||||
})}
|
||||
</time>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-date': SlFormatDate;
|
||||
}
|
||||
}
|
||||
import SlFormatDate from './format-date.component.js';
|
||||
export * from './format-date.component.js';
|
||||
export default SlFormatDate;
|
||||
SlFormatDate.define('sl-format-date');
|
||||
|
||||
67
src/components/format-number/format-number.component.ts
Normal file
67
src/components/format-number/format-number.component.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-number
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
export default class SlFormatNumber extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The formatting style to use. */
|
||||
@property() type: 'currency' | 'decimal' | 'percent' = 'decimal';
|
||||
|
||||
/** Turns off grouping separators. */
|
||||
@property({ attribute: 'no-grouping', type: Boolean }) noGrouping = false;
|
||||
|
||||
/** The [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code to use when formatting. */
|
||||
@property() currency = 'USD';
|
||||
|
||||
/** How to display the currency. */
|
||||
@property({ attribute: 'currency-display' }) currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
|
||||
|
||||
/** The minimum number of integer digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-integer-digits', type: Number }) minimumIntegerDigits: number;
|
||||
|
||||
/** The minimum number of fraction digits to use. Possible values are 0-20. */
|
||||
@property({ attribute: 'minimum-fraction-digits', type: Number }) minimumFractionDigits: number;
|
||||
|
||||
/** The maximum number of fraction digits to use. Possible values are 0-0. */
|
||||
@property({ attribute: 'maximum-fraction-digits', type: Number }) maximumFractionDigits: number;
|
||||
|
||||
/** The minimum number of significant digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-significant-digits', type: Number }) minimumSignificantDigits: number;
|
||||
|
||||
/** The maximum number of significant digits to use,. Possible values are 1-21. */
|
||||
@property({ attribute: 'maximum-significant-digits', type: Number }) maximumSignificantDigits: number;
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.localize.number(this.value, {
|
||||
style: this.type,
|
||||
currency: this.currency,
|
||||
currencyDisplay: this.currencyDisplay,
|
||||
useGrouping: !this.noGrouping,
|
||||
minimumIntegerDigits: this.minimumIntegerDigits,
|
||||
minimumFractionDigits: this.minimumFractionDigits,
|
||||
maximumFractionDigits: this.maximumFractionDigits,
|
||||
minimumSignificantDigits: this.minimumSignificantDigits,
|
||||
maximumSignificantDigits: this.maximumSignificantDigits
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-number': SlFormatNumber;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-number
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
@customElement('sl-format-number')
|
||||
export default class SlFormatNumber extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The formatting style to use. */
|
||||
@property() type: 'currency' | 'decimal' | 'percent' = 'decimal';
|
||||
|
||||
/** Turns off grouping separators. */
|
||||
@property({ attribute: 'no-grouping', type: Boolean }) noGrouping = false;
|
||||
|
||||
/** The [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code to use when formatting. */
|
||||
@property() currency = 'USD';
|
||||
|
||||
/** How to display the currency. */
|
||||
@property({ attribute: 'currency-display' }) currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
|
||||
|
||||
/** The minimum number of integer digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-integer-digits', type: Number }) minimumIntegerDigits: number;
|
||||
|
||||
/** The minimum number of fraction digits to use. Possible values are 0-20. */
|
||||
@property({ attribute: 'minimum-fraction-digits', type: Number }) minimumFractionDigits: number;
|
||||
|
||||
/** The maximum number of fraction digits to use. Possible values are 0-0. */
|
||||
@property({ attribute: 'maximum-fraction-digits', type: Number }) maximumFractionDigits: number;
|
||||
|
||||
/** The minimum number of significant digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-significant-digits', type: Number }) minimumSignificantDigits: number;
|
||||
|
||||
/** The maximum number of significant digits to use,. Possible values are 1-21. */
|
||||
@property({ attribute: 'maximum-significant-digits', type: Number }) maximumSignificantDigits: number;
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.localize.number(this.value, {
|
||||
style: this.type,
|
||||
currency: this.currency,
|
||||
currencyDisplay: this.currencyDisplay,
|
||||
useGrouping: !this.noGrouping,
|
||||
minimumIntegerDigits: this.minimumIntegerDigits,
|
||||
minimumFractionDigits: this.minimumFractionDigits,
|
||||
maximumFractionDigits: this.maximumFractionDigits,
|
||||
minimumSignificantDigits: this.minimumSignificantDigits,
|
||||
maximumSignificantDigits: this.maximumSignificantDigits
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-number': SlFormatNumber;
|
||||
}
|
||||
}
|
||||
import SlFormatNumber from './format-number.component.js';
|
||||
export * from './format-number.component.js';
|
||||
export default SlFormatNumber;
|
||||
SlFormatNumber.define('sl-format-number');
|
||||
|
||||
136
src/components/icon-button/icon-button.component.ts
Normal file
136
src/components/icon-button/icon-button.component.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './icon-button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
|
||||
* @documentation https://shoelace.style/components/icon-button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-blur - Emitted when the icon button loses focus.
|
||||
* @event sl-focus - Emitted when the icon button gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlIconButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property() name?: string;
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property() library?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* A description that gets read by assistive devices. For optimal accessibility, you should always include a label
|
||||
* that describes what the icon button does.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the icon button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the icon button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the icon button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'icon-button': true,
|
||||
'icon-button--disabled': !isLink && this.disabled,
|
||||
'icon-button--focused': this.hasFocus
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : 'button')}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-label="${this.label}"
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<sl-icon
|
||||
class="icon-button__icon"
|
||||
name=${ifDefined(this.name)}
|
||||
library=${ifDefined(this.library)}
|
||||
src=${ifDefined(this.src)}
|
||||
aria-hidden="true"
|
||||
></sl-icon>
|
||||
</${tag}>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon-button': SlIconButton;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './icon-button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
|
||||
* @documentation https://shoelace.style/components/icon-button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-blur - Emitted when the icon button loses focus.
|
||||
* @event sl-focus - Emitted when the icon button gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-icon-button')
|
||||
export default class SlIconButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property() name?: string;
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property() library?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* A description that gets read by assistive devices. For optimal accessibility, you should always include a label
|
||||
* that describes what the icon button does.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the icon button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the icon button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the icon button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'icon-button': true,
|
||||
'icon-button--disabled': !isLink && this.disabled,
|
||||
'icon-button--focused': this.hasFocus
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : 'button')}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-label="${this.label}"
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<sl-icon
|
||||
class="icon-button__icon"
|
||||
name=${ifDefined(this.name)}
|
||||
library=${ifDefined(this.library)}
|
||||
src=${ifDefined(this.src)}
|
||||
aria-hidden="true"
|
||||
></sl-icon>
|
||||
</${tag}>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon-button': SlIconButton;
|
||||
}
|
||||
}
|
||||
import SlIconButton from './icon-button.component.js';
|
||||
export * from './icon-button.component.js';
|
||||
export default SlIconButton;
|
||||
SlIconButton.define('sl-icon-button');
|
||||
|
||||
189
src/components/icon/icon.component.ts
Normal file
189
src/components/icon/icon.component.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { getIconLibrary, type IconLibrary, unwatchIcon, watchIcon } from './library.js';
|
||||
import { html } from 'lit';
|
||||
import { isTemplateResult } from 'lit/directive-helpers.js';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './icon.styles.js';
|
||||
|
||||
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
|
||||
|
||||
const CACHEABLE_ERROR = Symbol();
|
||||
const RETRYABLE_ERROR = Symbol();
|
||||
type SVGResult = HTMLTemplateResult | SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR;
|
||||
|
||||
let parser: DOMParser;
|
||||
const iconCache = new Map<string, Promise<SVGResult>>();
|
||||
|
||||
/**
|
||||
* @summary Icons are symbols that can be used to represent various options within an application.
|
||||
* @documentation https://shoelace.style/components/icon
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the icon has loaded. When using `spriteSheet: true` this will not emit.
|
||||
* @event sl-error - Emitted when the icon fails to load due to an error. When using `spriteSheet: true` this will not emit.
|
||||
*
|
||||
* @csspart svg - The internal SVG element.
|
||||
* @csspart use - The <use> element generated when using `spriteSheet: true`
|
||||
*/
|
||||
export default class SlIcon extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private initialRender = false;
|
||||
|
||||
/** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */
|
||||
private async resolveIcon(url: string, library?: IconLibrary): Promise<SVGResult> {
|
||||
let fileData: Response;
|
||||
|
||||
if (library?.spriteSheet) {
|
||||
return html`<svg part="svg">
|
||||
<use part="use" href="${url}"></use>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
try {
|
||||
fileData = await fetch(url, { mode: 'cors' });
|
||||
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
|
||||
} catch {
|
||||
return RETRYABLE_ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = await fileData.text();
|
||||
|
||||
const svg = div.firstElementChild;
|
||||
if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR;
|
||||
|
||||
if (!parser) parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svg.outerHTML, 'text/html');
|
||||
|
||||
const svgEl = doc.body.querySelector('svg');
|
||||
if (!svgEl) return CACHEABLE_ERROR;
|
||||
|
||||
svgEl.part.add('svg');
|
||||
return document.adoptNode(svgEl);
|
||||
} catch {
|
||||
return CACHEABLE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@state() private svg: SVGElement | HTMLTemplateResult | null = null;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property({ reflect: true }) name?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/**
|
||||
* An alternate description to use for assistive devices. If omitted, the icon will be considered presentational and
|
||||
* ignored by assistive devices.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property({ reflect: true }) library = 'default';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
watchIcon(this);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.initialRender = true;
|
||||
this.setIcon();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unwatchIcon(this);
|
||||
}
|
||||
|
||||
private getUrl() {
|
||||
const library = getIconLibrary(this.library);
|
||||
if (this.name && library) {
|
||||
return library.resolver(this.name);
|
||||
}
|
||||
return this.src;
|
||||
}
|
||||
|
||||
@watch('label')
|
||||
handleLabelChange() {
|
||||
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
|
||||
|
||||
if (hasLabel) {
|
||||
this.setAttribute('role', 'img');
|
||||
this.setAttribute('aria-label', this.label);
|
||||
this.removeAttribute('aria-hidden');
|
||||
} else {
|
||||
this.removeAttribute('role');
|
||||
this.removeAttribute('aria-label');
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
@watch(['name', 'src', 'library'])
|
||||
async setIcon() {
|
||||
const library = getIconLibrary(this.library);
|
||||
const url = this.getUrl();
|
||||
|
||||
if (!url) {
|
||||
this.svg = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let iconResolver = iconCache.get(url);
|
||||
if (!iconResolver) {
|
||||
iconResolver = this.resolveIcon(url, library);
|
||||
iconCache.set(url, iconResolver);
|
||||
}
|
||||
|
||||
// If we haven't rendered yet, exit early. This avoids unnecessary work due to watching multiple props.
|
||||
if (!this.initialRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = await iconResolver;
|
||||
|
||||
if (svg === RETRYABLE_ERROR) {
|
||||
iconCache.delete(url);
|
||||
}
|
||||
|
||||
if (url !== this.getUrl()) {
|
||||
// If the url has changed while fetching the icon, ignore this request
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTemplateResult(svg)) {
|
||||
this.svg = svg;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (svg) {
|
||||
case RETRYABLE_ERROR:
|
||||
case CACHEABLE_ERROR:
|
||||
this.svg = null;
|
||||
this.emit('sl-error');
|
||||
break;
|
||||
default:
|
||||
this.svg = svg.cloneNode(true) as SVGElement;
|
||||
library?.mutator?.(this.svg);
|
||||
this.emit('sl-load');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.svg;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon': SlIcon;
|
||||
}
|
||||
}
|
||||
@@ -1,190 +1,4 @@
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { getIconLibrary, type IconLibrary, unwatchIcon, watchIcon } from './library.js';
|
||||
import { html } from 'lit';
|
||||
import { isTemplateResult } from 'lit/directive-helpers.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './icon.styles.js';
|
||||
|
||||
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
|
||||
|
||||
const CACHEABLE_ERROR = Symbol();
|
||||
const RETRYABLE_ERROR = Symbol();
|
||||
type SVGResult = HTMLTemplateResult | SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR;
|
||||
|
||||
let parser: DOMParser;
|
||||
const iconCache = new Map<string, Promise<SVGResult>>();
|
||||
|
||||
/**
|
||||
* @summary Icons are symbols that can be used to represent various options within an application.
|
||||
* @documentation https://shoelace.style/components/icon
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the icon has loaded. When using `spriteSheet: true` this will not emit.
|
||||
* @event sl-error - Emitted when the icon fails to load due to an error. When using `spriteSheet: true` this will not emit.
|
||||
*
|
||||
* @csspart svg - The internal SVG element.
|
||||
* @csspart use - The <use> element generated when using `spriteSheet: true`
|
||||
*/
|
||||
@customElement('sl-icon')
|
||||
export default class SlIcon extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private initialRender = false;
|
||||
|
||||
/** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */
|
||||
private async resolveIcon(url: string, library?: IconLibrary): Promise<SVGResult> {
|
||||
let fileData: Response;
|
||||
|
||||
if (library?.spriteSheet) {
|
||||
return html`<svg part="svg">
|
||||
<use part="use" href="${url}"></use>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
try {
|
||||
fileData = await fetch(url, { mode: 'cors' });
|
||||
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
|
||||
} catch {
|
||||
return RETRYABLE_ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = await fileData.text();
|
||||
|
||||
const svg = div.firstElementChild;
|
||||
if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR;
|
||||
|
||||
if (!parser) parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svg.outerHTML, 'text/html');
|
||||
|
||||
const svgEl = doc.body.querySelector('svg');
|
||||
if (!svgEl) return CACHEABLE_ERROR;
|
||||
|
||||
svgEl.part.add('svg');
|
||||
return document.adoptNode(svgEl);
|
||||
} catch {
|
||||
return CACHEABLE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@state() private svg: SVGElement | HTMLTemplateResult | null = null;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property({ reflect: true }) name?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/**
|
||||
* An alternate description to use for assistive devices. If omitted, the icon will be considered presentational and
|
||||
* ignored by assistive devices.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property({ reflect: true }) library = 'default';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
watchIcon(this);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.initialRender = true;
|
||||
this.setIcon();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unwatchIcon(this);
|
||||
}
|
||||
|
||||
private getUrl() {
|
||||
const library = getIconLibrary(this.library);
|
||||
if (this.name && library) {
|
||||
return library.resolver(this.name);
|
||||
}
|
||||
return this.src;
|
||||
}
|
||||
|
||||
@watch('label')
|
||||
handleLabelChange() {
|
||||
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
|
||||
|
||||
if (hasLabel) {
|
||||
this.setAttribute('role', 'img');
|
||||
this.setAttribute('aria-label', this.label);
|
||||
this.removeAttribute('aria-hidden');
|
||||
} else {
|
||||
this.removeAttribute('role');
|
||||
this.removeAttribute('aria-label');
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
@watch(['name', 'src', 'library'])
|
||||
async setIcon() {
|
||||
const library = getIconLibrary(this.library);
|
||||
const url = this.getUrl();
|
||||
|
||||
if (!url) {
|
||||
this.svg = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let iconResolver = iconCache.get(url);
|
||||
if (!iconResolver) {
|
||||
iconResolver = this.resolveIcon(url, library);
|
||||
iconCache.set(url, iconResolver);
|
||||
}
|
||||
|
||||
// If we haven't rendered yet, exit early. This avoids unnecessary work due to watching multiple props.
|
||||
if (!this.initialRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = await iconResolver;
|
||||
|
||||
if (svg === RETRYABLE_ERROR) {
|
||||
iconCache.delete(url);
|
||||
}
|
||||
|
||||
if (url !== this.getUrl()) {
|
||||
// If the url has changed while fetching the icon, ignore this request
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTemplateResult(svg)) {
|
||||
this.svg = svg;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (svg) {
|
||||
case RETRYABLE_ERROR:
|
||||
case CACHEABLE_ERROR:
|
||||
this.svg = null;
|
||||
this.emit('sl-error');
|
||||
break;
|
||||
default:
|
||||
this.svg = svg.cloneNode(true) as SVGElement;
|
||||
library?.mutator?.(this.svg);
|
||||
this.emit('sl-load');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.svg;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon': SlIcon;
|
||||
}
|
||||
}
|
||||
import SlIcon from './icon.component.js';
|
||||
export * from './icon.component.js';
|
||||
export default SlIcon;
|
||||
SlIcon.define('sl-icon');
|
||||
|
||||
159
src/components/image-comparer/image-comparer.component.ts
Normal file
159
src/components/image-comparer/image-comparer.component.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { drag } from '../../internal/drag.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './image-comparer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Compare visual differences between similar photos with a sliding panel.
|
||||
* @documentation https://shoelace.style/components/image-comparer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot before - The before image, an `<img>` or `<svg>` element.
|
||||
* @slot after - The after image, an `<img>` or `<svg>` element.
|
||||
* @slot handle - The icon used inside the handle.
|
||||
*
|
||||
* @event sl-change - Emitted when the position changes.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart before - The container that wraps the before image.
|
||||
* @csspart after - The container that wraps the after image.
|
||||
* @csspart divider - The divider that separates the images.
|
||||
* @csspart handle - The handle that the user drags to expose the after image.
|
||||
*
|
||||
* @cssproperty --divider-width - The width of the dividing line.
|
||||
* @cssproperty --handle-size - The size of the compare handle.
|
||||
*/
|
||||
export default class SlImageComparer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static scopedElement = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.image-comparer') base: HTMLElement;
|
||||
@query('.image-comparer__handle') handle: HTMLElement;
|
||||
|
||||
/** The position of the divider as a percentage. */
|
||||
@property({ type: Number, reflect: true }) position = 50;
|
||||
|
||||
private handleDrag(event: PointerEvent) {
|
||||
const { width } = this.base.getBoundingClientRect();
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
drag(this.base, {
|
||||
onMove: x => {
|
||||
this.position = parseFloat(clamp((x / width) * 100, 0, 100).toFixed(2));
|
||||
if (isRtl) this.position = 100 - this.position;
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
||||
const incr = event.shiftKey ? 10 : 1;
|
||||
let newPosition = this.position;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
|
||||
newPosition -= incr;
|
||||
}
|
||||
if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
|
||||
newPosition += incr;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
newPosition = 0;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
newPosition = 100;
|
||||
}
|
||||
newPosition = clamp(newPosition, 0, 100);
|
||||
|
||||
this.position = newPosition;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('position', { waitUntilFirstUpdate: true })
|
||||
handlePositionChange() {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
id="image-comparer"
|
||||
class=${classMap({
|
||||
'image-comparer': true,
|
||||
'image-comparer--rtl': isRtl
|
||||
})}
|
||||
@keydown=${this.handleKeyDown}
|
||||
>
|
||||
<div class="image-comparer__image">
|
||||
<div part="before" class="image-comparer__before">
|
||||
<slot name="before"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="after"
|
||||
class="image-comparer__after"
|
||||
style=${styleMap({
|
||||
clipPath: isRtl ? `inset(0 0 0 ${100 - this.position}%)` : `inset(0 ${100 - this.position}% 0 0)`
|
||||
})}
|
||||
>
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="divider"
|
||||
class="image-comparer__divider"
|
||||
style=${styleMap({
|
||||
left: isRtl ? `${100 - this.position}%` : `${this.position}%`
|
||||
})}
|
||||
@mousedown=${this.handleDrag}
|
||||
@touchstart=${this.handleDrag}
|
||||
>
|
||||
<div
|
||||
part="handle"
|
||||
class="image-comparer__handle"
|
||||
role="scrollbar"
|
||||
aria-valuenow=${this.position}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-controls="image-comparer"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot name="handle">
|
||||
<sl-icon library="system" name="grip-vertical"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-image-comparer': SlImageComparer;
|
||||
}
|
||||
}
|
||||
@@ -1,159 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { drag } from '../../internal/drag.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './image-comparer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Compare visual differences between similar photos with a sliding panel.
|
||||
* @documentation https://shoelace.style/components/image-comparer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot before - The before image, an `<img>` or `<svg>` element.
|
||||
* @slot after - The after image, an `<img>` or `<svg>` element.
|
||||
* @slot handle - The icon used inside the handle.
|
||||
*
|
||||
* @event sl-change - Emitted when the position changes.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart before - The container that wraps the before image.
|
||||
* @csspart after - The container that wraps the after image.
|
||||
* @csspart divider - The divider that separates the images.
|
||||
* @csspart handle - The handle that the user drags to expose the after image.
|
||||
*
|
||||
* @cssproperty --divider-width - The width of the dividing line.
|
||||
* @cssproperty --handle-size - The size of the compare handle.
|
||||
*/
|
||||
@customElement('sl-image-comparer')
|
||||
export default class SlImageComparer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.image-comparer') base: HTMLElement;
|
||||
@query('.image-comparer__handle') handle: HTMLElement;
|
||||
|
||||
/** The position of the divider as a percentage. */
|
||||
@property({ type: Number, reflect: true }) position = 50;
|
||||
|
||||
private handleDrag(event: PointerEvent) {
|
||||
const { width } = this.base.getBoundingClientRect();
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
drag(this.base, {
|
||||
onMove: x => {
|
||||
this.position = parseFloat(clamp((x / width) * 100, 0, 100).toFixed(2));
|
||||
if (isRtl) this.position = 100 - this.position;
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
||||
const incr = event.shiftKey ? 10 : 1;
|
||||
let newPosition = this.position;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
|
||||
newPosition -= incr;
|
||||
}
|
||||
if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
|
||||
newPosition += incr;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
newPosition = 0;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
newPosition = 100;
|
||||
}
|
||||
newPosition = clamp(newPosition, 0, 100);
|
||||
|
||||
this.position = newPosition;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('position', { waitUntilFirstUpdate: true })
|
||||
handlePositionChange() {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
id="image-comparer"
|
||||
class=${classMap({
|
||||
'image-comparer': true,
|
||||
'image-comparer--rtl': isRtl
|
||||
})}
|
||||
@keydown=${this.handleKeyDown}
|
||||
>
|
||||
<div class="image-comparer__image">
|
||||
<div part="before" class="image-comparer__before">
|
||||
<slot name="before"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="after"
|
||||
class="image-comparer__after"
|
||||
style=${styleMap({
|
||||
clipPath: isRtl ? `inset(0 0 0 ${100 - this.position}%)` : `inset(0 ${100 - this.position}% 0 0)`
|
||||
})}
|
||||
>
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="divider"
|
||||
class="image-comparer__divider"
|
||||
style=${styleMap({
|
||||
left: isRtl ? `${100 - this.position}%` : `${this.position}%`
|
||||
})}
|
||||
@mousedown=${this.handleDrag}
|
||||
@touchstart=${this.handleDrag}
|
||||
>
|
||||
<div
|
||||
part="handle"
|
||||
class="image-comparer__handle"
|
||||
role="scrollbar"
|
||||
aria-valuenow=${this.position}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-controls="image-comparer"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot name="handle">
|
||||
<sl-icon library="system" name="grip-vertical"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-image-comparer': SlImageComparer;
|
||||
}
|
||||
}
|
||||
import SlImageComparer from './image-comparer.component.js';
|
||||
export * from './image-comparer.component.js';
|
||||
export default SlImageComparer;
|
||||
SlImageComparer.define('sl-image-comparer');
|
||||
|
||||
81
src/components/include/include.component.ts
Normal file
81
src/components/include/include.component.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { requestInclude } from './request.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './include.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Includes give you the power to embed external HTML files into the page.
|
||||
* @documentation https://shoelace.style/components/include
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the included file is loaded.
|
||||
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
|
||||
*/
|
||||
export default class SlInclude extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/**
|
||||
* The location of the HTML file to include. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property() src: string;
|
||||
|
||||
/** The fetch mode to use. */
|
||||
@property() mode: 'cors' | 'no-cors' | 'same-origin' = 'cors';
|
||||
|
||||
/**
|
||||
* Allows included scripts to be executed. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
|
||||
|
||||
private executeScript(script: HTMLScriptElement) {
|
||||
// Create a copy of the script and swap it out so the browser executes it
|
||||
const newScript = document.createElement('script');
|
||||
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = script.textContent;
|
||||
script.parentNode!.replaceChild(newScript, script);
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
async handleSrcChange() {
|
||||
try {
|
||||
const src = this.src;
|
||||
const file = await requestInclude(src, this.mode);
|
||||
|
||||
// If the src changed since the request started do nothing, otherwise we risk overwriting a subsequent response
|
||||
if (src !== this.src) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.ok) {
|
||||
this.emit('sl-error', { detail: { status: file.status } });
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = file.html;
|
||||
|
||||
if (this.allowScripts) {
|
||||
[...this.querySelectorAll('script')].forEach(script => this.executeScript(script));
|
||||
}
|
||||
|
||||
this.emit('sl-load');
|
||||
} catch {
|
||||
this.emit('sl-error', { detail: { status: -1 } });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-include': SlInclude;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { requestInclude } from './request.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './include.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Includes give you the power to embed external HTML files into the page.
|
||||
* @documentation https://shoelace.style/components/include
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the included file is loaded.
|
||||
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
|
||||
*/
|
||||
@customElement('sl-include')
|
||||
export default class SlInclude extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/**
|
||||
* The location of the HTML file to include. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property() src: string;
|
||||
|
||||
/** The fetch mode to use. */
|
||||
@property() mode: 'cors' | 'no-cors' | 'same-origin' = 'cors';
|
||||
|
||||
/**
|
||||
* Allows included scripts to be executed. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
|
||||
|
||||
private executeScript(script: HTMLScriptElement) {
|
||||
// Create a copy of the script and swap it out so the browser executes it
|
||||
const newScript = document.createElement('script');
|
||||
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = script.textContent;
|
||||
script.parentNode!.replaceChild(newScript, script);
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
async handleSrcChange() {
|
||||
try {
|
||||
const src = this.src;
|
||||
const file = await requestInclude(src, this.mode);
|
||||
|
||||
// If the src changed since the request started do nothing, otherwise we risk overwriting a subsequent response
|
||||
if (src !== this.src) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.ok) {
|
||||
this.emit('sl-error', { detail: { status: file.status } });
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = file.html;
|
||||
|
||||
if (this.allowScripts) {
|
||||
[...this.querySelectorAll('script')].forEach(script => this.executeScript(script));
|
||||
}
|
||||
|
||||
this.emit('sl-load');
|
||||
} catch {
|
||||
this.emit('sl-error', { detail: { status: -1 } });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-include': SlInclude;
|
||||
}
|
||||
}
|
||||
import SlInclude from './include.component.js';
|
||||
export * from './include.component.js';
|
||||
export default SlInclude;
|
||||
SlInclude.define('sl-include');
|
||||
|
||||
556
src/components/input/input.component.ts
Normal file
556
src/components/input/input.component.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './input.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Inputs collect data from the user.
|
||||
* @documentation https://shoelace.style/components/input
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot prefix - Used to prepend a presentational icon or similar element to the input.
|
||||
* @slot suffix - Used to append a presentational icon or similar element to the input.
|
||||
* @slot clear-icon - An icon to use in lieu of the default clear icon.
|
||||
* @slot show-password-icon - An icon to use in lieu of the default show password icon.
|
||||
* @slot hide-password-icon - An icon to use in lieu of the default hide password icon.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-clear - Emitted when the clear button is activated.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The input's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart input - The internal `<input>` control.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart clear-button - The clear button.
|
||||
* @csspart password-toggle-button - The password toggle button.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.input__control') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
private __numberInput = Object.assign(document.createElement('input'), { type: 'number' });
|
||||
private __dateInput = Object.assign(document.createElement('input'), { type: 'date' });
|
||||
|
||||
/**
|
||||
* The type of input. Works the same as a native `<input>` element, but only a subset of types are supported. Defaults
|
||||
* to `text`.
|
||||
*/
|
||||
@property({ reflect: true }) type:
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'search'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'time'
|
||||
| 'url' = 'text';
|
||||
|
||||
/** The name of the input, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the input, submitted as a name/value pair with form data. */
|
||||
@property() value = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = '';
|
||||
|
||||
/** The input's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws a filled input. */
|
||||
@property({ type: Boolean, reflect: true }) filled = false;
|
||||
|
||||
/** Draws a pill-style input with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** The input's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The input's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** Adds a clear button when the input is not empty. */
|
||||
@property({ type: Boolean }) clearable = false;
|
||||
|
||||
/** Disables the input. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Placeholder text to show as a hint when the input is empty. */
|
||||
@property() placeholder = '';
|
||||
|
||||
/** Makes the input readonly. */
|
||||
@property({ type: Boolean, reflect: true }) readonly = false;
|
||||
|
||||
/** Adds a button to toggle the password's visibility. Only applies to password types. */
|
||||
@property({ attribute: 'password-toggle', type: Boolean }) passwordToggle = false;
|
||||
|
||||
/** Determines whether or not the password is currently visible. Only applies to password input types. */
|
||||
@property({ attribute: 'password-visible', type: Boolean }) passwordVisible = false;
|
||||
|
||||
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
|
||||
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the input a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** A regular expression pattern to validate input against. */
|
||||
@property() pattern: string;
|
||||
|
||||
/** The minimum length of input that will be considered valid. */
|
||||
@property({ type: Number }) minlength: number;
|
||||
|
||||
/** The maximum length of input that will be considered valid. */
|
||||
@property({ type: Number }) maxlength: number;
|
||||
|
||||
/** The input's minimum value. Only applies to date and number input types. */
|
||||
@property() min: number | string;
|
||||
|
||||
/** The input's maximum value. Only applies to date and number input types. */
|
||||
@property() max: number | string;
|
||||
|
||||
/**
|
||||
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
|
||||
* implied, allowing any numeric value. Only applies to date and number input types.
|
||||
*/
|
||||
@property() step: number | 'any';
|
||||
|
||||
/** Controls whether and how text input is automatically capitalized as it is entered by the user. */
|
||||
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
|
||||
|
||||
/** Indicates whether the browser's autocorrect feature is on or off. */
|
||||
@property() autocorrect: 'off' | 'on';
|
||||
|
||||
/**
|
||||
* Specifies what permission the browser has to provide assistance in filling out form field values. Refer to
|
||||
* [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values.
|
||||
*/
|
||||
@property() autocomplete: string;
|
||||
|
||||
/** Indicates that the input should receive focus on page load. */
|
||||
@property({ type: Boolean }) autofocus: boolean;
|
||||
|
||||
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
|
||||
@property() enterkeyhint: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
|
||||
|
||||
/** Enables spell checking on the input. */
|
||||
@property({
|
||||
type: Boolean,
|
||||
converter: {
|
||||
// Allow "true|false" attribute values but keep the property boolean
|
||||
fromAttribute: value => (!value || value === 'false' ? false : true),
|
||||
toAttribute: value => (value ? 'true' : 'false')
|
||||
}
|
||||
})
|
||||
spellcheck = true;
|
||||
|
||||
/**
|
||||
* Tells the browser what type of data will be entered by the user, allowing it to display the appropriate virtual
|
||||
* keyboard on supportive devices.
|
||||
*/
|
||||
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
||||
|
||||
//
|
||||
// NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
|
||||
// can be set before the component is rendered.
|
||||
//
|
||||
|
||||
/**
|
||||
* Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. This will use the native `<input type="{{type}}">` implementation and may result in an error.
|
||||
*/
|
||||
get valueAsDate() {
|
||||
this.__dateInput.type = this.type;
|
||||
this.__dateInput.value = this.value;
|
||||
return this.input?.valueAsDate || this.__dateInput.valueAsDate;
|
||||
}
|
||||
|
||||
set valueAsDate(newValue: Date | null) {
|
||||
this.__dateInput.type = this.type;
|
||||
this.__dateInput.valueAsDate = newValue;
|
||||
this.value = this.__dateInput.value;
|
||||
}
|
||||
|
||||
/** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */
|
||||
get valueAsNumber() {
|
||||
this.__numberInput.value = this.value;
|
||||
return this.input?.valueAsNumber || this.__numberInput.valueAsNumber;
|
||||
}
|
||||
|
||||
set valueAsNumber(newValue: number) {
|
||||
this.__numberInput.valueAsNumber = newValue;
|
||||
this.value = this.__numberInput.value;
|
||||
}
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleChange() {
|
||||
this.value = this.input.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleClearClick(event: MouseEvent) {
|
||||
this.value = '';
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
this.input.focus();
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.value = this.input.value;
|
||||
this.formControlController.updateValidity();
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
|
||||
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
|
||||
// submitting to allow users to cancel the keydown event if they need to
|
||||
if (event.key === 'Enter' && !hasModifier) {
|
||||
setTimeout(() => {
|
||||
//
|
||||
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
|
||||
// to check for this is to look at event.isComposing, which will be true when the IME is open.
|
||||
//
|
||||
// See https://github.com/shoelace-style/shoelace/pull/988
|
||||
//
|
||||
if (!event.defaultPrevented && !event.isComposing) {
|
||||
this.formControlController.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handlePasswordToggle() {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('step', { waitUntilFirstUpdate: true })
|
||||
handleStepChange() {
|
||||
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
|
||||
// imperatively so we don't have to wait for the next render to report the updated validity.
|
||||
this.input.step = String(this.step);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
async handleValueChange() {
|
||||
await this.updateComplete;
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Sets focus on the input. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the input. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Selects all the text in the input. */
|
||||
select() {
|
||||
this.input.select();
|
||||
}
|
||||
|
||||
/** Sets the start and end positions of the text selection (0-based). */
|
||||
setSelectionRange(
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
selectionDirection: 'forward' | 'backward' | 'none' = 'none'
|
||||
) {
|
||||
this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
|
||||
}
|
||||
|
||||
/** Replaces a range of text with a new string. */
|
||||
setRangeText(
|
||||
replacement: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
selectMode?: 'select' | 'start' | 'end' | 'preserve'
|
||||
) {
|
||||
// @ts-expect-error - start, end, and selectMode are optional
|
||||
this.input.setRangeText(replacement, start, end, selectMode);
|
||||
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Displays the browser picker for an input element (only works if the browser supports it for the input type). */
|
||||
showPicker() {
|
||||
if ('showPicker' in HTMLInputElement.prototype) {
|
||||
this.input.showPicker();
|
||||
}
|
||||
}
|
||||
|
||||
/** Increments the value of a numeric input type by the value of the step attribute. */
|
||||
stepUp() {
|
||||
this.input.stepUp();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrements the value of a numeric input type by the value of the step attribute. */
|
||||
stepDown() {
|
||||
this.input.stepDown();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
const hasClearIcon =
|
||||
this.clearable && !this.disabled && !this.readonly && (typeof this.value === 'number' || this.value.length > 0);
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
for="input"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
input: true,
|
||||
|
||||
// Sizes
|
||||
'input--small': this.size === 'small',
|
||||
'input--medium': this.size === 'medium',
|
||||
'input--large': this.size === 'large',
|
||||
|
||||
// States
|
||||
'input--pill': this.pill,
|
||||
'input--standard': !this.filled,
|
||||
'input--filled': this.filled,
|
||||
'input--disabled': this.disabled,
|
||||
'input--focused': this.hasFocus,
|
||||
'input--empty': !this.value,
|
||||
'input--no-spin-buttons': this.noSpinButtons
|
||||
})}
|
||||
>
|
||||
<span part="prefix" class="input__prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</span>
|
||||
|
||||
<input
|
||||
part="input"
|
||||
id="input"
|
||||
class="input__control"
|
||||
type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(this.name)}
|
||||
?disabled=${this.disabled}
|
||||
?readonly=${this.readonly}
|
||||
?required=${this.required}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
minlength=${ifDefined(this.minlength)}
|
||||
maxlength=${ifDefined(this.maxlength)}
|
||||
min=${ifDefined(this.min)}
|
||||
max=${ifDefined(this.max)}
|
||||
step=${ifDefined(this.step as number)}
|
||||
.value=${live(this.value)}
|
||||
autocapitalize=${ifDefined(this.autocapitalize)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
autocorrect=${ifDefined(this.autocorrect)}
|
||||
?autofocus=${this.autofocus}
|
||||
spellcheck=${this.spellcheck}
|
||||
pattern=${ifDefined(this.pattern)}
|
||||
enterkeyhint=${ifDefined(this.enterkeyhint)}
|
||||
inputmode=${ifDefined(this.inputmode)}
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
|
||||
${hasClearIcon
|
||||
? html`
|
||||
<button
|
||||
part="clear-button"
|
||||
class="input__clear"
|
||||
type="button"
|
||||
aria-label=${this.localize.term('clearEntry')}
|
||||
@click=${this.handleClearClick}
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot name="clear-icon">
|
||||
<sl-icon name="x-circle-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
${this.passwordToggle && !this.disabled
|
||||
? html`
|
||||
<button
|
||||
part="password-toggle-button"
|
||||
class="input__password-toggle"
|
||||
type="button"
|
||||
aria-label=${this.localize.term(this.passwordVisible ? 'hidePassword' : 'showPassword')}
|
||||
@click=${this.handlePasswordToggle}
|
||||
tabindex="-1"
|
||||
>
|
||||
${this.passwordVisible
|
||||
? html`
|
||||
<slot name="show-password-icon">
|
||||
<sl-icon name="eye-slash" library="system"></sl-icon>
|
||||
</slot>
|
||||
`
|
||||
: html`
|
||||
<slot name="hide-password-icon">
|
||||
<sl-icon name="eye" library="system"></sl-icon>
|
||||
</slot>
|
||||
`}
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<span part="suffix" class="input__suffix">
|
||||
<slot name="suffix"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-input': SlInput;
|
||||
}
|
||||
}
|
||||
@@ -1,556 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './input.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Inputs collect data from the user.
|
||||
* @documentation https://shoelace.style/components/input
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot prefix - Used to prepend a presentational icon or similar element to the input.
|
||||
* @slot suffix - Used to append a presentational icon or similar element to the input.
|
||||
* @slot clear-icon - An icon to use in lieu of the default clear icon.
|
||||
* @slot show-password-icon - An icon to use in lieu of the default show password icon.
|
||||
* @slot hide-password-icon - An icon to use in lieu of the default hide password icon.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-clear - Emitted when the clear button is activated.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The input's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart input - The internal `<input>` control.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart clear-button - The clear button.
|
||||
* @csspart password-toggle-button - The password toggle button.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
@customElement('sl-input')
|
||||
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.input__control') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
private __numberInput = Object.assign(document.createElement('input'), { type: 'number' });
|
||||
private __dateInput = Object.assign(document.createElement('input'), { type: 'date' });
|
||||
|
||||
/**
|
||||
* The type of input. Works the same as a native `<input>` element, but only a subset of types are supported. Defaults
|
||||
* to `text`.
|
||||
*/
|
||||
@property({ reflect: true }) type:
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'search'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'time'
|
||||
| 'url' = 'text';
|
||||
|
||||
/** The name of the input, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the input, submitted as a name/value pair with form data. */
|
||||
@property() value = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = '';
|
||||
|
||||
/** The input's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws a filled input. */
|
||||
@property({ type: Boolean, reflect: true }) filled = false;
|
||||
|
||||
/** Draws a pill-style input with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** The input's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The input's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** Adds a clear button when the input is not empty. */
|
||||
@property({ type: Boolean }) clearable = false;
|
||||
|
||||
/** Disables the input. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Placeholder text to show as a hint when the input is empty. */
|
||||
@property() placeholder = '';
|
||||
|
||||
/** Makes the input readonly. */
|
||||
@property({ type: Boolean, reflect: true }) readonly = false;
|
||||
|
||||
/** Adds a button to toggle the password's visibility. Only applies to password types. */
|
||||
@property({ attribute: 'password-toggle', type: Boolean }) passwordToggle = false;
|
||||
|
||||
/** Determines whether or not the password is currently visible. Only applies to password input types. */
|
||||
@property({ attribute: 'password-visible', type: Boolean }) passwordVisible = false;
|
||||
|
||||
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
|
||||
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the input a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** A regular expression pattern to validate input against. */
|
||||
@property() pattern: string;
|
||||
|
||||
/** The minimum length of input that will be considered valid. */
|
||||
@property({ type: Number }) minlength: number;
|
||||
|
||||
/** The maximum length of input that will be considered valid. */
|
||||
@property({ type: Number }) maxlength: number;
|
||||
|
||||
/** The input's minimum value. Only applies to date and number input types. */
|
||||
@property() min: number | string;
|
||||
|
||||
/** The input's maximum value. Only applies to date and number input types. */
|
||||
@property() max: number | string;
|
||||
|
||||
/**
|
||||
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
|
||||
* implied, allowing any numeric value. Only applies to date and number input types.
|
||||
*/
|
||||
@property() step: number | 'any';
|
||||
|
||||
/** Controls whether and how text input is automatically capitalized as it is entered by the user. */
|
||||
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
|
||||
|
||||
/** Indicates whether the browser's autocorrect feature is on or off. */
|
||||
@property() autocorrect: 'off' | 'on';
|
||||
|
||||
/**
|
||||
* Specifies what permission the browser has to provide assistance in filling out form field values. Refer to
|
||||
* [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values.
|
||||
*/
|
||||
@property() autocomplete: string;
|
||||
|
||||
/** Indicates that the input should receive focus on page load. */
|
||||
@property({ type: Boolean }) autofocus: boolean;
|
||||
|
||||
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
|
||||
@property() enterkeyhint: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
|
||||
|
||||
/** Enables spell checking on the input. */
|
||||
@property({
|
||||
type: Boolean,
|
||||
converter: {
|
||||
// Allow "true|false" attribute values but keep the property boolean
|
||||
fromAttribute: value => (!value || value === 'false' ? false : true),
|
||||
toAttribute: value => (value ? 'true' : 'false')
|
||||
}
|
||||
})
|
||||
spellcheck = true;
|
||||
|
||||
/**
|
||||
* Tells the browser what type of data will be entered by the user, allowing it to display the appropriate virtual
|
||||
* keyboard on supportive devices.
|
||||
*/
|
||||
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
||||
|
||||
//
|
||||
// NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
|
||||
// can be set before the component is rendered.
|
||||
//
|
||||
|
||||
/**
|
||||
* Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. This will use the native `<input type="{{type}}">` implementation and may result in an error.
|
||||
*/
|
||||
get valueAsDate() {
|
||||
this.__dateInput.type = this.type;
|
||||
this.__dateInput.value = this.value;
|
||||
return this.input?.valueAsDate || this.__dateInput.valueAsDate;
|
||||
}
|
||||
|
||||
set valueAsDate(newValue: Date | null) {
|
||||
this.__dateInput.type = this.type;
|
||||
this.__dateInput.valueAsDate = newValue;
|
||||
this.value = this.__dateInput.value;
|
||||
}
|
||||
|
||||
/** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */
|
||||
get valueAsNumber() {
|
||||
this.__numberInput.value = this.value;
|
||||
return this.input?.valueAsNumber || this.__numberInput.valueAsNumber;
|
||||
}
|
||||
|
||||
set valueAsNumber(newValue: number) {
|
||||
this.__numberInput.valueAsNumber = newValue;
|
||||
this.value = this.__numberInput.value;
|
||||
}
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleChange() {
|
||||
this.value = this.input.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleClearClick(event: MouseEvent) {
|
||||
this.value = '';
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
this.input.focus();
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.value = this.input.value;
|
||||
this.formControlController.updateValidity();
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
|
||||
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
|
||||
// submitting to allow users to cancel the keydown event if they need to
|
||||
if (event.key === 'Enter' && !hasModifier) {
|
||||
setTimeout(() => {
|
||||
//
|
||||
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
|
||||
// to check for this is to look at event.isComposing, which will be true when the IME is open.
|
||||
//
|
||||
// See https://github.com/shoelace-style/shoelace/pull/988
|
||||
//
|
||||
if (!event.defaultPrevented && !event.isComposing) {
|
||||
this.formControlController.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handlePasswordToggle() {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('step', { waitUntilFirstUpdate: true })
|
||||
handleStepChange() {
|
||||
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
|
||||
// imperatively so we don't have to wait for the next render to report the updated validity.
|
||||
this.input.step = String(this.step);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
async handleValueChange() {
|
||||
await this.updateComplete;
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Sets focus on the input. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the input. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Selects all the text in the input. */
|
||||
select() {
|
||||
this.input.select();
|
||||
}
|
||||
|
||||
/** Sets the start and end positions of the text selection (0-based). */
|
||||
setSelectionRange(
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
selectionDirection: 'forward' | 'backward' | 'none' = 'none'
|
||||
) {
|
||||
this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
|
||||
}
|
||||
|
||||
/** Replaces a range of text with a new string. */
|
||||
setRangeText(
|
||||
replacement: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
selectMode?: 'select' | 'start' | 'end' | 'preserve'
|
||||
) {
|
||||
// @ts-expect-error - start, end, and selectMode are optional
|
||||
this.input.setRangeText(replacement, start, end, selectMode);
|
||||
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Displays the browser picker for an input element (only works if the browser supports it for the input type). */
|
||||
showPicker() {
|
||||
if ('showPicker' in HTMLInputElement.prototype) {
|
||||
this.input.showPicker();
|
||||
}
|
||||
}
|
||||
|
||||
/** Increments the value of a numeric input type by the value of the step attribute. */
|
||||
stepUp() {
|
||||
this.input.stepUp();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrements the value of a numeric input type by the value of the step attribute. */
|
||||
stepDown() {
|
||||
this.input.stepDown();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
const hasClearIcon =
|
||||
this.clearable && !this.disabled && !this.readonly && (typeof this.value === 'number' || this.value.length > 0);
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
for="input"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
input: true,
|
||||
|
||||
// Sizes
|
||||
'input--small': this.size === 'small',
|
||||
'input--medium': this.size === 'medium',
|
||||
'input--large': this.size === 'large',
|
||||
|
||||
// States
|
||||
'input--pill': this.pill,
|
||||
'input--standard': !this.filled,
|
||||
'input--filled': this.filled,
|
||||
'input--disabled': this.disabled,
|
||||
'input--focused': this.hasFocus,
|
||||
'input--empty': !this.value,
|
||||
'input--no-spin-buttons': this.noSpinButtons
|
||||
})}
|
||||
>
|
||||
<span part="prefix" class="input__prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</span>
|
||||
|
||||
<input
|
||||
part="input"
|
||||
id="input"
|
||||
class="input__control"
|
||||
type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(this.name)}
|
||||
?disabled=${this.disabled}
|
||||
?readonly=${this.readonly}
|
||||
?required=${this.required}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
minlength=${ifDefined(this.minlength)}
|
||||
maxlength=${ifDefined(this.maxlength)}
|
||||
min=${ifDefined(this.min)}
|
||||
max=${ifDefined(this.max)}
|
||||
step=${ifDefined(this.step as number)}
|
||||
.value=${live(this.value)}
|
||||
autocapitalize=${ifDefined(this.autocapitalize)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
autocorrect=${ifDefined(this.autocorrect)}
|
||||
?autofocus=${this.autofocus}
|
||||
spellcheck=${this.spellcheck}
|
||||
pattern=${ifDefined(this.pattern)}
|
||||
enterkeyhint=${ifDefined(this.enterkeyhint)}
|
||||
inputmode=${ifDefined(this.inputmode)}
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
|
||||
${hasClearIcon
|
||||
? html`
|
||||
<button
|
||||
part="clear-button"
|
||||
class="input__clear"
|
||||
type="button"
|
||||
aria-label=${this.localize.term('clearEntry')}
|
||||
@click=${this.handleClearClick}
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot name="clear-icon">
|
||||
<sl-icon name="x-circle-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
${this.passwordToggle && !this.disabled
|
||||
? html`
|
||||
<button
|
||||
part="password-toggle-button"
|
||||
class="input__password-toggle"
|
||||
type="button"
|
||||
aria-label=${this.localize.term(this.passwordVisible ? 'hidePassword' : 'showPassword')}
|
||||
@click=${this.handlePasswordToggle}
|
||||
tabindex="-1"
|
||||
>
|
||||
${this.passwordVisible
|
||||
? html`
|
||||
<slot name="show-password-icon">
|
||||
<sl-icon name="eye-slash" library="system"></sl-icon>
|
||||
</slot>
|
||||
`
|
||||
: html`
|
||||
<slot name="hide-password-icon">
|
||||
<sl-icon name="eye" library="system"></sl-icon>
|
||||
</slot>
|
||||
`}
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<span part="suffix" class="input__suffix">
|
||||
<slot name="suffix"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-input': SlInput;
|
||||
}
|
||||
}
|
||||
import SlInput from './input.component.js';
|
||||
export * from './input.component.js';
|
||||
export default SlInput;
|
||||
SlInput.define('sl-input');
|
||||
|
||||
138
src/components/menu-item/menu-item.component.ts
Normal file
138
src/components/menu-item/menu-item.component.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getTextContent } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './menu-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu items provide options for the user to pick from in a menu.
|
||||
* @documentation https://shoelace.style/components/menu-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The menu item's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
|
||||
* @csspart prefix - The prefix container.
|
||||
* @csspart label - The menu item label.
|
||||
* @csspart suffix - The suffix container.
|
||||
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
|
||||
*/
|
||||
export default class SlMenuItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private cachedTextLabel: string;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.menu-item') menuItem: HTMLElement;
|
||||
|
||||
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
|
||||
@property() type: 'normal' | 'checkbox' = 'normal';
|
||||
|
||||
/** Draws the item in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
|
||||
@property() value = '';
|
||||
|
||||
/** Draws the menu item in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
|
||||
if (this.checked && this.type !== 'checkbox') {
|
||||
this.checked = false;
|
||||
console.error('The checked attribute can only be used on menu items with type="checkbox"', this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only checkbox types can receive the aria-checked attribute
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('type')
|
||||
handleTypeChange() {
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('role', 'menuitemcheckbox');
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.setAttribute('role', 'menuitem');
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a text label based on the contents of the menu item's default slot. */
|
||||
getTextLabel() {
|
||||
return getTextContent(this.defaultSlot);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'menu-item': true,
|
||||
'menu-item--checked': this.checked,
|
||||
'menu-item--disabled': this.disabled,
|
||||
'menu-item--has-submenu': false // reserved for future use
|
||||
})}
|
||||
>
|
||||
<span part="checked-icon" class="menu-item__check">
|
||||
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
|
||||
<slot name="prefix" part="prefix" class="menu-item__prefix"></slot>
|
||||
|
||||
<slot part="label" class="menu-item__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
|
||||
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
|
||||
|
||||
<span part="submenu-icon" class="menu-item__chevron">
|
||||
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-item': SlMenuItem;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getTextContent } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu items provide options for the user to pick from in a menu.
|
||||
* @documentation https://shoelace.style/components/menu-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The menu item's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
|
||||
* @csspart prefix - The prefix container.
|
||||
* @csspart label - The menu item label.
|
||||
* @csspart suffix - The suffix container.
|
||||
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
|
||||
*/
|
||||
@customElement('sl-menu-item')
|
||||
export default class SlMenuItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private cachedTextLabel: string;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.menu-item') menuItem: HTMLElement;
|
||||
|
||||
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
|
||||
@property() type: 'normal' | 'checkbox' = 'normal';
|
||||
|
||||
/** Draws the item in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
|
||||
@property() value = '';
|
||||
|
||||
/** Draws the menu item in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
|
||||
if (this.checked && this.type !== 'checkbox') {
|
||||
this.checked = false;
|
||||
console.error('The checked attribute can only be used on menu items with type="checkbox"', this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only checkbox types can receive the aria-checked attribute
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('type')
|
||||
handleTypeChange() {
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('role', 'menuitemcheckbox');
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.setAttribute('role', 'menuitem');
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a text label based on the contents of the menu item's default slot. */
|
||||
getTextLabel() {
|
||||
return getTextContent(this.defaultSlot);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'menu-item': true,
|
||||
'menu-item--checked': this.checked,
|
||||
'menu-item--disabled': this.disabled,
|
||||
'menu-item--has-submenu': false // reserved for future use
|
||||
})}
|
||||
>
|
||||
<span part="checked-icon" class="menu-item__check">
|
||||
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
|
||||
<slot name="prefix" part="prefix" class="menu-item__prefix"></slot>
|
||||
|
||||
<slot part="label" class="menu-item__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
|
||||
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
|
||||
|
||||
<span part="submenu-icon" class="menu-item__chevron">
|
||||
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-item': SlMenuItem;
|
||||
}
|
||||
}
|
||||
import SlMenuItem from './menu-item.component.js';
|
||||
export * from './menu-item.component.js';
|
||||
export default SlMenuItem;
|
||||
SlMenuItem.define('sl-menu-item');
|
||||
|
||||
28
src/components/menu-label/menu-label.component.ts
Normal file
28
src/components/menu-label/menu-label.component.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu-label.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu labels are used to describe a group of menu items.
|
||||
* @documentation https://shoelace.style/components/menu-label
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu label's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlMenuLabel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
render() {
|
||||
return html` <slot part="base" class="menu-label"></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-label': SlMenuLabel;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,4 @@
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu-label.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu labels are used to describe a group of menu items.
|
||||
* @documentation https://shoelace.style/components/menu-label
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu label's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-menu-label')
|
||||
export default class SlMenuLabel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
render() {
|
||||
return html` <slot part="base" class="menu-label"></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-label': SlMenuLabel;
|
||||
}
|
||||
}
|
||||
import SlMenuLabel from './menu-label.component.js';
|
||||
export * from './menu-label.component.js';
|
||||
export default SlMenuLabel;
|
||||
SlMenuLabel.define('sl-menu-label');
|
||||
|
||||
159
src/components/menu/menu.component.ts
Normal file
159
src/components/menu/menu.component.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlMenuItem from '../menu-item/menu-item.js';
|
||||
export interface MenuSelectEventDetail {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Menus provide a list of options for the user to choose from.
|
||||
* @documentation https://shoelace.style/components/menu
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu's content, including menu items, menu labels, and dividers.
|
||||
*
|
||||
* @event {{ item: SlMenuItem }} sl-select - Emitted when a menu item is selected.
|
||||
*/
|
||||
export default class SlMenu extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'menu');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const item = target.closest('sl-menu-item');
|
||||
|
||||
if (!item || item.disabled || item.inert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
||||
this.emit('sl-select', { detail: { item } });
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
// Make a selection when pressing enter or space
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
const item = this.getCurrentItem();
|
||||
event.preventDefault();
|
||||
|
||||
// Simulate a click to support @click handlers on menu items that also work with the keyboard
|
||||
item?.click();
|
||||
}
|
||||
|
||||
// Move the selection when pressing down or up
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
const items = this.getAllItems();
|
||||
const activeItem = this.getCurrentItem();
|
||||
let index = activeItem ? items.indexOf(activeItem) : 0;
|
||||
|
||||
if (items.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
index++;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
index--;
|
||||
} else if (event.key === 'Home') {
|
||||
index = 0;
|
||||
} else if (event.key === 'End') {
|
||||
index = items.length - 1;
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
index = items.length - 1;
|
||||
}
|
||||
if (index > items.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.setCurrentItem(items[index]);
|
||||
items[index].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseDown(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (this.isMenuItem(target)) {
|
||||
this.setCurrentItem(target as SlMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Reset the roving tab index when the slotted items change
|
||||
if (items.length > 0) {
|
||||
this.setCurrentItem(items[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private isMenuItem(item: HTMLElement) {
|
||||
return (
|
||||
item.tagName.toLowerCase() === 'sl-menu-item' ||
|
||||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
|
||||
getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (el.inert || !this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
|
||||
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
|
||||
*/
|
||||
getCurrentItem() {
|
||||
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
|
||||
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
|
||||
*/
|
||||
setCurrentItem(item: SlMenuItem) {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Update tab indexes
|
||||
items.forEach(i => {
|
||||
i.setAttribute('tabindex', i === item ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot
|
||||
@slotchange=${this.handleSlotChange}
|
||||
@click=${this.handleClick}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@mousedown=${this.handleMouseDown}
|
||||
></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu': SlMenu;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +1,4 @@
|
||||
import { customElement, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlMenuItem from '../menu-item/menu-item.js';
|
||||
export interface MenuSelectEventDetail {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Menus provide a list of options for the user to choose from.
|
||||
* @documentation https://shoelace.style/components/menu
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu's content, including menu items, menu labels, and dividers.
|
||||
*
|
||||
* @event {{ item: SlMenuItem }} sl-select - Emitted when a menu item is selected.
|
||||
*/
|
||||
@customElement('sl-menu')
|
||||
export default class SlMenu extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'menu');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const item = target.closest('sl-menu-item');
|
||||
|
||||
if (!item || item.disabled || item.inert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
||||
this.emit('sl-select', { detail: { item } });
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
// Make a selection when pressing enter or space
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
const item = this.getCurrentItem();
|
||||
event.preventDefault();
|
||||
|
||||
// Simulate a click to support @click handlers on menu items that also work with the keyboard
|
||||
item?.click();
|
||||
}
|
||||
|
||||
// Move the selection when pressing down or up
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
const items = this.getAllItems();
|
||||
const activeItem = this.getCurrentItem();
|
||||
let index = activeItem ? items.indexOf(activeItem) : 0;
|
||||
|
||||
if (items.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
index++;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
index--;
|
||||
} else if (event.key === 'Home') {
|
||||
index = 0;
|
||||
} else if (event.key === 'End') {
|
||||
index = items.length - 1;
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
index = items.length - 1;
|
||||
}
|
||||
if (index > items.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.setCurrentItem(items[index]);
|
||||
items[index].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseDown(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (this.isMenuItem(target)) {
|
||||
this.setCurrentItem(target as SlMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Reset the roving tab index when the slotted items change
|
||||
if (items.length > 0) {
|
||||
this.setCurrentItem(items[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private isMenuItem(item: HTMLElement) {
|
||||
return (
|
||||
item.tagName.toLowerCase() === 'sl-menu-item' ||
|
||||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
|
||||
getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (el.inert || !this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
|
||||
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
|
||||
*/
|
||||
getCurrentItem() {
|
||||
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
|
||||
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
|
||||
*/
|
||||
setCurrentItem(item: SlMenuItem) {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Update tab indexes
|
||||
items.forEach(i => {
|
||||
i.setAttribute('tabindex', i === item ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot
|
||||
@slotchange=${this.handleSlotChange}
|
||||
@click=${this.handleClick}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@mousedown=${this.handleMouseDown}
|
||||
></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu': SlMenu;
|
||||
}
|
||||
}
|
||||
import SlMenu from './menu.component.js';
|
||||
export * from './menu.component.js';
|
||||
export default SlMenu;
|
||||
SlMenu.define('sl-menu');
|
||||
|
||||
119
src/components/mutation-observer/mutation-observer.component.ts
Normal file
119
src/components/mutation-observer/mutation-observer.component.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './mutation-observer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary The Mutation Observer component offers a thin, declarative interface to the [`MutationObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
|
||||
* @documentation https://shoelace.style/components/mutation-observer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event {{ mutationList: MutationRecord[] }} sl-mutation - Emitted when a mutation occurs.
|
||||
*
|
||||
* @slot - The content to watch for mutations.
|
||||
*/
|
||||
export default class SlMutationObserver extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
/**
|
||||
* Watches for changes to attributes. To watch only specific attributes, separate them by a space, e.g.
|
||||
* `attr="class id title"`. To watch all attributes, use `*`.
|
||||
*/
|
||||
@property({ reflect: true }) attr: string;
|
||||
|
||||
/** Indicates whether or not the attribute's previous value should be recorded when monitoring changes. */
|
||||
@property({ attribute: 'attr-old-value', type: Boolean, reflect: true }) attrOldValue = false;
|
||||
|
||||
/** Watches for changes to the character data contained within the node. */
|
||||
@property({ attribute: 'char-data', type: Boolean, reflect: true }) charData = false;
|
||||
|
||||
/** Indicates whether or not the previous value of the node's text should be recorded. */
|
||||
@property({ attribute: 'char-data-old-value', type: Boolean, reflect: true }) charDataOldValue = false;
|
||||
|
||||
/** Watches for the addition or removal of new child nodes. */
|
||||
@property({ attribute: 'child-list', type: Boolean, reflect: true }) childList = false;
|
||||
|
||||
/** Disables the observer. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.mutationObserver = new MutationObserver(this.handleMutation);
|
||||
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopObserver();
|
||||
}
|
||||
|
||||
private handleMutation = (mutationList: MutationRecord[]) => {
|
||||
this.emit('sl-mutation', {
|
||||
detail: { mutationList }
|
||||
});
|
||||
};
|
||||
|
||||
private startObserver() {
|
||||
const observeAttributes = typeof this.attr === 'string' && this.attr.length > 0;
|
||||
const attributeFilter = observeAttributes && this.attr !== '*' ? this.attr.split(' ') : undefined;
|
||||
|
||||
try {
|
||||
this.mutationObserver.observe(this, {
|
||||
subtree: true,
|
||||
childList: this.childList,
|
||||
attributes: observeAttributes,
|
||||
attributeFilter,
|
||||
attributeOldValue: this.attrOldValue,
|
||||
characterData: this.charData,
|
||||
characterDataOldValue: this.charDataOldValue
|
||||
});
|
||||
} catch {
|
||||
//
|
||||
// A mutation observer was created without one of the required attributes: attr, char-data, or child-list. The
|
||||
// browser will normally throw an error, but we'll suppress that so it doesn't appear as attributes are added
|
||||
// and removed.
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
private stopObserver() {
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
if (this.disabled) {
|
||||
this.stopObserver();
|
||||
} else {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('attr', { waitUntilFirstUpdate: true })
|
||||
@watch('attr-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('childList', { waitUntilFirstUpdate: true })
|
||||
handleChange() {
|
||||
this.stopObserver();
|
||||
this.startObserver();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-mutation-observer': SlMutationObserver;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './mutation-observer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary The Mutation Observer component offers a thin, declarative interface to the [`MutationObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
|
||||
* @documentation https://shoelace.style/components/mutation-observer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event {{ mutationList: MutationRecord[] }} sl-mutation - Emitted when a mutation occurs.
|
||||
*
|
||||
* @slot - The content to watch for mutations.
|
||||
*/
|
||||
@customElement('sl-mutation-observer')
|
||||
export default class SlMutationObserver extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
/**
|
||||
* Watches for changes to attributes. To watch only specific attributes, separate them by a space, e.g.
|
||||
* `attr="class id title"`. To watch all attributes, use `*`.
|
||||
*/
|
||||
@property({ reflect: true }) attr: string;
|
||||
|
||||
/** Indicates whether or not the attribute's previous value should be recorded when monitoring changes. */
|
||||
@property({ attribute: 'attr-old-value', type: Boolean, reflect: true }) attrOldValue = false;
|
||||
|
||||
/** Watches for changes to the character data contained within the node. */
|
||||
@property({ attribute: 'char-data', type: Boolean, reflect: true }) charData = false;
|
||||
|
||||
/** Indicates whether or not the previous value of the node's text should be recorded. */
|
||||
@property({ attribute: 'char-data-old-value', type: Boolean, reflect: true }) charDataOldValue = false;
|
||||
|
||||
/** Watches for the addition or removal of new child nodes. */
|
||||
@property({ attribute: 'child-list', type: Boolean, reflect: true }) childList = false;
|
||||
|
||||
/** Disables the observer. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.mutationObserver = new MutationObserver(this.handleMutation);
|
||||
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopObserver();
|
||||
}
|
||||
|
||||
private handleMutation = (mutationList: MutationRecord[]) => {
|
||||
this.emit('sl-mutation', {
|
||||
detail: { mutationList }
|
||||
});
|
||||
};
|
||||
|
||||
private startObserver() {
|
||||
const observeAttributes = typeof this.attr === 'string' && this.attr.length > 0;
|
||||
const attributeFilter = observeAttributes && this.attr !== '*' ? this.attr.split(' ') : undefined;
|
||||
|
||||
try {
|
||||
this.mutationObserver.observe(this, {
|
||||
subtree: true,
|
||||
childList: this.childList,
|
||||
attributes: observeAttributes,
|
||||
attributeFilter,
|
||||
attributeOldValue: this.attrOldValue,
|
||||
characterData: this.charData,
|
||||
characterDataOldValue: this.charDataOldValue
|
||||
});
|
||||
} catch {
|
||||
//
|
||||
// A mutation observer was created without one of the required attributes: attr, char-data, or child-list. The
|
||||
// browser will normally throw an error, but we'll suppress that so it doesn't appear as attributes are added
|
||||
// and removed.
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
private stopObserver() {
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
if (this.disabled) {
|
||||
this.stopObserver();
|
||||
} else {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('attr', { waitUntilFirstUpdate: true })
|
||||
@watch('attr-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('childList', { waitUntilFirstUpdate: true })
|
||||
handleChange() {
|
||||
this.stopObserver();
|
||||
this.startObserver();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-mutation-observer': SlMutationObserver;
|
||||
}
|
||||
}
|
||||
import SlMutationObserver from './mutation-observer.component.js';
|
||||
export * from './mutation-observer.component.js';
|
||||
export default SlMutationObserver;
|
||||
SlMutationObserver.define('sl-mutation-observer');
|
||||
|
||||
139
src/components/option/option.component.ts
Normal file
139
src/components/option/option.component.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './option.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Options define the selectable items within various form controls such as [select](/components/select).
|
||||
* @documentation https://shoelace.style/components/option
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The option's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The option's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
export default class SlOption extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private cachedTextLabel: string;
|
||||
// @ts-expect-error - Controller is currently unused
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.option__label') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
|
||||
@state() selected = false; // the option is selected and has aria-selected="true"
|
||||
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
|
||||
|
||||
/**
|
||||
* The option's value. When selected, the containing form control will receive this value. The value must be unique
|
||||
* from other options in the same group. Values may not contain spaces, as spaces are used as delimiters when listing
|
||||
* multiple values.
|
||||
*/
|
||||
@property({ reflect: true }) value = '';
|
||||
|
||||
/** Draws the option in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'option');
|
||||
this.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseEnter() {
|
||||
this.hasHover = true;
|
||||
}
|
||||
|
||||
private handleMouseLeave() {
|
||||
this.hasHover = false;
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('selected')
|
||||
handleSelectedChange() {
|
||||
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('value')
|
||||
handleValueChange() {
|
||||
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
|
||||
// instead of requiring them to cast the value to a string.
|
||||
if (typeof this.value !== 'string') {
|
||||
this.value = String(this.value);
|
||||
}
|
||||
|
||||
if (this.value.includes(' ')) {
|
||||
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
|
||||
this.value = this.value.replace(/ /g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a plain text label based on the option's content. */
|
||||
getTextLabel() {
|
||||
return (this.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
option: true,
|
||||
'option--current': this.current,
|
||||
'option--disabled': this.disabled,
|
||||
'option--selected': this.selected,
|
||||
'option--hover': this.hasHover
|
||||
})}
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
@mouseleave=${this.handleMouseLeave}
|
||||
>
|
||||
<sl-icon part="checked-icon" class="option__check" name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
<slot part="prefix" name="prefix" class="option__prefix"></slot>
|
||||
<slot part="label" class="option__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
<slot part="suffix" name="suffix" class="option__suffix"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-option': SlOption;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './option.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Options define the selectable items within various form controls such as [select](/components/select).
|
||||
* @documentation https://shoelace.style/components/option
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The option's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The option's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
@customElement('sl-option')
|
||||
export default class SlOption extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private cachedTextLabel: string;
|
||||
// @ts-expect-error - Controller is currently unused
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.option__label') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
|
||||
@state() selected = false; // the option is selected and has aria-selected="true"
|
||||
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
|
||||
|
||||
/**
|
||||
* The option's value. When selected, the containing form control will receive this value. The value must be unique
|
||||
* from other options in the same group. Values may not contain spaces, as spaces are used as delimiters when listing
|
||||
* multiple values.
|
||||
*/
|
||||
@property({ reflect: true }) value = '';
|
||||
|
||||
/** Draws the option in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'option');
|
||||
this.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseEnter() {
|
||||
this.hasHover = true;
|
||||
}
|
||||
|
||||
private handleMouseLeave() {
|
||||
this.hasHover = false;
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('selected')
|
||||
handleSelectedChange() {
|
||||
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('value')
|
||||
handleValueChange() {
|
||||
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
|
||||
// instead of requiring them to cast the value to a string.
|
||||
if (typeof this.value !== 'string') {
|
||||
this.value = String(this.value);
|
||||
}
|
||||
|
||||
if (this.value.includes(' ')) {
|
||||
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
|
||||
this.value = this.value.replace(/ /g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a plain text label based on the option's content. */
|
||||
getTextLabel() {
|
||||
return (this.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
option: true,
|
||||
'option--current': this.current,
|
||||
'option--disabled': this.disabled,
|
||||
'option--selected': this.selected,
|
||||
'option--hover': this.hasHover
|
||||
})}
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
@mouseleave=${this.handleMouseLeave}
|
||||
>
|
||||
<sl-icon part="checked-icon" class="option__check" name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
<slot part="prefix" name="prefix" class="option__prefix"></slot>
|
||||
<slot part="label" class="option__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
<slot part="suffix" name="suffix" class="option__suffix"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-option': SlOption;
|
||||
}
|
||||
}
|
||||
import SlOption from './option.component.js';
|
||||
export * from './option.component.js';
|
||||
export default SlOption;
|
||||
SlOption.define('sl-option');
|
||||
|
||||
479
src/components/popup/popup.component.ts
Normal file
479
src/components/popup/popup.component.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { offsetParent } from 'composed-offset-position';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './popup.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
export interface VirtualElement {
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
}
|
||||
|
||||
function isVirtualElement(e: unknown): e is VirtualElement {
|
||||
return e !== null && typeof e === 'object' && 'getBoundingClientRect' in e;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Popup is a utility that lets you declaratively anchor "popup" containers to another element.
|
||||
* @documentation https://shoelace.style/components/popup
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-reposition - Emitted when the popup is repositioned. This event can fire a lot, so avoid putting expensive
|
||||
* operations in your listener or consider debouncing it.
|
||||
*
|
||||
* @slot - The popup's content.
|
||||
* @slot anchor - The element the popup will be anchored to. If the anchor lives outside of the popup, you can use the
|
||||
* `anchor` attribute or property instead.
|
||||
*
|
||||
* @csspart arrow - The arrow's container. Avoid setting `top|bottom|left|right` properties, as these values are
|
||||
* assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and
|
||||
* maybe a border or box shadow.
|
||||
* @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc.
|
||||
*
|
||||
* @cssproperty [--arrow-size=6px] - The size of the arrow. Note that an arrow won't be shown unless the `arrow`
|
||||
* attribute is used.
|
||||
* @cssproperty [--arrow-color=var(--sl-color-neutral-0)] - The color of the arrow.
|
||||
* @cssproperty [--auto-size-available-width] - A read-only custom property that determines the amount of width the
|
||||
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
|
||||
* available when using `auto-size`.
|
||||
* @cssproperty [--auto-size-available-height] - A read-only custom property that determines the amount of height the
|
||||
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
|
||||
* available when using `auto-size`.
|
||||
*/
|
||||
export default class SlPopup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private anchorEl: Element | VirtualElement | null;
|
||||
private cleanup: ReturnType<typeof autoUpdate> | undefined;
|
||||
|
||||
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
|
||||
@query('.popup') popup: HTMLElement;
|
||||
@query('.popup__arrow') private arrowEl: HTMLElement;
|
||||
|
||||
/**
|
||||
* The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide the anchor
|
||||
* element `id`, a DOM element reference, or a `VirtualElement`. If the anchor lives inside the popup, use the
|
||||
* `anchor` slot instead.
|
||||
*/
|
||||
@property() anchor: Element | string | VirtualElement;
|
||||
|
||||
/**
|
||||
* Activates the positioning logic and shows the popup. When this attribute is removed, the positioning logic is torn
|
||||
* down and the popup will be hidden.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) active = false;
|
||||
|
||||
/**
|
||||
* The preferred placement of the popup. Note that the actual placement will vary as configured to keep the
|
||||
* panel inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end' = 'top';
|
||||
|
||||
/**
|
||||
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if overflow is
|
||||
* clipped, using a `fixed` position strategy can often workaround it.
|
||||
*/
|
||||
@property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
|
||||
|
||||
/** The distance in pixels from which to offset the panel away from its anchor. */
|
||||
@property({ type: Number }) distance = 0;
|
||||
|
||||
/** The distance in pixels from which to offset the panel along its anchor. */
|
||||
@property({ type: Number }) skidding = 0;
|
||||
|
||||
/**
|
||||
* Attaches an arrow to the popup. The arrow's size and color can be customized using the `--arrow-size` and
|
||||
* `--arrow-color` custom properties. For additional customizations, you can also target the arrow using
|
||||
* `::part(arrow)` in your stylesheet.
|
||||
*/
|
||||
@property({ type: Boolean }) arrow = false;
|
||||
|
||||
/**
|
||||
* The placement of the arrow. The default is `anchor`, which will align the arrow as close to the center of the
|
||||
* anchor as possible, considering available space and `arrow-padding`. A value of `start`, `end`, or `center` will
|
||||
* align the arrow to the start, end, or center of the popover instead.
|
||||
*/
|
||||
@property({ attribute: 'arrow-placement' }) arrowPlacement: 'start' | 'end' | 'center' | 'anchor' = 'anchor';
|
||||
|
||||
/**
|
||||
* The amount of padding between the arrow and the edges of the popup. If the popup has a border-radius, for example,
|
||||
* this will prevent it from overflowing the corners.
|
||||
*/
|
||||
@property({ attribute: 'arrow-padding', type: Number }) arrowPadding = 10;
|
||||
|
||||
/**
|
||||
* When set, placement of the popup will flip to the opposite site to keep it in view. You can use
|
||||
* `flipFallbackPlacements` to further configure how the fallback placement is determined.
|
||||
*/
|
||||
@property({ type: Boolean }) flip = false;
|
||||
|
||||
/**
|
||||
* If the preferred placement doesn't fit, popup will be tested in these fallback placements until one fits. Must be a
|
||||
* string of any number of placements separated by a space, e.g. "top bottom left". If no placement fits, the flip
|
||||
* fallback strategy will be used instead.
|
||||
* */
|
||||
@property({
|
||||
attribute: 'flip-fallback-placements',
|
||||
converter: {
|
||||
fromAttribute: (value: string) => {
|
||||
return value
|
||||
.split(' ')
|
||||
.map(p => p.trim())
|
||||
.filter(p => p !== '');
|
||||
},
|
||||
toAttribute: (value: []) => {
|
||||
return value.join(' ');
|
||||
}
|
||||
}
|
||||
})
|
||||
flipFallbackPlacements = '';
|
||||
|
||||
/**
|
||||
* When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether
|
||||
* the popup should be positioned using the best available fit based on available space or as it was initially
|
||||
* preferred.
|
||||
*/
|
||||
@property({ attribute: 'flip-fallback-strategy' }) flipFallbackStrategy: 'best-fit' | 'initial' = 'best-fit';
|
||||
|
||||
/**
|
||||
* The flip boundary describes clipping element(s) that overflow will be checked relative to when flipping. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) flipBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the flip behavior will occur. */
|
||||
@property({ attribute: 'flip-padding', type: Number }) flipPadding = 0;
|
||||
|
||||
/** Moves the popup along the axis to keep it in view when clipped. */
|
||||
@property({ type: Boolean }) shift = false;
|
||||
|
||||
/**
|
||||
* The shift boundary describes clipping element(s) that overflow will be checked relative to when shifting. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) shiftBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the shift behavior will occur. */
|
||||
@property({ attribute: 'shift-padding', type: Number }) shiftPadding = 0;
|
||||
|
||||
/** When set, this will cause the popup to automatically resize itself to prevent it from overflowing. */
|
||||
@property({ attribute: 'auto-size' }) autoSize: 'horizontal' | 'vertical' | 'both';
|
||||
|
||||
/** Syncs the popup's width or height to that of the anchor element. */
|
||||
@property() sync: 'width' | 'height' | 'both';
|
||||
|
||||
/**
|
||||
* The auto-size boundary describes clipping element(s) that overflow will be checked relative to when resizing. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) autoSizeBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the auto-size behavior will occur. */
|
||||
@property({ attribute: 'auto-size-padding', type: Number }) autoSizePadding = 0;
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Start the positioner after the first update
|
||||
await this.updateComplete;
|
||||
this.start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
async updated(changedProps: Map<string, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
// Start or stop the positioner when active changes
|
||||
if (changedProps.has('active')) {
|
||||
if (this.active) {
|
||||
this.start();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Update the anchor when anchor changes
|
||||
if (changedProps.has('anchor')) {
|
||||
this.handleAnchorChange();
|
||||
}
|
||||
|
||||
// All other properties will trigger a reposition when active
|
||||
if (this.active) {
|
||||
await this.updateComplete;
|
||||
this.reposition();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAnchorChange() {
|
||||
await this.stop();
|
||||
|
||||
if (this.anchor && typeof this.anchor === 'string') {
|
||||
// Locate the anchor by id
|
||||
const root = this.getRootNode() as Document | ShadowRoot;
|
||||
this.anchorEl = root.getElementById(this.anchor);
|
||||
} else if (this.anchor instanceof Element || isVirtualElement(this.anchor)) {
|
||||
// Use the anchor's reference
|
||||
this.anchorEl = this.anchor;
|
||||
} else {
|
||||
// Look for a slotted anchor
|
||||
this.anchorEl = this.querySelector<HTMLElement>('[slot="anchor"]');
|
||||
}
|
||||
|
||||
// If the anchor is a <slot>, we'll use the first assigned element as the target since slots use `display: contents`
|
||||
// and positioning can't be calculated on them
|
||||
if (this.anchorEl instanceof HTMLSlotElement) {
|
||||
this.anchorEl = this.anchorEl.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||
}
|
||||
|
||||
if (!this.anchorEl) {
|
||||
throw new Error(
|
||||
'Invalid anchor element: no anchor could be found using the anchor slot or the anchor attribute.'
|
||||
);
|
||||
}
|
||||
|
||||
this.start();
|
||||
}
|
||||
|
||||
private start() {
|
||||
// We can't start the positioner without an anchor
|
||||
if (!this.anchorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {
|
||||
this.reposition();
|
||||
});
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (this.cleanup) {
|
||||
this.cleanup();
|
||||
this.cleanup = undefined;
|
||||
this.removeAttribute('data-current-placement');
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
requestAnimationFrame(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Forces the popup to recalculate and reposition itself. */
|
||||
reposition() {
|
||||
// Nothing to do if the popup is inactive or the anchor doesn't exist
|
||||
if (!this.active || !this.anchorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// NOTE: Floating UI middlewares are order dependent: https://floating-ui.com/docs/middleware
|
||||
//
|
||||
const middleware = [
|
||||
// The offset middleware goes first
|
||||
offset({ mainAxis: this.distance, crossAxis: this.skidding })
|
||||
];
|
||||
|
||||
// First we sync width/height
|
||||
if (this.sync) {
|
||||
middleware.push(
|
||||
size({
|
||||
apply: ({ rects }) => {
|
||||
const syncWidth = this.sync === 'width' || this.sync === 'both';
|
||||
const syncHeight = this.sync === 'height' || this.sync === 'both';
|
||||
this.popup.style.width = syncWidth ? `${rects.reference.width}px` : '';
|
||||
this.popup.style.height = syncHeight ? `${rects.reference.height}px` : '';
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Cleanup styles if we're not matching width/height
|
||||
this.popup.style.width = '';
|
||||
this.popup.style.height = '';
|
||||
}
|
||||
|
||||
// Then we flip
|
||||
if (this.flip) {
|
||||
middleware.push(
|
||||
flip({
|
||||
boundary: this.flipBoundary,
|
||||
// @ts-expect-error - We're converting a string attribute to an array here
|
||||
fallbackPlacements: this.flipFallbackPlacements,
|
||||
fallbackStrategy: this.flipFallbackStrategy === 'best-fit' ? 'bestFit' : 'initialPlacement',
|
||||
padding: this.flipPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Then we shift
|
||||
if (this.shift) {
|
||||
middleware.push(
|
||||
shift({
|
||||
boundary: this.shiftBoundary,
|
||||
padding: this.shiftPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Now we adjust the size as needed
|
||||
if (this.autoSize) {
|
||||
middleware.push(
|
||||
size({
|
||||
boundary: this.autoSizeBoundary,
|
||||
padding: this.autoSizePadding,
|
||||
apply: ({ availableWidth, availableHeight }) => {
|
||||
if (this.autoSize === 'vertical' || this.autoSize === 'both') {
|
||||
this.style.setProperty('--auto-size-available-height', `${availableHeight}px`);
|
||||
} else {
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
}
|
||||
|
||||
if (this.autoSize === 'horizontal' || this.autoSize === 'both') {
|
||||
this.style.setProperty('--auto-size-available-width', `${availableWidth}px`);
|
||||
} else {
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Cleanup styles if we're no longer using auto-size
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
}
|
||||
|
||||
// Finally, we add an arrow
|
||||
if (this.arrow) {
|
||||
middleware.push(
|
||||
arrow({
|
||||
element: this.arrowEl,
|
||||
padding: this.arrowPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Use custom positioning logic if the strategy is absolute. Otherwise, fall back to the default logic.
|
||||
//
|
||||
// More info: https://github.com/shoelace-style/shoelace/issues/1135
|
||||
//
|
||||
const getOffsetParent =
|
||||
this.strategy === 'absolute'
|
||||
? (element: Element) => platform.getOffsetParent(element, offsetParent)
|
||||
: platform.getOffsetParent;
|
||||
|
||||
computePosition(this.anchorEl, this.popup, {
|
||||
placement: this.placement,
|
||||
middleware,
|
||||
strategy: this.strategy,
|
||||
platform: {
|
||||
...platform,
|
||||
getOffsetParent
|
||||
}
|
||||
}).then(({ x, y, middlewareData, placement }) => {
|
||||
//
|
||||
// Even though we have our own localization utility, it uses different heuristics to determine RTL. Because of
|
||||
// that, we'll use the same approach that Floating UI uses.
|
||||
//
|
||||
// Source: https://github.com/floating-ui/floating-ui/blob/cb3b6ab07f95275730d3e6e46c702f8d4908b55c/packages/dom/src/utils/getDocumentRect.ts#L31
|
||||
//
|
||||
const isRtl = getComputedStyle(this).direction === 'rtl';
|
||||
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
|
||||
|
||||
this.setAttribute('data-current-placement', placement);
|
||||
|
||||
Object.assign(this.popup.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
});
|
||||
|
||||
if (this.arrow) {
|
||||
const arrowX = middlewareData.arrow!.x;
|
||||
const arrowY = middlewareData.arrow!.y;
|
||||
let top = '';
|
||||
let right = '';
|
||||
let bottom = '';
|
||||
let left = '';
|
||||
|
||||
if (this.arrowPlacement === 'start') {
|
||||
// Start
|
||||
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
top = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
right = isRtl ? value : '';
|
||||
left = isRtl ? '' : value;
|
||||
} else if (this.arrowPlacement === 'end') {
|
||||
// End
|
||||
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
right = isRtl ? '' : value;
|
||||
left = isRtl ? value : '';
|
||||
bottom = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
} else if (this.arrowPlacement === 'center') {
|
||||
// Center
|
||||
left = typeof arrowX === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
|
||||
top = typeof arrowY === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
|
||||
} else {
|
||||
// Anchor (default)
|
||||
left = typeof arrowX === 'number' ? `${arrowX}px` : '';
|
||||
top = typeof arrowY === 'number' ? `${arrowY}px` : '';
|
||||
}
|
||||
|
||||
Object.assign(this.arrowEl.style, {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left,
|
||||
[staticSide]: 'calc(var(--arrow-size-diagonal) * -1)'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.emit('sl-reposition');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot name="anchor" @slotchange=${this.handleAnchorChange}></slot>
|
||||
|
||||
<div
|
||||
part="popup"
|
||||
class=${classMap({
|
||||
popup: true,
|
||||
'popup--active': this.active,
|
||||
'popup--fixed': this.strategy === 'fixed',
|
||||
'popup--has-arrow': this.arrow
|
||||
})}
|
||||
>
|
||||
<slot></slot>
|
||||
${this.arrow ? html`<div part="arrow" class="popup__arrow" role="presentation"></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-popup': SlPopup;
|
||||
}
|
||||
}
|
||||
@@ -1,480 +1,4 @@
|
||||
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { offsetParent } from 'composed-offset-position';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './popup.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
export interface VirtualElement {
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
}
|
||||
|
||||
function isVirtualElement(e: unknown): e is VirtualElement {
|
||||
return e !== null && typeof e === 'object' && 'getBoundingClientRect' in e;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Popup is a utility that lets you declaratively anchor "popup" containers to another element.
|
||||
* @documentation https://shoelace.style/components/popup
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-reposition - Emitted when the popup is repositioned. This event can fire a lot, so avoid putting expensive
|
||||
* operations in your listener or consider debouncing it.
|
||||
*
|
||||
* @slot - The popup's content.
|
||||
* @slot anchor - The element the popup will be anchored to. If the anchor lives outside of the popup, you can use the
|
||||
* `anchor` attribute or property instead.
|
||||
*
|
||||
* @csspart arrow - The arrow's container. Avoid setting `top|bottom|left|right` properties, as these values are
|
||||
* assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and
|
||||
* maybe a border or box shadow.
|
||||
* @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc.
|
||||
*
|
||||
* @cssproperty [--arrow-size=6px] - The size of the arrow. Note that an arrow won't be shown unless the `arrow`
|
||||
* attribute is used.
|
||||
* @cssproperty [--arrow-color=var(--sl-color-neutral-0)] - The color of the arrow.
|
||||
* @cssproperty [--auto-size-available-width] - A read-only custom property that determines the amount of width the
|
||||
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
|
||||
* available when using `auto-size`.
|
||||
* @cssproperty [--auto-size-available-height] - A read-only custom property that determines the amount of height the
|
||||
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
|
||||
* available when using `auto-size`.
|
||||
*/
|
||||
@customElement('sl-popup')
|
||||
export default class SlPopup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private anchorEl: Element | VirtualElement | null;
|
||||
private cleanup: ReturnType<typeof autoUpdate> | undefined;
|
||||
|
||||
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
|
||||
@query('.popup') popup: HTMLElement;
|
||||
@query('.popup__arrow') private arrowEl: HTMLElement;
|
||||
|
||||
/**
|
||||
* The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide the anchor
|
||||
* element `id`, a DOM element reference, or a `VirtualElement`. If the anchor lives inside the popup, use the
|
||||
* `anchor` slot instead.
|
||||
*/
|
||||
@property() anchor: Element | string | VirtualElement;
|
||||
|
||||
/**
|
||||
* Activates the positioning logic and shows the popup. When this attribute is removed, the positioning logic is torn
|
||||
* down and the popup will be hidden.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) active = false;
|
||||
|
||||
/**
|
||||
* The preferred placement of the popup. Note that the actual placement will vary as configured to keep the
|
||||
* panel inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end' = 'top';
|
||||
|
||||
/**
|
||||
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if overflow is
|
||||
* clipped, using a `fixed` position strategy can often workaround it.
|
||||
*/
|
||||
@property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
|
||||
|
||||
/** The distance in pixels from which to offset the panel away from its anchor. */
|
||||
@property({ type: Number }) distance = 0;
|
||||
|
||||
/** The distance in pixels from which to offset the panel along its anchor. */
|
||||
@property({ type: Number }) skidding = 0;
|
||||
|
||||
/**
|
||||
* Attaches an arrow to the popup. The arrow's size and color can be customized using the `--arrow-size` and
|
||||
* `--arrow-color` custom properties. For additional customizations, you can also target the arrow using
|
||||
* `::part(arrow)` in your stylesheet.
|
||||
*/
|
||||
@property({ type: Boolean }) arrow = false;
|
||||
|
||||
/**
|
||||
* The placement of the arrow. The default is `anchor`, which will align the arrow as close to the center of the
|
||||
* anchor as possible, considering available space and `arrow-padding`. A value of `start`, `end`, or `center` will
|
||||
* align the arrow to the start, end, or center of the popover instead.
|
||||
*/
|
||||
@property({ attribute: 'arrow-placement' }) arrowPlacement: 'start' | 'end' | 'center' | 'anchor' = 'anchor';
|
||||
|
||||
/**
|
||||
* The amount of padding between the arrow and the edges of the popup. If the popup has a border-radius, for example,
|
||||
* this will prevent it from overflowing the corners.
|
||||
*/
|
||||
@property({ attribute: 'arrow-padding', type: Number }) arrowPadding = 10;
|
||||
|
||||
/**
|
||||
* When set, placement of the popup will flip to the opposite site to keep it in view. You can use
|
||||
* `flipFallbackPlacements` to further configure how the fallback placement is determined.
|
||||
*/
|
||||
@property({ type: Boolean }) flip = false;
|
||||
|
||||
/**
|
||||
* If the preferred placement doesn't fit, popup will be tested in these fallback placements until one fits. Must be a
|
||||
* string of any number of placements separated by a space, e.g. "top bottom left". If no placement fits, the flip
|
||||
* fallback strategy will be used instead.
|
||||
* */
|
||||
@property({
|
||||
attribute: 'flip-fallback-placements',
|
||||
converter: {
|
||||
fromAttribute: (value: string) => {
|
||||
return value
|
||||
.split(' ')
|
||||
.map(p => p.trim())
|
||||
.filter(p => p !== '');
|
||||
},
|
||||
toAttribute: (value: []) => {
|
||||
return value.join(' ');
|
||||
}
|
||||
}
|
||||
})
|
||||
flipFallbackPlacements = '';
|
||||
|
||||
/**
|
||||
* When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether
|
||||
* the popup should be positioned using the best available fit based on available space or as it was initially
|
||||
* preferred.
|
||||
*/
|
||||
@property({ attribute: 'flip-fallback-strategy' }) flipFallbackStrategy: 'best-fit' | 'initial' = 'best-fit';
|
||||
|
||||
/**
|
||||
* The flip boundary describes clipping element(s) that overflow will be checked relative to when flipping. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) flipBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the flip behavior will occur. */
|
||||
@property({ attribute: 'flip-padding', type: Number }) flipPadding = 0;
|
||||
|
||||
/** Moves the popup along the axis to keep it in view when clipped. */
|
||||
@property({ type: Boolean }) shift = false;
|
||||
|
||||
/**
|
||||
* The shift boundary describes clipping element(s) that overflow will be checked relative to when shifting. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) shiftBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the shift behavior will occur. */
|
||||
@property({ attribute: 'shift-padding', type: Number }) shiftPadding = 0;
|
||||
|
||||
/** When set, this will cause the popup to automatically resize itself to prevent it from overflowing. */
|
||||
@property({ attribute: 'auto-size' }) autoSize: 'horizontal' | 'vertical' | 'both';
|
||||
|
||||
/** Syncs the popup's width or height to that of the anchor element. */
|
||||
@property() sync: 'width' | 'height' | 'both';
|
||||
|
||||
/**
|
||||
* The auto-size boundary describes clipping element(s) that overflow will be checked relative to when resizing. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) autoSizeBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the auto-size behavior will occur. */
|
||||
@property({ attribute: 'auto-size-padding', type: Number }) autoSizePadding = 0;
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Start the positioner after the first update
|
||||
await this.updateComplete;
|
||||
this.start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
async updated(changedProps: Map<string, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
// Start or stop the positioner when active changes
|
||||
if (changedProps.has('active')) {
|
||||
if (this.active) {
|
||||
this.start();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Update the anchor when anchor changes
|
||||
if (changedProps.has('anchor')) {
|
||||
this.handleAnchorChange();
|
||||
}
|
||||
|
||||
// All other properties will trigger a reposition when active
|
||||
if (this.active) {
|
||||
await this.updateComplete;
|
||||
this.reposition();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAnchorChange() {
|
||||
await this.stop();
|
||||
|
||||
if (this.anchor && typeof this.anchor === 'string') {
|
||||
// Locate the anchor by id
|
||||
const root = this.getRootNode() as Document | ShadowRoot;
|
||||
this.anchorEl = root.getElementById(this.anchor);
|
||||
} else if (this.anchor instanceof Element || isVirtualElement(this.anchor)) {
|
||||
// Use the anchor's reference
|
||||
this.anchorEl = this.anchor;
|
||||
} else {
|
||||
// Look for a slotted anchor
|
||||
this.anchorEl = this.querySelector<HTMLElement>('[slot="anchor"]');
|
||||
}
|
||||
|
||||
// If the anchor is a <slot>, we'll use the first assigned element as the target since slots use `display: contents`
|
||||
// and positioning can't be calculated on them
|
||||
if (this.anchorEl instanceof HTMLSlotElement) {
|
||||
this.anchorEl = this.anchorEl.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||
}
|
||||
|
||||
if (!this.anchorEl) {
|
||||
throw new Error(
|
||||
'Invalid anchor element: no anchor could be found using the anchor slot or the anchor attribute.'
|
||||
);
|
||||
}
|
||||
|
||||
this.start();
|
||||
}
|
||||
|
||||
private start() {
|
||||
// We can't start the positioner without an anchor
|
||||
if (!this.anchorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {
|
||||
this.reposition();
|
||||
});
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (this.cleanup) {
|
||||
this.cleanup();
|
||||
this.cleanup = undefined;
|
||||
this.removeAttribute('data-current-placement');
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
requestAnimationFrame(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Forces the popup to recalculate and reposition itself. */
|
||||
reposition() {
|
||||
// Nothing to do if the popup is inactive or the anchor doesn't exist
|
||||
if (!this.active || !this.anchorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// NOTE: Floating UI middlewares are order dependent: https://floating-ui.com/docs/middleware
|
||||
//
|
||||
const middleware = [
|
||||
// The offset middleware goes first
|
||||
offset({ mainAxis: this.distance, crossAxis: this.skidding })
|
||||
];
|
||||
|
||||
// First we sync width/height
|
||||
if (this.sync) {
|
||||
middleware.push(
|
||||
size({
|
||||
apply: ({ rects }) => {
|
||||
const syncWidth = this.sync === 'width' || this.sync === 'both';
|
||||
const syncHeight = this.sync === 'height' || this.sync === 'both';
|
||||
this.popup.style.width = syncWidth ? `${rects.reference.width}px` : '';
|
||||
this.popup.style.height = syncHeight ? `${rects.reference.height}px` : '';
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Cleanup styles if we're not matching width/height
|
||||
this.popup.style.width = '';
|
||||
this.popup.style.height = '';
|
||||
}
|
||||
|
||||
// Then we flip
|
||||
if (this.flip) {
|
||||
middleware.push(
|
||||
flip({
|
||||
boundary: this.flipBoundary,
|
||||
// @ts-expect-error - We're converting a string attribute to an array here
|
||||
fallbackPlacements: this.flipFallbackPlacements,
|
||||
fallbackStrategy: this.flipFallbackStrategy === 'best-fit' ? 'bestFit' : 'initialPlacement',
|
||||
padding: this.flipPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Then we shift
|
||||
if (this.shift) {
|
||||
middleware.push(
|
||||
shift({
|
||||
boundary: this.shiftBoundary,
|
||||
padding: this.shiftPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Now we adjust the size as needed
|
||||
if (this.autoSize) {
|
||||
middleware.push(
|
||||
size({
|
||||
boundary: this.autoSizeBoundary,
|
||||
padding: this.autoSizePadding,
|
||||
apply: ({ availableWidth, availableHeight }) => {
|
||||
if (this.autoSize === 'vertical' || this.autoSize === 'both') {
|
||||
this.style.setProperty('--auto-size-available-height', `${availableHeight}px`);
|
||||
} else {
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
}
|
||||
|
||||
if (this.autoSize === 'horizontal' || this.autoSize === 'both') {
|
||||
this.style.setProperty('--auto-size-available-width', `${availableWidth}px`);
|
||||
} else {
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Cleanup styles if we're no longer using auto-size
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
}
|
||||
|
||||
// Finally, we add an arrow
|
||||
if (this.arrow) {
|
||||
middleware.push(
|
||||
arrow({
|
||||
element: this.arrowEl,
|
||||
padding: this.arrowPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Use custom positioning logic if the strategy is absolute. Otherwise, fall back to the default logic.
|
||||
//
|
||||
// More info: https://github.com/shoelace-style/shoelace/issues/1135
|
||||
//
|
||||
const getOffsetParent =
|
||||
this.strategy === 'absolute'
|
||||
? (element: Element) => platform.getOffsetParent(element, offsetParent)
|
||||
: platform.getOffsetParent;
|
||||
|
||||
computePosition(this.anchorEl, this.popup, {
|
||||
placement: this.placement,
|
||||
middleware,
|
||||
strategy: this.strategy,
|
||||
platform: {
|
||||
...platform,
|
||||
getOffsetParent
|
||||
}
|
||||
}).then(({ x, y, middlewareData, placement }) => {
|
||||
//
|
||||
// Even though we have our own localization utility, it uses different heuristics to determine RTL. Because of
|
||||
// that, we'll use the same approach that Floating UI uses.
|
||||
//
|
||||
// Source: https://github.com/floating-ui/floating-ui/blob/cb3b6ab07f95275730d3e6e46c702f8d4908b55c/packages/dom/src/utils/getDocumentRect.ts#L31
|
||||
//
|
||||
const isRtl = getComputedStyle(this).direction === 'rtl';
|
||||
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
|
||||
|
||||
this.setAttribute('data-current-placement', placement);
|
||||
|
||||
Object.assign(this.popup.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
});
|
||||
|
||||
if (this.arrow) {
|
||||
const arrowX = middlewareData.arrow!.x;
|
||||
const arrowY = middlewareData.arrow!.y;
|
||||
let top = '';
|
||||
let right = '';
|
||||
let bottom = '';
|
||||
let left = '';
|
||||
|
||||
if (this.arrowPlacement === 'start') {
|
||||
// Start
|
||||
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
top = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
right = isRtl ? value : '';
|
||||
left = isRtl ? '' : value;
|
||||
} else if (this.arrowPlacement === 'end') {
|
||||
// End
|
||||
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
right = isRtl ? '' : value;
|
||||
left = isRtl ? value : '';
|
||||
bottom = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
} else if (this.arrowPlacement === 'center') {
|
||||
// Center
|
||||
left = typeof arrowX === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
|
||||
top = typeof arrowY === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
|
||||
} else {
|
||||
// Anchor (default)
|
||||
left = typeof arrowX === 'number' ? `${arrowX}px` : '';
|
||||
top = typeof arrowY === 'number' ? `${arrowY}px` : '';
|
||||
}
|
||||
|
||||
Object.assign(this.arrowEl.style, {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left,
|
||||
[staticSide]: 'calc(var(--arrow-size-diagonal) * -1)'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.emit('sl-reposition');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot name="anchor" @slotchange=${this.handleAnchorChange}></slot>
|
||||
|
||||
<div
|
||||
part="popup"
|
||||
class=${classMap({
|
||||
popup: true,
|
||||
'popup--active': this.active,
|
||||
'popup--fixed': this.strategy === 'fixed',
|
||||
'popup--has-arrow': this.arrow
|
||||
})}
|
||||
>
|
||||
<slot></slot>
|
||||
${this.arrow ? html`<div part="arrow" class="popup__arrow" role="presentation"></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-popup': SlPopup;
|
||||
}
|
||||
}
|
||||
import SlPopup from './popup.component.js';
|
||||
export * from './popup.component.js';
|
||||
export default SlPopup;
|
||||
SlPopup.define('sl-popup');
|
||||
|
||||
69
src/components/progress-bar/progress-bar.component.ts
Normal file
69
src/components/progress-bar/progress-bar.component.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './progress-bar.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Progress bars are used to show the status of an ongoing operation.
|
||||
* @documentation https://shoelace.style/components/progress-bar
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - A label to show inside the progress indicator.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart indicator - The progress bar's indicator.
|
||||
* @csspart label - The progress bar's label.
|
||||
*
|
||||
* @cssproperty --height - The progress bar's height.
|
||||
* @cssproperty --track-color - The color of the track.
|
||||
* @cssproperty --indicator-color - The color of the indicator.
|
||||
* @cssproperty --label-color - The color of the label.
|
||||
*/
|
||||
export default class SlProgressBar extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The current progress as a percentage, 0 to 100. */
|
||||
@property({ type: Number, reflect: true }) value = 0;
|
||||
|
||||
/** When true, percentage is ignored, the label is hidden, and the progress bar is drawn in an indeterminate state. */
|
||||
@property({ type: Boolean, reflect: true }) indeterminate = false;
|
||||
|
||||
/** A custom label for assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'progress-bar': true,
|
||||
'progress-bar--indeterminate': this.indeterminate,
|
||||
'progress-bar--rtl': this.localize.dir() === 'rtl'
|
||||
})}
|
||||
role="progressbar"
|
||||
title=${ifDefined(this.title)}
|
||||
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow=${this.indeterminate ? 0 : this.value}
|
||||
>
|
||||
<div part="indicator" class="progress-bar__indicator" style=${styleMap({ width: `${this.value}%` })}>
|
||||
${!this.indeterminate ? html` <slot part="label" class="progress-bar__label"></slot> ` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-progress-bar': SlProgressBar;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './progress-bar.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Progress bars are used to show the status of an ongoing operation.
|
||||
* @documentation https://shoelace.style/components/progress-bar
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - A label to show inside the progress indicator.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart indicator - The progress bar's indicator.
|
||||
* @csspart label - The progress bar's label.
|
||||
*
|
||||
* @cssproperty --height - The progress bar's height.
|
||||
* @cssproperty --track-color - The color of the track.
|
||||
* @cssproperty --indicator-color - The color of the indicator.
|
||||
* @cssproperty --label-color - The color of the label.
|
||||
*/
|
||||
@customElement('sl-progress-bar')
|
||||
export default class SlProgressBar extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The current progress as a percentage, 0 to 100. */
|
||||
@property({ type: Number, reflect: true }) value = 0;
|
||||
|
||||
/** When true, percentage is ignored, the label is hidden, and the progress bar is drawn in an indeterminate state. */
|
||||
@property({ type: Boolean, reflect: true }) indeterminate = false;
|
||||
|
||||
/** A custom label for assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'progress-bar': true,
|
||||
'progress-bar--indeterminate': this.indeterminate,
|
||||
'progress-bar--rtl': this.localize.dir() === 'rtl'
|
||||
})}
|
||||
role="progressbar"
|
||||
title=${ifDefined(this.title)}
|
||||
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow=${this.indeterminate ? 0 : this.value}
|
||||
>
|
||||
<div part="indicator" class="progress-bar__indicator" style=${styleMap({ width: `${this.value}%` })}>
|
||||
${!this.indeterminate ? html` <slot part="label" class="progress-bar__label"></slot> ` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-progress-bar': SlProgressBar;
|
||||
}
|
||||
}
|
||||
import SlProgressBar from './progress-bar.component.js';
|
||||
export * from './progress-bar.component.js';
|
||||
export default SlProgressBar;
|
||||
SlProgressBar.define('sl-progress-bar');
|
||||
|
||||
86
src/components/progress-ring/progress-ring.component.ts
Normal file
86
src/components/progress-ring/progress-ring.component.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './progress-ring.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Progress rings are used to show the progress of a determinate operation in a circular fashion.
|
||||
* @documentation https://shoelace.style/components/progress-ring
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - A label to show inside the ring.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The progress ring label.
|
||||
*
|
||||
* @cssproperty --size - The diameter of the progress ring (cannot be a percentage).
|
||||
* @cssproperty --track-width - The width of the track.
|
||||
* @cssproperty --track-color - The color of the track.
|
||||
* @cssproperty --indicator-width - The width of the indicator. Defaults to the track width.
|
||||
* @cssproperty --indicator-color - The color of the indicator.
|
||||
* @cssproperty --indicator-transition-duration - The duration of the indicator's transition when the value changes.
|
||||
*/
|
||||
export default class SlProgressRing extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.progress-ring__indicator') indicator: SVGCircleElement;
|
||||
|
||||
@state() indicatorOffset: string;
|
||||
|
||||
/** The current progress as a percentage, 0 to 100. */
|
||||
@property({ type: Number, reflect: true }) value = 0;
|
||||
|
||||
/** A custom label for assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
updated(changedProps: Map<string, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
//
|
||||
// This block is only required for Safari because it doesn't transition the circle when the custom properties
|
||||
// change, possibly because of a mix of pixel + unit-less values in the calc() function. It seems like a Safari bug,
|
||||
// but I couldn't pinpoint it so this works around the problem.
|
||||
//
|
||||
if (changedProps.has('value')) {
|
||||
const radius = parseFloat(getComputedStyle(this.indicator).getPropertyValue('r'));
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (this.value / 100) * circumference;
|
||||
|
||||
this.indicatorOffset = `${offset}px`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class="progress-ring"
|
||||
role="progressbar"
|
||||
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
|
||||
aria-describedby="label"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="${this.value}"
|
||||
style="--percentage: ${this.value / 100}"
|
||||
>
|
||||
<svg class="progress-ring__image">
|
||||
<circle class="progress-ring__track"></circle>
|
||||
<circle class="progress-ring__indicator" style="stroke-dashoffset: ${this.indicatorOffset}"></circle>
|
||||
</svg>
|
||||
|
||||
<slot id="label" part="label" class="progress-ring__label"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-progress-ring': SlProgressRing;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,4 @@
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './progress-ring.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Progress rings are used to show the progress of a determinate operation in a circular fashion.
|
||||
* @documentation https://shoelace.style/components/progress-ring
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - A label to show inside the ring.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The progress ring label.
|
||||
*
|
||||
* @cssproperty --size - The diameter of the progress ring (cannot be a percentage).
|
||||
* @cssproperty --track-width - The width of the track.
|
||||
* @cssproperty --track-color - The color of the track.
|
||||
* @cssproperty --indicator-width - The width of the indicator. Defaults to the track width.
|
||||
* @cssproperty --indicator-color - The color of the indicator.
|
||||
* @cssproperty --indicator-transition-duration - The duration of the indicator's transition when the value changes.
|
||||
*/
|
||||
@customElement('sl-progress-ring')
|
||||
export default class SlProgressRing extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.progress-ring__indicator') indicator: SVGCircleElement;
|
||||
|
||||
@state() indicatorOffset: string;
|
||||
|
||||
/** The current progress as a percentage, 0 to 100. */
|
||||
@property({ type: Number, reflect: true }) value = 0;
|
||||
|
||||
/** A custom label for assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
updated(changedProps: Map<string, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
//
|
||||
// This block is only required for Safari because it doesn't transition the circle when the custom properties
|
||||
// change, possibly because of a mix of pixel + unit-less values in the calc() function. It seems like a Safari bug,
|
||||
// but I couldn't pinpoint it so this works around the problem.
|
||||
//
|
||||
if (changedProps.has('value')) {
|
||||
const radius = parseFloat(getComputedStyle(this.indicator).getPropertyValue('r'));
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (this.value / 100) * circumference;
|
||||
|
||||
this.indicatorOffset = `${offset}px`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class="progress-ring"
|
||||
role="progressbar"
|
||||
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
|
||||
aria-describedby="label"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="${this.value}"
|
||||
style="--percentage: ${this.value / 100}"
|
||||
>
|
||||
<svg class="progress-ring__image">
|
||||
<circle class="progress-ring__track"></circle>
|
||||
<circle class="progress-ring__indicator" style="stroke-dashoffset: ${this.indicatorOffset}"></circle>
|
||||
</svg>
|
||||
|
||||
<slot id="label" part="label" class="progress-ring__label"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-progress-ring': SlProgressRing;
|
||||
}
|
||||
}
|
||||
import SlProgressRing from './progress-ring.component.js';
|
||||
export * from './progress-ring.component.js';
|
||||
export default SlProgressRing;
|
||||
SlProgressRing.define('sl-progress-ring');
|
||||
|
||||
88
src/components/qr-code/qr-code.component.ts
Normal file
88
src/components/qr-code/qr-code.component.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import QrCreator from 'qr-creator';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './qr-code.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Generates a [QR code](https://www.qrcode.com/) and renders it using the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
|
||||
* @documentation https://shoelace.style/components/qr-code
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlQrCode extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('canvas') canvas: HTMLElement;
|
||||
|
||||
/** The QR code's value. */
|
||||
@property() value = '';
|
||||
|
||||
/** The label for assistive devices to announce. If unspecified, the value will be used instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The size of the QR code, in pixels. */
|
||||
@property({ type: Number }) size = 128;
|
||||
|
||||
/** The fill color. This can be any valid CSS color, but not a CSS custom property. */
|
||||
@property() fill = 'black';
|
||||
|
||||
/** The background color. This can be any valid CSS color or `transparent`. It cannot be a CSS custom property. */
|
||||
@property() background = 'white';
|
||||
|
||||
/** The edge radius of each module. Must be between 0 and 0.5. */
|
||||
@property({ type: Number }) radius = 0;
|
||||
|
||||
/** The level of error correction to use. [Learn more](https://www.qrcode.com/en/about/error_correction.html) */
|
||||
@property({ attribute: 'error-correction' }) errorCorrection: 'L' | 'M' | 'Q' | 'H' = 'H';
|
||||
|
||||
firstUpdated() {
|
||||
this.generate();
|
||||
}
|
||||
|
||||
@watch(['background', 'errorCorrection', 'fill', 'radius', 'size', 'value'])
|
||||
generate() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
QrCreator.render(
|
||||
{
|
||||
text: this.value,
|
||||
radius: this.radius,
|
||||
ecLevel: this.errorCorrection,
|
||||
fill: this.fill,
|
||||
background: this.background,
|
||||
// We draw the canvas larger and scale its container down to avoid blurring on high-density displays
|
||||
size: this.size * 2
|
||||
},
|
||||
this.canvas
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<canvas
|
||||
part="base"
|
||||
class="qr-code"
|
||||
role="img"
|
||||
aria-label=${this.label?.length > 0 ? this.label : this.value}
|
||||
style=${styleMap({
|
||||
width: `${this.size}px`,
|
||||
height: `${this.size}px`
|
||||
})}
|
||||
></canvas>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-qr-code': SlQrCode;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,4 @@
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import QrCreator from 'qr-creator';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './qr-code.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Generates a [QR code](https://www.qrcode.com/) and renders it using the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
|
||||
* @documentation https://shoelace.style/components/qr-code
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-qr-code')
|
||||
export default class SlQrCode extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('canvas') canvas: HTMLElement;
|
||||
|
||||
/** The QR code's value. */
|
||||
@property() value = '';
|
||||
|
||||
/** The label for assistive devices to announce. If unspecified, the value will be used instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The size of the QR code, in pixels. */
|
||||
@property({ type: Number }) size = 128;
|
||||
|
||||
/** The fill color. This can be any valid CSS color, but not a CSS custom property. */
|
||||
@property() fill = 'black';
|
||||
|
||||
/** The background color. This can be any valid CSS color or `transparent`. It cannot be a CSS custom property. */
|
||||
@property() background = 'white';
|
||||
|
||||
/** The edge radius of each module. Must be between 0 and 0.5. */
|
||||
@property({ type: Number }) radius = 0;
|
||||
|
||||
/** The level of error correction to use. [Learn more](https://www.qrcode.com/en/about/error_correction.html) */
|
||||
@property({ attribute: 'error-correction' }) errorCorrection: 'L' | 'M' | 'Q' | 'H' = 'H';
|
||||
|
||||
firstUpdated() {
|
||||
this.generate();
|
||||
}
|
||||
|
||||
@watch(['background', 'errorCorrection', 'fill', 'radius', 'size', 'value'])
|
||||
generate() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
QrCreator.render(
|
||||
{
|
||||
text: this.value,
|
||||
radius: this.radius,
|
||||
ecLevel: this.errorCorrection,
|
||||
fill: this.fill,
|
||||
background: this.background,
|
||||
// We draw the canvas larger and scale its container down to avoid blurring on high-density displays
|
||||
size: this.size * 2
|
||||
},
|
||||
this.canvas
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<canvas
|
||||
part="base"
|
||||
class="qr-code"
|
||||
role="img"
|
||||
aria-label=${this.label?.length > 0 ? this.label : this.value}
|
||||
style=${styleMap({
|
||||
width: `${this.size}px`,
|
||||
height: `${this.size}px`
|
||||
})}
|
||||
></canvas>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-qr-code': SlQrCode;
|
||||
}
|
||||
}
|
||||
import SlQrCode from './qr-code.component.js';
|
||||
export * from './qr-code.component.js';
|
||||
export default SlQrCode;
|
||||
SlQrCode.define('sl-qr-code');
|
||||
|
||||
145
src/components/radio-button/radio-button.component.ts
Normal file
145
src/components/radio-button/radio-button.component.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './radio-button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Radios buttons allow the user to select a single option from a group using a button-like control.
|
||||
* @documentation https://shoelace.style/components/radio-button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The radio button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
* @slot suffix - A presentational suffix icon or similar element.
|
||||
*
|
||||
* @event sl-blur - Emitted when the button loses focus.
|
||||
* @event sl-focus - Emitted when the button gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart button - The internal `<button>` element.
|
||||
* @csspart button--checked - The internal button element when the radio button is checked.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart label - The container that wraps the radio button's label.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
export default class SlRadioButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
|
||||
@query('.button') input: HTMLInputElement;
|
||||
@query('.hidden-input') hiddenInput: HTMLInputElement;
|
||||
|
||||
@state() protected hasFocus = false;
|
||||
|
||||
/**
|
||||
* @internal The radio button's checked state. This is exposed as an "internal" attribute so we can reflect it, making
|
||||
* it easier to style in button groups.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** The radio's value. When selected, the radio group will receive this value. */
|
||||
@property() value: string;
|
||||
|
||||
/** Disables the radio button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* The radio button's size. When used inside a radio group, the size will be determined by the radio group's size so
|
||||
* this attribute can typically be omitted.
|
||||
*/
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws a pill-style radio button with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'presentation');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleClick(e: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.checked = true;
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/** Sets focus on the radio button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the radio button. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div part="base" role="presentation">
|
||||
<button
|
||||
part="${`button${this.checked ? ' button--checked' : ''}`}"
|
||||
role="radio"
|
||||
aria-checked="${this.checked}"
|
||||
class=${classMap({
|
||||
button: true,
|
||||
'button--default': true,
|
||||
'button--small': this.size === 'small',
|
||||
'button--medium': this.size === 'medium',
|
||||
'button--large': this.size === 'large',
|
||||
'button--checked': this.checked,
|
||||
'button--disabled': this.disabled,
|
||||
'button--focused': this.hasFocus,
|
||||
'button--outline': true,
|
||||
'button--pill': this.pill,
|
||||
'button--has-label': this.hasSlotController.test('[default]'),
|
||||
'button--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'button--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
aria-disabled=${this.disabled}
|
||||
type="button"
|
||||
value=${ifDefined(this.value)}
|
||||
tabindex="${this.checked ? '0' : '-1'}"
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="button__prefix"></slot>
|
||||
<slot part="label" class="button__label"></slot>
|
||||
<slot name="suffix" part="suffix" class="button__suffix"></slot>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-radio-button': SlRadioButton;
|
||||
}
|
||||
}
|
||||
@@ -1,146 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './radio-button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Radios buttons allow the user to select a single option from a group using a button-like control.
|
||||
* @documentation https://shoelace.style/components/radio-button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The radio button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
* @slot suffix - A presentational suffix icon or similar element.
|
||||
*
|
||||
* @event sl-blur - Emitted when the button loses focus.
|
||||
* @event sl-focus - Emitted when the button gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart button - The internal `<button>` element.
|
||||
* @csspart button--checked - The internal button element when the radio button is checked.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart label - The container that wraps the radio button's label.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
@customElement('sl-radio-button')
|
||||
export default class SlRadioButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
|
||||
@query('.button') input: HTMLInputElement;
|
||||
@query('.hidden-input') hiddenInput: HTMLInputElement;
|
||||
|
||||
@state() protected hasFocus = false;
|
||||
|
||||
/**
|
||||
* @internal The radio button's checked state. This is exposed as an "internal" attribute so we can reflect it, making
|
||||
* it easier to style in button groups.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** The radio's value. When selected, the radio group will receive this value. */
|
||||
@property() value: string;
|
||||
|
||||
/** Disables the radio button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* The radio button's size. When used inside a radio group, the size will be determined by the radio group's size so
|
||||
* this attribute can typically be omitted.
|
||||
*/
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws a pill-style radio button with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'presentation');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleClick(e: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.checked = true;
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/** Sets focus on the radio button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the radio button. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div part="base" role="presentation">
|
||||
<button
|
||||
part="${`button${this.checked ? ' button--checked' : ''}`}"
|
||||
role="radio"
|
||||
aria-checked="${this.checked}"
|
||||
class=${classMap({
|
||||
button: true,
|
||||
'button--default': true,
|
||||
'button--small': this.size === 'small',
|
||||
'button--medium': this.size === 'medium',
|
||||
'button--large': this.size === 'large',
|
||||
'button--checked': this.checked,
|
||||
'button--disabled': this.disabled,
|
||||
'button--focused': this.hasFocus,
|
||||
'button--outline': true,
|
||||
'button--pill': this.pill,
|
||||
'button--has-label': this.hasSlotController.test('[default]'),
|
||||
'button--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'button--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
aria-disabled=${this.disabled}
|
||||
type="button"
|
||||
value=${ifDefined(this.value)}
|
||||
tabindex="${this.checked ? '0' : '-1'}"
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="button__prefix"></slot>
|
||||
<slot part="label" class="button__label"></slot>
|
||||
<slot name="suffix" part="suffix" class="button__suffix"></slot>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-radio-button': SlRadioButton;
|
||||
}
|
||||
}
|
||||
import SlRadioButton from './radio-button.component.js';
|
||||
export * from './radio-button.component.js';
|
||||
export default SlRadioButton;
|
||||
SlRadioButton.define('sl-radio-button');
|
||||
|
||||
406
src/components/radio-group/radio-group.component.ts
Normal file
406
src/components/radio-group/radio-group.component.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import {
|
||||
customErrorValidityState,
|
||||
FormControlController,
|
||||
validValidityState,
|
||||
valueMissingValidityState
|
||||
} from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlButtonGroup from '../button-group/button-group.component.js';
|
||||
import styles from './radio-group.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
import type SlRadio from '../radio/radio.js';
|
||||
import type SlRadioButton from '../radio-button/radio-button.js';
|
||||
|
||||
/**
|
||||
* @summary Radio groups are used to group multiple [radios](/components/radio) or [radio buttons](/components/radio-button) so they function as a single form control.
|
||||
* @documentation https://shoelace.style/components/radio-group
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-button-group
|
||||
*
|
||||
* @slot - The default slot where `<sl-radio>` or `<sl-radio-button>` elements are placed.
|
||||
* @slot label - The radio group's label. Required for proper accessibility. Alternatively, you can use the `label`
|
||||
* attribute.
|
||||
*
|
||||
* @event sl-change - Emitted when the radio group's selected value changes.
|
||||
* @event sl-input - Emitted when the radio group receives user input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The input's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart button-group - The button group that wraps radio buttons.
|
||||
* @csspart button-group__base - The button group's `base` part.
|
||||
*/
|
||||
export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-button-group': SlButtonGroup };
|
||||
|
||||
protected readonly formControlController = new FormControlController(this);
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private customValidityMessage = '';
|
||||
private validationTimeout: number;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.radio-group__validation-input') validationInput: HTMLInputElement;
|
||||
|
||||
@state() private hasButtonGroup = false;
|
||||
@state() private errorMessage = '';
|
||||
@state() defaultValue = '';
|
||||
|
||||
/**
|
||||
* The radio group's label. Required for proper accessibility. If you need to display HTML, use the `label` slot
|
||||
* instead.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** The radio groups's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** The name of the radio group, submitted as a name/value pair with form data. */
|
||||
@property() name = 'option';
|
||||
|
||||
/** The current value of the radio group, submitted as a name/value pair with form data. */
|
||||
@property({ reflect: true }) value = '';
|
||||
|
||||
/** The radio group's size. This size will be applied to all child radios and radio buttons. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Ensures a child radio is checked before allowing the containing form to submit. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (hasCustomValidityMessage) {
|
||||
return customErrorValidityState;
|
||||
} else if (isRequiredAndEmpty) {
|
||||
return valueMissingValidityState;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (hasCustomValidityMessage) {
|
||||
return this.customValidityMessage;
|
||||
} else if (isRequiredAndEmpty) {
|
||||
return this.validationInput.validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.defaultValue = this.value;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private getAllRadios() {
|
||||
return [...this.querySelectorAll<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')];
|
||||
}
|
||||
|
||||
private handleRadioClick(event: MouseEvent) {
|
||||
const target = (event.target as HTMLElement).closest<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')!;
|
||||
const radios = this.getAllRadios();
|
||||
const oldValue = this.value;
|
||||
|
||||
if (target.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = target.value;
|
||||
radios.forEach(radio => (radio.checked = radio === target));
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
this.emit('sl-input');
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const radios = this.getAllRadios().filter(radio => !radio.disabled);
|
||||
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
|
||||
const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
|
||||
const oldValue = this.value;
|
||||
let index = radios.indexOf(checkedRadio) + incr;
|
||||
|
||||
if (index < 0) {
|
||||
index = radios.length - 1;
|
||||
}
|
||||
|
||||
if (index > radios.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.getAllRadios().forEach(radio => {
|
||||
radio.checked = false;
|
||||
|
||||
if (!this.hasButtonGroup) {
|
||||
radio.tabIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
this.value = radios[index].value;
|
||||
radios[index].checked = true;
|
||||
|
||||
if (!this.hasButtonGroup) {
|
||||
radios[index].tabIndex = 0;
|
||||
radios[index].focus();
|
||||
} else {
|
||||
radios[index].shadowRoot!.querySelector('button')!.focus();
|
||||
}
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private handleLabelClick() {
|
||||
const radios = this.getAllRadios();
|
||||
const checked = radios.find(radio => radio.checked);
|
||||
const radioToFocus = checked || radios[0];
|
||||
|
||||
// Move focus to the checked radio (or the first one if none are checked) when clicking the label
|
||||
if (radioToFocus) {
|
||||
radioToFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private async syncRadioElements() {
|
||||
const radios = this.getAllRadios();
|
||||
|
||||
await Promise.all(
|
||||
// Sync the checked state and size
|
||||
radios.map(async radio => {
|
||||
await radio.updateComplete;
|
||||
radio.checked = radio.value === this.value;
|
||||
radio.size = this.size;
|
||||
})
|
||||
);
|
||||
|
||||
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
|
||||
|
||||
if (!radios.some(radio => radio.checked)) {
|
||||
if (this.hasButtonGroup) {
|
||||
const buttonRadio = radios[0].shadowRoot?.querySelector('button');
|
||||
|
||||
if (buttonRadio) {
|
||||
buttonRadio.tabIndex = 0;
|
||||
}
|
||||
} else {
|
||||
radios[0].tabIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasButtonGroup) {
|
||||
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
|
||||
|
||||
if (buttonGroup) {
|
||||
buttonGroup.disableRole = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncRadios() {
|
||||
if (customElements.get('sl-radio') && customElements.get('sl-radio-button')) {
|
||||
this.syncRadioElements();
|
||||
return;
|
||||
}
|
||||
|
||||
if (customElements.get('sl-radio')) {
|
||||
this.syncRadioElements();
|
||||
} else {
|
||||
customElements.whenDefined('sl-radio').then(() => this.syncRadios());
|
||||
}
|
||||
|
||||
if (customElements.get('sl-radio-button')) {
|
||||
this.syncRadioElements();
|
||||
} else {
|
||||
// Rerun this handler when <sl-radio> or <sl-radio-button> is registered
|
||||
customElements.whenDefined('sl-radio-button').then(() => this.syncRadios());
|
||||
}
|
||||
}
|
||||
|
||||
private updateCheckedRadio() {
|
||||
const radios = this.getAllRadios();
|
||||
radios.forEach(radio => (radio.checked = radio.value === this.value));
|
||||
this.formControlController.setValidity(this.validity.valid);
|
||||
}
|
||||
|
||||
@watch('size', { waitUntilFirstUpdate: true })
|
||||
handleSizeChange() {
|
||||
this.syncRadios();
|
||||
}
|
||||
|
||||
@watch('value')
|
||||
handleValueChange() {
|
||||
if (this.hasUpdated) {
|
||||
this.updateCheckedRadio();
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (isRequiredAndEmpty || hasCustomValidityMessage) {
|
||||
this.formControlController.emitInvalidEvent();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity(): boolean {
|
||||
const isValid = this.validity.valid;
|
||||
|
||||
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
|
||||
this.formControlController.setValidity(isValid);
|
||||
this.validationInput.hidden = true;
|
||||
clearTimeout(this.validationTimeout);
|
||||
|
||||
if (!isValid) {
|
||||
// Show the browser's constraint validation message
|
||||
this.validationInput.hidden = false;
|
||||
this.validationInput.reportValidity();
|
||||
this.validationTimeout = setTimeout(() => (this.validationInput.hidden = true), 10000) as unknown as number;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message = '') {
|
||||
this.customValidityMessage = message;
|
||||
this.errorMessage = message;
|
||||
this.validationInput.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
|
||||
const defaultSlot = html`
|
||||
<span @click=${this.handleRadioClick} @keydown=${this.handleKeyDown} role="presentation">
|
||||
<slot @slotchange=${this.syncRadios}></slot>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<fieldset
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--radio-group': true,
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
role="radiogroup"
|
||||
aria-labelledby="label"
|
||||
aria-describedby="help-text"
|
||||
aria-errormessage="error-message"
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
id="label"
|
||||
class="form-control__label"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
@click=${this.handleLabelClick}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div class="visually-hidden">
|
||||
<div id="error-message" aria-live="assertive">${this.errorMessage}</div>
|
||||
<label class="radio-group__validation">
|
||||
<input
|
||||
type="text"
|
||||
class="radio-group__validation-input"
|
||||
?required=${this.required}
|
||||
tabindex="-1"
|
||||
hidden
|
||||
@invalid=${this.handleInvalid}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${this.hasButtonGroup
|
||||
? html`
|
||||
<sl-button-group part="button-group" exportparts="base:button-group__base">
|
||||
${defaultSlot}
|
||||
</sl-button-group>
|
||||
`
|
||||
: defaultSlot}
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</fieldset>
|
||||
`;
|
||||
/* eslint-enable lit-a11y/click-events-have-key-events */
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-radio-group': SlRadioGroup;
|
||||
}
|
||||
}
|
||||
@@ -1,406 +1,4 @@
|
||||
import '../button-group/button-group.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import {
|
||||
customErrorValidityState,
|
||||
FormControlController,
|
||||
validValidityState,
|
||||
valueMissingValidityState
|
||||
} from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './radio-group.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
import type SlRadio from '../radio/radio.js';
|
||||
import type SlRadioButton from '../radio-button/radio-button.js';
|
||||
|
||||
/**
|
||||
* @summary Radio groups are used to group multiple [radios](/components/radio) or [radio buttons](/components/radio-button) so they function as a single form control.
|
||||
* @documentation https://shoelace.style/components/radio-group
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-button-group
|
||||
*
|
||||
* @slot - The default slot where `<sl-radio>` or `<sl-radio-button>` elements are placed.
|
||||
* @slot label - The radio group's label. Required for proper accessibility. Alternatively, you can use the `label`
|
||||
* attribute.
|
||||
*
|
||||
* @event sl-change - Emitted when the radio group's selected value changes.
|
||||
* @event sl-input - Emitted when the radio group receives user input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The input's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart button-group - The button group that wraps radio buttons.
|
||||
* @csspart button-group__base - The button group's `base` part.
|
||||
*/
|
||||
@customElement('sl-radio-group')
|
||||
export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
protected readonly formControlController = new FormControlController(this);
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private customValidityMessage = '';
|
||||
private validationTimeout: number;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.radio-group__validation-input') validationInput: HTMLInputElement;
|
||||
|
||||
@state() private hasButtonGroup = false;
|
||||
@state() private errorMessage = '';
|
||||
@state() defaultValue = '';
|
||||
|
||||
/**
|
||||
* The radio group's label. Required for proper accessibility. If you need to display HTML, use the `label` slot
|
||||
* instead.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** The radio groups's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** The name of the radio group, submitted as a name/value pair with form data. */
|
||||
@property() name = 'option';
|
||||
|
||||
/** The current value of the radio group, submitted as a name/value pair with form data. */
|
||||
@property({ reflect: true }) value = '';
|
||||
|
||||
/** The radio group's size. This size will be applied to all child radios and radio buttons. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Ensures a child radio is checked before allowing the containing form to submit. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (hasCustomValidityMessage) {
|
||||
return customErrorValidityState;
|
||||
} else if (isRequiredAndEmpty) {
|
||||
return valueMissingValidityState;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (hasCustomValidityMessage) {
|
||||
return this.customValidityMessage;
|
||||
} else if (isRequiredAndEmpty) {
|
||||
return this.validationInput.validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.defaultValue = this.value;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private getAllRadios() {
|
||||
return [...this.querySelectorAll<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')];
|
||||
}
|
||||
|
||||
private handleRadioClick(event: MouseEvent) {
|
||||
const target = (event.target as HTMLElement).closest<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')!;
|
||||
const radios = this.getAllRadios();
|
||||
const oldValue = this.value;
|
||||
|
||||
if (target.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = target.value;
|
||||
radios.forEach(radio => (radio.checked = radio === target));
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
this.emit('sl-input');
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const radios = this.getAllRadios().filter(radio => !radio.disabled);
|
||||
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
|
||||
const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
|
||||
const oldValue = this.value;
|
||||
let index = radios.indexOf(checkedRadio) + incr;
|
||||
|
||||
if (index < 0) {
|
||||
index = radios.length - 1;
|
||||
}
|
||||
|
||||
if (index > radios.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.getAllRadios().forEach(radio => {
|
||||
radio.checked = false;
|
||||
|
||||
if (!this.hasButtonGroup) {
|
||||
radio.tabIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
this.value = radios[index].value;
|
||||
radios[index].checked = true;
|
||||
|
||||
if (!this.hasButtonGroup) {
|
||||
radios[index].tabIndex = 0;
|
||||
radios[index].focus();
|
||||
} else {
|
||||
radios[index].shadowRoot!.querySelector('button')!.focus();
|
||||
}
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private handleLabelClick() {
|
||||
const radios = this.getAllRadios();
|
||||
const checked = radios.find(radio => radio.checked);
|
||||
const radioToFocus = checked || radios[0];
|
||||
|
||||
// Move focus to the checked radio (or the first one if none are checked) when clicking the label
|
||||
if (radioToFocus) {
|
||||
radioToFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private async syncRadioElements() {
|
||||
const radios = this.getAllRadios();
|
||||
|
||||
await Promise.all(
|
||||
// Sync the checked state and size
|
||||
radios.map(async radio => {
|
||||
await radio.updateComplete;
|
||||
radio.checked = radio.value === this.value;
|
||||
radio.size = this.size;
|
||||
})
|
||||
);
|
||||
|
||||
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
|
||||
|
||||
if (!radios.some(radio => radio.checked)) {
|
||||
if (this.hasButtonGroup) {
|
||||
const buttonRadio = radios[0].shadowRoot?.querySelector('button');
|
||||
|
||||
if (buttonRadio) {
|
||||
buttonRadio.tabIndex = 0;
|
||||
}
|
||||
} else {
|
||||
radios[0].tabIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasButtonGroup) {
|
||||
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
|
||||
|
||||
if (buttonGroup) {
|
||||
buttonGroup.disableRole = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncRadios() {
|
||||
if (customElements.get('sl-radio') && customElements.get('sl-radio-button')) {
|
||||
this.syncRadioElements();
|
||||
return;
|
||||
}
|
||||
|
||||
if (customElements.get('sl-radio')) {
|
||||
this.syncRadioElements();
|
||||
} else {
|
||||
customElements.whenDefined('sl-radio').then(() => this.syncRadios());
|
||||
}
|
||||
|
||||
if (customElements.get('sl-radio-button')) {
|
||||
this.syncRadioElements();
|
||||
} else {
|
||||
// Rerun this handler when <sl-radio> or <sl-radio-button> is registered
|
||||
customElements.whenDefined('sl-radio-button').then(() => this.syncRadios());
|
||||
}
|
||||
}
|
||||
|
||||
private updateCheckedRadio() {
|
||||
const radios = this.getAllRadios();
|
||||
radios.forEach(radio => (radio.checked = radio.value === this.value));
|
||||
this.formControlController.setValidity(this.validity.valid);
|
||||
}
|
||||
|
||||
@watch('size', { waitUntilFirstUpdate: true })
|
||||
handleSizeChange() {
|
||||
this.syncRadios();
|
||||
}
|
||||
|
||||
@watch('value')
|
||||
handleValueChange() {
|
||||
if (this.hasUpdated) {
|
||||
this.updateCheckedRadio();
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (isRequiredAndEmpty || hasCustomValidityMessage) {
|
||||
this.formControlController.emitInvalidEvent();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity(): boolean {
|
||||
const isValid = this.validity.valid;
|
||||
|
||||
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
|
||||
this.formControlController.setValidity(isValid);
|
||||
this.validationInput.hidden = true;
|
||||
clearTimeout(this.validationTimeout);
|
||||
|
||||
if (!isValid) {
|
||||
// Show the browser's constraint validation message
|
||||
this.validationInput.hidden = false;
|
||||
this.validationInput.reportValidity();
|
||||
this.validationTimeout = setTimeout(() => (this.validationInput.hidden = true), 10000) as unknown as number;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message = '') {
|
||||
this.customValidityMessage = message;
|
||||
this.errorMessage = message;
|
||||
this.validationInput.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
|
||||
const defaultSlot = html`
|
||||
<span @click=${this.handleRadioClick} @keydown=${this.handleKeyDown} role="presentation">
|
||||
<slot @slotchange=${this.syncRadios}></slot>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<fieldset
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--radio-group': true,
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
role="radiogroup"
|
||||
aria-labelledby="label"
|
||||
aria-describedby="help-text"
|
||||
aria-errormessage="error-message"
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
id="label"
|
||||
class="form-control__label"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
@click=${this.handleLabelClick}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div class="visually-hidden">
|
||||
<div id="error-message" aria-live="assertive">${this.errorMessage}</div>
|
||||
<label class="radio-group__validation">
|
||||
<input
|
||||
type="text"
|
||||
class="radio-group__validation-input"
|
||||
?required=${this.required}
|
||||
tabindex="-1"
|
||||
hidden
|
||||
@invalid=${this.handleInvalid}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${this.hasButtonGroup
|
||||
? html`
|
||||
<sl-button-group part="button-group" exportparts="base:button-group__base">
|
||||
${defaultSlot}
|
||||
</sl-button-group>
|
||||
`
|
||||
: defaultSlot}
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</fieldset>
|
||||
`;
|
||||
/* eslint-enable lit-a11y/click-events-have-key-events */
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-radio-group': SlRadioGroup;
|
||||
}
|
||||
}
|
||||
import SlRadioGroup from './radio-group.component.js';
|
||||
export * from './radio-group.component.js';
|
||||
export default SlRadioGroup;
|
||||
SlRadioGroup.define('sl-radio-group');
|
||||
|
||||
123
src/components/radio/radio.component.ts
Normal file
123
src/components/radio/radio.component.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './radio.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Radios allow the user to select a single option from a group.
|
||||
* @documentation https://shoelace.style/components/radio
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The radio's label.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The circular container that wraps the radio's checked state.
|
||||
* @csspart control--checked - The radio control when the radio is checked.
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart label - The container that wraps the radio's label.
|
||||
*/
|
||||
export default class SlRadio extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
@state() checked = false;
|
||||
@state() protected hasFocus = false;
|
||||
|
||||
/** The radio's value. When selected, the radio group will receive this value. */
|
||||
@property() value: string;
|
||||
|
||||
/**
|
||||
* The radio's size. When used inside a radio group, the size will be determined by the radio group's size so this
|
||||
* attribute can typically be omitted.
|
||||
*/
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Disables the radio. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('blur', this.handleBlur);
|
||||
this.addEventListener('click', this.handleClick);
|
||||
this.addEventListener('focus', this.handleFocus);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setInitialAttributes();
|
||||
}
|
||||
|
||||
private handleBlur = () => {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
};
|
||||
|
||||
private handleClick = () => {
|
||||
if (!this.disabled) {
|
||||
this.checked = true;
|
||||
}
|
||||
};
|
||||
|
||||
private handleFocus = () => {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
};
|
||||
|
||||
private setInitialAttributes() {
|
||||
this.setAttribute('role', 'radio');
|
||||
this.setAttribute('tabindex', '-1');
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
this.setAttribute('tabindex', this.checked ? '0' : '-1');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<span
|
||||
part="base"
|
||||
class=${classMap({
|
||||
radio: true,
|
||||
'radio--checked': this.checked,
|
||||
'radio--disabled': this.disabled,
|
||||
'radio--focused': this.hasFocus,
|
||||
'radio--small': this.size === 'small',
|
||||
'radio--medium': this.size === 'medium',
|
||||
'radio--large': this.size === 'large'
|
||||
})}
|
||||
>
|
||||
<span part="${`control${this.checked ? ' control--checked' : ''}`}" class="radio__control">
|
||||
${this.checked
|
||||
? html` <sl-icon part="checked-icon" class="radio__checked-icon" library="system" name="radio"></sl-icon> `
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<slot part="label" class="radio__label"></slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-radio': SlRadio;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './radio.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Radios allow the user to select a single option from a group.
|
||||
* @documentation https://shoelace.style/components/radio
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The radio's label.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The circular container that wraps the radio's checked state.
|
||||
* @csspart control--checked - The radio control when the radio is checked.
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart label - The container that wraps the radio's label.
|
||||
*/
|
||||
@customElement('sl-radio')
|
||||
export default class SlRadio extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@state() checked = false;
|
||||
@state() protected hasFocus = false;
|
||||
|
||||
/** The radio's value. When selected, the radio group will receive this value. */
|
||||
@property() value: string;
|
||||
|
||||
/**
|
||||
* The radio's size. When used inside a radio group, the size will be determined by the radio group's size so this
|
||||
* attribute can typically be omitted.
|
||||
*/
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Disables the radio. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('blur', this.handleBlur);
|
||||
this.addEventListener('click', this.handleClick);
|
||||
this.addEventListener('focus', this.handleFocus);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setInitialAttributes();
|
||||
}
|
||||
|
||||
private handleBlur = () => {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
};
|
||||
|
||||
private handleClick = () => {
|
||||
if (!this.disabled) {
|
||||
this.checked = true;
|
||||
}
|
||||
};
|
||||
|
||||
private handleFocus = () => {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
};
|
||||
|
||||
private setInitialAttributes() {
|
||||
this.setAttribute('role', 'radio');
|
||||
this.setAttribute('tabindex', '-1');
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
this.setAttribute('tabindex', this.checked ? '0' : '-1');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<span
|
||||
part="base"
|
||||
class=${classMap({
|
||||
radio: true,
|
||||
'radio--checked': this.checked,
|
||||
'radio--disabled': this.disabled,
|
||||
'radio--focused': this.hasFocus,
|
||||
'radio--small': this.size === 'small',
|
||||
'radio--medium': this.size === 'medium',
|
||||
'radio--large': this.size === 'large'
|
||||
})}
|
||||
>
|
||||
<span part="${`control${this.checked ? ' control--checked' : ''}`}" class="radio__control">
|
||||
${this.checked
|
||||
? html` <sl-icon part="checked-icon" class="radio__checked-icon" library="system" name="radio"></sl-icon> `
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<slot part="label" class="radio__label"></slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-radio': SlRadio;
|
||||
}
|
||||
}
|
||||
import SlRadio from './radio.component.js';
|
||||
export * from './radio.component.js';
|
||||
export default SlRadio;
|
||||
SlRadio.define('sl-radio');
|
||||
|
||||
362
src/components/range/range.component.ts
Normal file
362
src/components/range/range.component.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { eventOptions, property, query, state } from 'lit/decorators.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './range.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Ranges allow the user to select a single value within a given range using a slider.
|
||||
* @documentation https://shoelace.style/components/range
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot label - The range's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The range's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart input - The internal `<input>` element.
|
||||
* @csspart tooltip - The range's tooltip.
|
||||
*
|
||||
* @cssproperty --thumb-size - The size of the thumb.
|
||||
* @cssproperty --tooltip-offset - The vertical distance the tooltip is offset from the track.
|
||||
* @cssproperty --track-color-active - The color of the portion of the track that represents the current value.
|
||||
* @cssproperty --track-color-inactive - The of the portion of the track that represents the remaining value.
|
||||
* @cssproperty --track-height - The height of the track.
|
||||
* @cssproperty --track-active-offset - The point of origin of the active track.
|
||||
*/
|
||||
export default class SlRange extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this);
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private resizeObserver: ResizeObserver;
|
||||
|
||||
@query('.range__control') input: HTMLInputElement;
|
||||
@query('.range__tooltip') output: HTMLOutputElement | null;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() private hasTooltip = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The name of the range, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the range, submitted as a name/value pair with form data. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The range's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The range's help text. If you need to display HTML, use the help-text slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** Disables the range. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** The minimum acceptable value of the range. */
|
||||
@property({ type: Number }) min = 0;
|
||||
|
||||
/** The maximum acceptable value of the range. */
|
||||
@property({ type: Number }) max = 100;
|
||||
|
||||
/** The interval at which the range will increase and decrease. */
|
||||
@property({ type: Number }) step = 1;
|
||||
|
||||
/** The preferred placement of the range's tooltip. */
|
||||
@property() tooltip: 'top' | 'bottom' | 'none' = 'top';
|
||||
|
||||
/**
|
||||
* A function used to format the tooltip's value. The range's value is passed as the first and only argument. The
|
||||
* function should return a string to display in the tooltip.
|
||||
*/
|
||||
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = 0;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.resizeObserver = new ResizeObserver(() => this.syncRange());
|
||||
|
||||
if (this.value < this.min) {
|
||||
this.value = this.min;
|
||||
}
|
||||
if (this.value > this.max) {
|
||||
this.value = this.max;
|
||||
}
|
||||
|
||||
this.updateComplete.then(() => {
|
||||
this.syncRange();
|
||||
this.resizeObserver.observe(this.input);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.resizeObserver.unobserve(this.input);
|
||||
}
|
||||
|
||||
private handleChange() {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.value = parseFloat(this.input.value);
|
||||
this.emit('sl-input');
|
||||
this.syncRange();
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.hasTooltip = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.hasTooltip = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private handleThumbDragStart() {
|
||||
this.hasTooltip = true;
|
||||
}
|
||||
|
||||
private handleThumbDragEnd() {
|
||||
this.hasTooltip = false;
|
||||
}
|
||||
|
||||
private syncProgress(percent: number) {
|
||||
this.input.style.setProperty('--percent', `${percent * 100}%`);
|
||||
}
|
||||
|
||||
private syncTooltip(percent: number) {
|
||||
if (this.output !== null) {
|
||||
const inputWidth = this.input.offsetWidth;
|
||||
const tooltipWidth = this.output.offsetWidth;
|
||||
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const percentAsWidth = inputWidth * percent;
|
||||
|
||||
// The calculations are used to "guess" where the thumb is located. Since we're using the native range control
|
||||
// under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two
|
||||
// off depending on the size of the control, thumb, and tooltip dimensions.
|
||||
if (isRtl) {
|
||||
const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`;
|
||||
this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`;
|
||||
} else {
|
||||
const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`;
|
||||
this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
handleValueChange() {
|
||||
this.formControlController.updateValidity();
|
||||
|
||||
// The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to
|
||||
// min, max, and step properly
|
||||
this.input.value = this.value.toString();
|
||||
this.value = parseFloat(this.input.value);
|
||||
|
||||
this.syncRange();
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('hasTooltip', { waitUntilFirstUpdate: true })
|
||||
syncRange() {
|
||||
const percent = Math.max(0, (this.value - this.min) / (this.max - this.min));
|
||||
|
||||
this.syncProgress(percent);
|
||||
|
||||
if (this.tooltip !== 'none') {
|
||||
this.syncTooltip(percent);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
/** Sets focus on the range. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the range. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Increments the value of the range by the value of the step attribute. */
|
||||
stepUp() {
|
||||
this.input.stepUp();
|
||||
if (this.value !== Number(this.input.value)) {
|
||||
this.value = Number(this.input.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrements the value of the range by the value of the step attribute. */
|
||||
stepDown() {
|
||||
this.input.stepDown();
|
||||
if (this.value !== Number(this.input.value)) {
|
||||
this.value = Number(this.input.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
|
||||
// NOTE - always bind value after min/max, otherwise it will be clamped
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--medium': true, // range only has one size
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
for="input"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
range: true,
|
||||
'range--disabled': this.disabled,
|
||||
'range--focused': this.hasFocus,
|
||||
'range--rtl': this.localize.dir() === 'rtl',
|
||||
'range--tooltip-visible': this.hasTooltip,
|
||||
'range--tooltip-top': this.tooltip === 'top',
|
||||
'range--tooltip-bottom': this.tooltip === 'bottom'
|
||||
})}
|
||||
@mousedown=${this.handleThumbDragStart}
|
||||
@mouseup=${this.handleThumbDragEnd}
|
||||
@touchstart=${this.handleThumbDragStart}
|
||||
@touchend=${this.handleThumbDragEnd}
|
||||
>
|
||||
<input
|
||||
part="input"
|
||||
id="input"
|
||||
class="range__control"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
type="range"
|
||||
name=${ifDefined(this.name)}
|
||||
?disabled=${this.disabled}
|
||||
min=${ifDefined(this.min)}
|
||||
max=${ifDefined(this.max)}
|
||||
step=${ifDefined(this.step)}
|
||||
.value=${live(this.value.toString())}
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@focus=${this.handleFocus}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
${this.tooltip !== 'none' && !this.disabled
|
||||
? html`
|
||||
<output part="tooltip" class="range__tooltip">
|
||||
${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value}
|
||||
</output>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-range': SlRange;
|
||||
}
|
||||
}
|
||||
@@ -1,363 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './range.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Ranges allow the user to select a single value within a given range using a slider.
|
||||
* @documentation https://shoelace.style/components/range
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot label - The range's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The range's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart input - The internal `<input>` element.
|
||||
* @csspart tooltip - The range's tooltip.
|
||||
*
|
||||
* @cssproperty --thumb-size - The size of the thumb.
|
||||
* @cssproperty --tooltip-offset - The vertical distance the tooltip is offset from the track.
|
||||
* @cssproperty --track-color-active - The color of the portion of the track that represents the current value.
|
||||
* @cssproperty --track-color-inactive - The of the portion of the track that represents the remaining value.
|
||||
* @cssproperty --track-height - The height of the track.
|
||||
* @cssproperty --track-active-offset - The point of origin of the active track.
|
||||
*/
|
||||
@customElement('sl-range')
|
||||
export default class SlRange extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this);
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private resizeObserver: ResizeObserver;
|
||||
|
||||
@query('.range__control') input: HTMLInputElement;
|
||||
@query('.range__tooltip') output: HTMLOutputElement | null;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() private hasTooltip = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The name of the range, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the range, submitted as a name/value pair with form data. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The range's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The range's help text. If you need to display HTML, use the help-text slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** Disables the range. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** The minimum acceptable value of the range. */
|
||||
@property({ type: Number }) min = 0;
|
||||
|
||||
/** The maximum acceptable value of the range. */
|
||||
@property({ type: Number }) max = 100;
|
||||
|
||||
/** The interval at which the range will increase and decrease. */
|
||||
@property({ type: Number }) step = 1;
|
||||
|
||||
/** The preferred placement of the range's tooltip. */
|
||||
@property() tooltip: 'top' | 'bottom' | 'none' = 'top';
|
||||
|
||||
/**
|
||||
* A function used to format the tooltip's value. The range's value is passed as the first and only argument. The
|
||||
* function should return a string to display in the tooltip.
|
||||
*/
|
||||
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = 0;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.resizeObserver = new ResizeObserver(() => this.syncRange());
|
||||
|
||||
if (this.value < this.min) {
|
||||
this.value = this.min;
|
||||
}
|
||||
if (this.value > this.max) {
|
||||
this.value = this.max;
|
||||
}
|
||||
|
||||
this.updateComplete.then(() => {
|
||||
this.syncRange();
|
||||
this.resizeObserver.observe(this.input);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.resizeObserver.unobserve(this.input);
|
||||
}
|
||||
|
||||
private handleChange() {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.value = parseFloat(this.input.value);
|
||||
this.emit('sl-input');
|
||||
this.syncRange();
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.hasTooltip = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.hasTooltip = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private handleThumbDragStart() {
|
||||
this.hasTooltip = true;
|
||||
}
|
||||
|
||||
private handleThumbDragEnd() {
|
||||
this.hasTooltip = false;
|
||||
}
|
||||
|
||||
private syncProgress(percent: number) {
|
||||
this.input.style.setProperty('--percent', `${percent * 100}%`);
|
||||
}
|
||||
|
||||
private syncTooltip(percent: number) {
|
||||
if (this.output !== null) {
|
||||
const inputWidth = this.input.offsetWidth;
|
||||
const tooltipWidth = this.output.offsetWidth;
|
||||
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const percentAsWidth = inputWidth * percent;
|
||||
|
||||
// The calculations are used to "guess" where the thumb is located. Since we're using the native range control
|
||||
// under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two
|
||||
// off depending on the size of the control, thumb, and tooltip dimensions.
|
||||
if (isRtl) {
|
||||
const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`;
|
||||
this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`;
|
||||
} else {
|
||||
const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`;
|
||||
this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
handleValueChange() {
|
||||
this.formControlController.updateValidity();
|
||||
|
||||
// The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to
|
||||
// min, max, and step properly
|
||||
this.input.value = this.value.toString();
|
||||
this.value = parseFloat(this.input.value);
|
||||
|
||||
this.syncRange();
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('hasTooltip', { waitUntilFirstUpdate: true })
|
||||
syncRange() {
|
||||
const percent = Math.max(0, (this.value - this.min) / (this.max - this.min));
|
||||
|
||||
this.syncProgress(percent);
|
||||
|
||||
if (this.tooltip !== 'none') {
|
||||
this.syncTooltip(percent);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
/** Sets focus on the range. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the range. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Increments the value of the range by the value of the step attribute. */
|
||||
stepUp() {
|
||||
this.input.stepUp();
|
||||
if (this.value !== Number(this.input.value)) {
|
||||
this.value = Number(this.input.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrements the value of the range by the value of the step attribute. */
|
||||
stepDown() {
|
||||
this.input.stepDown();
|
||||
if (this.value !== Number(this.input.value)) {
|
||||
this.value = Number(this.input.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
|
||||
// NOTE - always bind value after min/max, otherwise it will be clamped
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--medium': true, // range only has one size
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
for="input"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
range: true,
|
||||
'range--disabled': this.disabled,
|
||||
'range--focused': this.hasFocus,
|
||||
'range--rtl': this.localize.dir() === 'rtl',
|
||||
'range--tooltip-visible': this.hasTooltip,
|
||||
'range--tooltip-top': this.tooltip === 'top',
|
||||
'range--tooltip-bottom': this.tooltip === 'bottom'
|
||||
})}
|
||||
@mousedown=${this.handleThumbDragStart}
|
||||
@mouseup=${this.handleThumbDragEnd}
|
||||
@touchstart=${this.handleThumbDragStart}
|
||||
@touchend=${this.handleThumbDragEnd}
|
||||
>
|
||||
<input
|
||||
part="input"
|
||||
id="input"
|
||||
class="range__control"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
type="range"
|
||||
name=${ifDefined(this.name)}
|
||||
?disabled=${this.disabled}
|
||||
min=${ifDefined(this.min)}
|
||||
max=${ifDefined(this.max)}
|
||||
step=${ifDefined(this.step)}
|
||||
.value=${live(this.value.toString())}
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@focus=${this.handleFocus}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
${this.tooltip !== 'none' && !this.disabled
|
||||
? html`
|
||||
<output part="tooltip" class="range__tooltip">
|
||||
${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value}
|
||||
</output>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-range': SlRange;
|
||||
}
|
||||
}
|
||||
import SlRange from './range.component.js';
|
||||
export * from './range.component.js';
|
||||
export default SlRange;
|
||||
SlRange.define('sl-range');
|
||||
|
||||
315
src/components/rating/rating.component.ts
Normal file
315
src/components/rating/rating.component.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { eventOptions, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './rating.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Ratings give users a way to quickly view and provide feedback.
|
||||
* @documentation https://shoelace.style/components/rating
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-change - Emitted when the rating's value changes.
|
||||
* @event {{ phase: 'start' | 'move' | 'end', value: number }} sl-hover - Emitted when the user hovers over a value. The
|
||||
* `phase` property indicates when hovering starts, moves to a new value, or ends. The `value` property tells what the
|
||||
* rating's value would be if the user were to commit to the hovered value.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*
|
||||
* @cssproperty --symbol-color - The inactive color for symbols.
|
||||
* @cssproperty --symbol-color-active - The active color for symbols.
|
||||
* @cssproperty --symbol-size - The size of symbols.
|
||||
* @cssproperty --symbol-spacing - The spacing to use around symbols.
|
||||
*/
|
||||
export default class SlRating extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.rating') rating: HTMLElement;
|
||||
|
||||
@state() private hoverValue = 0;
|
||||
@state() private isHovering = false;
|
||||
|
||||
/** A label that describes the rating to assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
/** The current rating. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The highest rating to show. */
|
||||
@property({ type: Number }) max = 5;
|
||||
|
||||
/**
|
||||
* The precision at which the rating will increase and decrease. For example, to allow half-star ratings, set this
|
||||
* attribute to `0.5`.
|
||||
*/
|
||||
@property({ type: Number }) precision = 1;
|
||||
|
||||
/** Makes the rating readonly. */
|
||||
@property({ type: Boolean, reflect: true }) readonly = false;
|
||||
|
||||
/** Disables the rating. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* A function that customizes the symbol to be rendered. The first and only argument is the rating's current value.
|
||||
* The function should return a string containing trusted HTML of the symbol to render at the specified value. Works
|
||||
* well with `<sl-icon>` elements.
|
||||
*/
|
||||
@property() getSymbol: (value: number) => string = () => '<sl-icon name="star-fill" library="system"></sl-icon>';
|
||||
|
||||
private getValueFromMousePosition(event: MouseEvent) {
|
||||
return this.getValueFromXCoordinate(event.clientX);
|
||||
}
|
||||
|
||||
private getValueFromTouchPosition(event: TouchEvent) {
|
||||
return this.getValueFromXCoordinate(event.touches[0].clientX);
|
||||
}
|
||||
|
||||
private getValueFromXCoordinate(coordinate: number) {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const { left, right, width } = this.rating.getBoundingClientRect();
|
||||
const value = isRtl
|
||||
? this.roundToPrecision(((right - coordinate) / width) * this.max, this.precision)
|
||||
: this.roundToPrecision(((coordinate - left) / width) * this.max, this.precision);
|
||||
|
||||
return clamp(value, 0, this.max);
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setValue(this.getValueFromMousePosition(event));
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private setValue(newValue: number) {
|
||||
if (this.disabled || this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = newValue === this.value ? 0 : newValue;
|
||||
this.isHovering = false;
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const oldValue = this.value;
|
||||
|
||||
if (this.disabled || this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || (isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
|
||||
const decrement = event.shiftKey ? 1 : this.precision;
|
||||
this.value = Math.max(0, this.value - decrement);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || (isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
|
||||
const increment = event.shiftKey ? 1 : this.precision;
|
||||
this.value = Math.min(this.max, this.value + increment);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
this.value = 0;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
this.value = this.max;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseEnter(event: MouseEvent) {
|
||||
this.isHovering = true;
|
||||
this.hoverValue = this.getValueFromMousePosition(event);
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent) {
|
||||
this.hoverValue = this.getValueFromMousePosition(event);
|
||||
}
|
||||
|
||||
private handleMouseLeave() {
|
||||
this.isHovering = false;
|
||||
}
|
||||
|
||||
private handleTouchStart(event: TouchEvent) {
|
||||
this.isHovering = true;
|
||||
this.hoverValue = this.getValueFromTouchPosition(event);
|
||||
|
||||
// Prevent scrolling when touch is initiated
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private handleTouchMove(event: TouchEvent) {
|
||||
this.hoverValue = this.getValueFromTouchPosition(event);
|
||||
}
|
||||
|
||||
private handleTouchEnd(event: TouchEvent) {
|
||||
this.isHovering = false;
|
||||
this.setValue(this.hoverValue);
|
||||
this.emit('sl-change');
|
||||
|
||||
// Prevent click on mobile devices
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private roundToPrecision(numberToRound: number, precision = 0.5) {
|
||||
const multiplier = 1 / precision;
|
||||
return Math.ceil(numberToRound * multiplier) / multiplier;
|
||||
}
|
||||
|
||||
@watch('hoverValue')
|
||||
handleHoverValueChange() {
|
||||
this.emit('sl-hover', {
|
||||
detail: {
|
||||
phase: 'move',
|
||||
value: this.hoverValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@watch('isHovering')
|
||||
handleIsHoveringChange() {
|
||||
this.emit('sl-hover', {
|
||||
detail: {
|
||||
phase: this.isHovering ? 'start' : 'end',
|
||||
value: this.hoverValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets focus on the rating. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.rating.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the rating. */
|
||||
blur() {
|
||||
this.rating.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const counter = Array.from(Array(this.max).keys());
|
||||
let displayValue = 0;
|
||||
|
||||
if (this.disabled || this.readonly) {
|
||||
displayValue = this.value;
|
||||
} else {
|
||||
displayValue = this.isHovering ? this.hoverValue : this.value;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
rating: true,
|
||||
'rating--readonly': this.readonly,
|
||||
'rating--disabled': this.disabled,
|
||||
'rating--rtl': isRtl
|
||||
})}
|
||||
role="slider"
|
||||
aria-label=${this.label}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-readonly=${this.readonly ? 'true' : 'false'}
|
||||
aria-valuenow=${this.value}
|
||||
aria-valuemin=${0}
|
||||
aria-valuemax=${this.max}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@click=${this.handleClick}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
@touchstart=${this.handleTouchStart}
|
||||
@mouseleave=${this.handleMouseLeave}
|
||||
@touchend=${this.handleTouchEnd}
|
||||
@mousemove=${this.handleMouseMove}
|
||||
@touchmove=${this.handleTouchMove}
|
||||
>
|
||||
<span class="rating__symbols">
|
||||
${counter.map(index => {
|
||||
if (displayValue > index && displayValue < index + 1) {
|
||||
// Users can click the current value to clear the rating. When this happens, we set this.isHovering to
|
||||
// false to prevent the hover state from confusing them as they move the mouse out of the control. This
|
||||
// extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol.
|
||||
return html`
|
||||
<span
|
||||
class=${classMap({
|
||||
rating__symbol: true,
|
||||
'rating__partial-symbol-container': true,
|
||||
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
|
||||
})}
|
||||
role="presentation"
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
>
|
||||
<div
|
||||
style=${styleMap({
|
||||
clipPath: isRtl
|
||||
? `inset(0 ${(displayValue - index) * 100}% 0 0)`
|
||||
: `inset(0 0 0 ${(displayValue - index) * 100}%)`
|
||||
})}
|
||||
>
|
||||
${unsafeHTML(this.getSymbol(index + 1))}
|
||||
</div>
|
||||
<div
|
||||
class="rating__partial--filled"
|
||||
style=${styleMap({
|
||||
clipPath: isRtl
|
||||
? `inset(0 0 0 ${100 - (displayValue - index) * 100}%)`
|
||||
: `inset(0 ${100 - (displayValue - index) * 100}% 0 0)`
|
||||
})}
|
||||
>
|
||||
${unsafeHTML(this.getSymbol(index + 1))}
|
||||
</div>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<span
|
||||
class=${classMap({
|
||||
rating__symbol: true,
|
||||
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1,
|
||||
'rating__symbol--active': displayValue >= index + 1
|
||||
})}
|
||||
role="presentation"
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
>
|
||||
${unsafeHTML(this.getSymbol(index + 1))}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-rating': SlRating;
|
||||
}
|
||||
}
|
||||
@@ -1,315 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './rating.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Ratings give users a way to quickly view and provide feedback.
|
||||
* @documentation https://shoelace.style/components/rating
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-change - Emitted when the rating's value changes.
|
||||
* @event {{ phase: 'start' | 'move' | 'end', value: number }} sl-hover - Emitted when the user hovers over a value. The
|
||||
* `phase` property indicates when hovering starts, moves to a new value, or ends. The `value` property tells what the
|
||||
* rating's value would be if the user were to commit to the hovered value.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*
|
||||
* @cssproperty --symbol-color - The inactive color for symbols.
|
||||
* @cssproperty --symbol-color-active - The active color for symbols.
|
||||
* @cssproperty --symbol-size - The size of symbols.
|
||||
* @cssproperty --symbol-spacing - The spacing to use around symbols.
|
||||
*/
|
||||
@customElement('sl-rating')
|
||||
export default class SlRating extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.rating') rating: HTMLElement;
|
||||
|
||||
@state() private hoverValue = 0;
|
||||
@state() private isHovering = false;
|
||||
|
||||
/** A label that describes the rating to assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
/** The current rating. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The highest rating to show. */
|
||||
@property({ type: Number }) max = 5;
|
||||
|
||||
/**
|
||||
* The precision at which the rating will increase and decrease. For example, to allow half-star ratings, set this
|
||||
* attribute to `0.5`.
|
||||
*/
|
||||
@property({ type: Number }) precision = 1;
|
||||
|
||||
/** Makes the rating readonly. */
|
||||
@property({ type: Boolean, reflect: true }) readonly = false;
|
||||
|
||||
/** Disables the rating. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* A function that customizes the symbol to be rendered. The first and only argument is the rating's current value.
|
||||
* The function should return a string containing trusted HTML of the symbol to render at the specified value. Works
|
||||
* well with `<sl-icon>` elements.
|
||||
*/
|
||||
@property() getSymbol: (value: number) => string = () => '<sl-icon name="star-fill" library="system"></sl-icon>';
|
||||
|
||||
private getValueFromMousePosition(event: MouseEvent) {
|
||||
return this.getValueFromXCoordinate(event.clientX);
|
||||
}
|
||||
|
||||
private getValueFromTouchPosition(event: TouchEvent) {
|
||||
return this.getValueFromXCoordinate(event.touches[0].clientX);
|
||||
}
|
||||
|
||||
private getValueFromXCoordinate(coordinate: number) {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const { left, right, width } = this.rating.getBoundingClientRect();
|
||||
const value = isRtl
|
||||
? this.roundToPrecision(((right - coordinate) / width) * this.max, this.precision)
|
||||
: this.roundToPrecision(((coordinate - left) / width) * this.max, this.precision);
|
||||
|
||||
return clamp(value, 0, this.max);
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setValue(this.getValueFromMousePosition(event));
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private setValue(newValue: number) {
|
||||
if (this.disabled || this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = newValue === this.value ? 0 : newValue;
|
||||
this.isHovering = false;
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const oldValue = this.value;
|
||||
|
||||
if (this.disabled || this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || (isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
|
||||
const decrement = event.shiftKey ? 1 : this.precision;
|
||||
this.value = Math.max(0, this.value - decrement);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || (isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
|
||||
const increment = event.shiftKey ? 1 : this.precision;
|
||||
this.value = Math.min(this.max, this.value + increment);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
this.value = 0;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
this.value = this.max;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseEnter(event: MouseEvent) {
|
||||
this.isHovering = true;
|
||||
this.hoverValue = this.getValueFromMousePosition(event);
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent) {
|
||||
this.hoverValue = this.getValueFromMousePosition(event);
|
||||
}
|
||||
|
||||
private handleMouseLeave() {
|
||||
this.isHovering = false;
|
||||
}
|
||||
|
||||
private handleTouchStart(event: TouchEvent) {
|
||||
this.isHovering = true;
|
||||
this.hoverValue = this.getValueFromTouchPosition(event);
|
||||
|
||||
// Prevent scrolling when touch is initiated
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private handleTouchMove(event: TouchEvent) {
|
||||
this.hoverValue = this.getValueFromTouchPosition(event);
|
||||
}
|
||||
|
||||
private handleTouchEnd(event: TouchEvent) {
|
||||
this.isHovering = false;
|
||||
this.setValue(this.hoverValue);
|
||||
this.emit('sl-change');
|
||||
|
||||
// Prevent click on mobile devices
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private roundToPrecision(numberToRound: number, precision = 0.5) {
|
||||
const multiplier = 1 / precision;
|
||||
return Math.ceil(numberToRound * multiplier) / multiplier;
|
||||
}
|
||||
|
||||
@watch('hoverValue')
|
||||
handleHoverValueChange() {
|
||||
this.emit('sl-hover', {
|
||||
detail: {
|
||||
phase: 'move',
|
||||
value: this.hoverValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@watch('isHovering')
|
||||
handleIsHoveringChange() {
|
||||
this.emit('sl-hover', {
|
||||
detail: {
|
||||
phase: this.isHovering ? 'start' : 'end',
|
||||
value: this.hoverValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets focus on the rating. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.rating.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the rating. */
|
||||
blur() {
|
||||
this.rating.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const counter = Array.from(Array(this.max).keys());
|
||||
let displayValue = 0;
|
||||
|
||||
if (this.disabled || this.readonly) {
|
||||
displayValue = this.value;
|
||||
} else {
|
||||
displayValue = this.isHovering ? this.hoverValue : this.value;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
rating: true,
|
||||
'rating--readonly': this.readonly,
|
||||
'rating--disabled': this.disabled,
|
||||
'rating--rtl': isRtl
|
||||
})}
|
||||
role="slider"
|
||||
aria-label=${this.label}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-readonly=${this.readonly ? 'true' : 'false'}
|
||||
aria-valuenow=${this.value}
|
||||
aria-valuemin=${0}
|
||||
aria-valuemax=${this.max}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@click=${this.handleClick}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
@touchstart=${this.handleTouchStart}
|
||||
@mouseleave=${this.handleMouseLeave}
|
||||
@touchend=${this.handleTouchEnd}
|
||||
@mousemove=${this.handleMouseMove}
|
||||
@touchmove=${this.handleTouchMove}
|
||||
>
|
||||
<span class="rating__symbols">
|
||||
${counter.map(index => {
|
||||
if (displayValue > index && displayValue < index + 1) {
|
||||
// Users can click the current value to clear the rating. When this happens, we set this.isHovering to
|
||||
// false to prevent the hover state from confusing them as they move the mouse out of the control. This
|
||||
// extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol.
|
||||
return html`
|
||||
<span
|
||||
class=${classMap({
|
||||
rating__symbol: true,
|
||||
'rating__partial-symbol-container': true,
|
||||
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
|
||||
})}
|
||||
role="presentation"
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
>
|
||||
<div
|
||||
style=${styleMap({
|
||||
clipPath: isRtl
|
||||
? `inset(0 ${(displayValue - index) * 100}% 0 0)`
|
||||
: `inset(0 0 0 ${(displayValue - index) * 100}%)`
|
||||
})}
|
||||
>
|
||||
${unsafeHTML(this.getSymbol(index + 1))}
|
||||
</div>
|
||||
<div
|
||||
class="rating__partial--filled"
|
||||
style=${styleMap({
|
||||
clipPath: isRtl
|
||||
? `inset(0 0 0 ${100 - (displayValue - index) * 100}%)`
|
||||
: `inset(0 ${100 - (displayValue - index) * 100}% 0 0)`
|
||||
})}
|
||||
>
|
||||
${unsafeHTML(this.getSymbol(index + 1))}
|
||||
</div>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<span
|
||||
class=${classMap({
|
||||
rating__symbol: true,
|
||||
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1,
|
||||
'rating__symbol--active': displayValue >= index + 1
|
||||
})}
|
||||
role="presentation"
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
>
|
||||
${unsafeHTML(this.getSymbol(index + 1))}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-rating': SlRating;
|
||||
}
|
||||
}
|
||||
import SlRating from './rating.component.js';
|
||||
export * from './rating.component.js';
|
||||
export default SlRating;
|
||||
SlRating.define('sl-rating');
|
||||
|
||||
127
src/components/relative-time/relative-time.component.ts
Normal file
127
src/components/relative-time/relative-time.component.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
interface UnitConfig {
|
||||
max: number;
|
||||
value: number;
|
||||
unit: Intl.RelativeTimeFormatUnit;
|
||||
}
|
||||
|
||||
const availableUnits: UnitConfig[] = [
|
||||
{ max: 2760000, value: 60000, unit: 'minute' }, // max 46 minutes
|
||||
{ max: 72000000, value: 3600000, unit: 'hour' }, // max 20 hours
|
||||
{ max: 518400000, value: 86400000, unit: 'day' }, // max 6 days
|
||||
{ max: 2419200000, value: 604800000, unit: 'week' }, // max 28 days
|
||||
{ max: 28512000000, value: 2592000000, unit: 'month' }, // max 11 months
|
||||
{ max: Infinity, value: 31536000000, unit: 'year' }
|
||||
];
|
||||
|
||||
/**
|
||||
* @summary Outputs a localized time phrase relative to the current date and time.
|
||||
* @documentation https://shoelace.style/components/relative-time
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
export default class SlRelativeTime extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private updateTimeout: number;
|
||||
|
||||
@state() private isoTime = '';
|
||||
@state() private relativeTime = '';
|
||||
@state() private titleTime = '';
|
||||
|
||||
/**
|
||||
* The date from which to calculate time from. If not set, the current date and time will be used. When passing a
|
||||
* string, it's strongly recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert
|
||||
* a date to this format in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
|
||||
*/
|
||||
@property() date: Date | string = new Date();
|
||||
|
||||
/** The formatting style to use. */
|
||||
@property() format: 'long' | 'short' | 'narrow' = 'long';
|
||||
|
||||
/**
|
||||
* When `auto`, values such as "yesterday" and "tomorrow" will be shown when possible. When `always`, values such as
|
||||
* "1 day ago" and "in 1 day" will be shown.
|
||||
*/
|
||||
@property() numeric: 'always' | 'auto' = 'auto';
|
||||
|
||||
/** Keep the displayed value up to date as time passes. */
|
||||
@property({ type: Boolean }) sync = false;
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this.updateTimeout);
|
||||
}
|
||||
|
||||
render() {
|
||||
const now = new Date();
|
||||
const then = new Date(this.date);
|
||||
|
||||
// Check for an invalid date
|
||||
if (isNaN(then.getMilliseconds())) {
|
||||
this.relativeTime = '';
|
||||
this.isoTime = '';
|
||||
return '';
|
||||
}
|
||||
|
||||
const diff = then.getTime() - now.getTime();
|
||||
const { unit, value } = availableUnits.find(singleUnit => Math.abs(diff) < singleUnit.max)!;
|
||||
|
||||
this.isoTime = then.toISOString();
|
||||
this.titleTime = this.localize.date(then, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
this.relativeTime = this.localize.relativeTime(Math.round(diff / value), unit, {
|
||||
numeric: this.numeric,
|
||||
style: this.format
|
||||
});
|
||||
|
||||
// If sync is enabled, update as time passes
|
||||
clearTimeout(this.updateTimeout);
|
||||
|
||||
if (this.sync) {
|
||||
let nextInterval: number;
|
||||
|
||||
// NOTE: this could be optimized to determine when the next update should actually occur, but the size and cost of
|
||||
// that logic probably isn't worth the performance benefit
|
||||
if (unit === 'minute') {
|
||||
nextInterval = getTimeUntilNextUnit('second');
|
||||
} else if (unit === 'hour') {
|
||||
nextInterval = getTimeUntilNextUnit('minute');
|
||||
} else if (unit === 'day') {
|
||||
nextInterval = getTimeUntilNextUnit('hour');
|
||||
} else {
|
||||
// Cap updates at once per day. It's unlikely a user will reach this value, plus setTimeout has a limit on the
|
||||
// value it can accept. https://stackoverflow.com/a/3468650/567486
|
||||
nextInterval = getTimeUntilNextUnit('day'); // next day
|
||||
}
|
||||
|
||||
this.updateTimeout = window.setTimeout(() => this.requestUpdate(), nextInterval);
|
||||
}
|
||||
|
||||
return html` <time datetime=${this.isoTime} title=${this.titleTime}>${this.relativeTime}</time> `;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates the number of milliseconds until the next respective unit changes. This ensures that all components
|
||||
// update at the same time which is less distracting than updating independently.
|
||||
function getTimeUntilNextUnit(unit: 'second' | 'minute' | 'hour' | 'day') {
|
||||
const units = { second: 1000, minute: 60000, hour: 3600000, day: 86400000 };
|
||||
const value = units[unit];
|
||||
return value - (Date.now() % value);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-relative-time': SlRelativeTime;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,4 @@
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
interface UnitConfig {
|
||||
max: number;
|
||||
value: number;
|
||||
unit: Intl.RelativeTimeFormatUnit;
|
||||
}
|
||||
|
||||
const availableUnits: UnitConfig[] = [
|
||||
{ max: 2760000, value: 60000, unit: 'minute' }, // max 46 minutes
|
||||
{ max: 72000000, value: 3600000, unit: 'hour' }, // max 20 hours
|
||||
{ max: 518400000, value: 86400000, unit: 'day' }, // max 6 days
|
||||
{ max: 2419200000, value: 604800000, unit: 'week' }, // max 28 days
|
||||
{ max: 28512000000, value: 2592000000, unit: 'month' }, // max 11 months
|
||||
{ max: Infinity, value: 31536000000, unit: 'year' }
|
||||
];
|
||||
|
||||
/**
|
||||
* @summary Outputs a localized time phrase relative to the current date and time.
|
||||
* @documentation https://shoelace.style/components/relative-time
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
@customElement('sl-relative-time')
|
||||
export default class SlRelativeTime extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private updateTimeout: number;
|
||||
|
||||
@state() private isoTime = '';
|
||||
@state() private relativeTime = '';
|
||||
@state() private titleTime = '';
|
||||
|
||||
/**
|
||||
* The date from which to calculate time from. If not set, the current date and time will be used. When passing a
|
||||
* string, it's strongly recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert
|
||||
* a date to this format in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
|
||||
*/
|
||||
@property() date: Date | string = new Date();
|
||||
|
||||
/** The formatting style to use. */
|
||||
@property() format: 'long' | 'short' | 'narrow' = 'long';
|
||||
|
||||
/**
|
||||
* When `auto`, values such as "yesterday" and "tomorrow" will be shown when possible. When `always`, values such as
|
||||
* "1 day ago" and "in 1 day" will be shown.
|
||||
*/
|
||||
@property() numeric: 'always' | 'auto' = 'auto';
|
||||
|
||||
/** Keep the displayed value up to date as time passes. */
|
||||
@property({ type: Boolean }) sync = false;
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this.updateTimeout);
|
||||
}
|
||||
|
||||
render() {
|
||||
const now = new Date();
|
||||
const then = new Date(this.date);
|
||||
|
||||
// Check for an invalid date
|
||||
if (isNaN(then.getMilliseconds())) {
|
||||
this.relativeTime = '';
|
||||
this.isoTime = '';
|
||||
return '';
|
||||
}
|
||||
|
||||
const diff = then.getTime() - now.getTime();
|
||||
const { unit, value } = availableUnits.find(singleUnit => Math.abs(diff) < singleUnit.max)!;
|
||||
|
||||
this.isoTime = then.toISOString();
|
||||
this.titleTime = this.localize.date(then, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
this.relativeTime = this.localize.relativeTime(Math.round(diff / value), unit, {
|
||||
numeric: this.numeric,
|
||||
style: this.format
|
||||
});
|
||||
|
||||
// If sync is enabled, update as time passes
|
||||
clearTimeout(this.updateTimeout);
|
||||
|
||||
if (this.sync) {
|
||||
let nextInterval: number;
|
||||
|
||||
// NOTE: this could be optimized to determine when the next update should actually occur, but the size and cost of
|
||||
// that logic probably isn't worth the performance benefit
|
||||
if (unit === 'minute') {
|
||||
nextInterval = getTimeUntilNextUnit('second');
|
||||
} else if (unit === 'hour') {
|
||||
nextInterval = getTimeUntilNextUnit('minute');
|
||||
} else if (unit === 'day') {
|
||||
nextInterval = getTimeUntilNextUnit('hour');
|
||||
} else {
|
||||
// Cap updates at once per day. It's unlikely a user will reach this value, plus setTimeout has a limit on the
|
||||
// value it can accept. https://stackoverflow.com/a/3468650/567486
|
||||
nextInterval = getTimeUntilNextUnit('day'); // next day
|
||||
}
|
||||
|
||||
this.updateTimeout = window.setTimeout(() => this.requestUpdate(), nextInterval);
|
||||
}
|
||||
|
||||
return html` <time datetime=${this.isoTime} title=${this.titleTime}>${this.relativeTime}</time> `;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates the number of milliseconds until the next respective unit changes. This ensures that all components
|
||||
// update at the same time which is less distracting than updating independently.
|
||||
function getTimeUntilNextUnit(unit: 'second' | 'minute' | 'hour' | 'day') {
|
||||
const units = { second: 1000, minute: 60000, hour: 3600000, day: 86400000 };
|
||||
const value = units[unit];
|
||||
return value - (Date.now() % value);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-relative-time': SlRelativeTime;
|
||||
}
|
||||
}
|
||||
import SlRelativeTime from './relative-time.component.js';
|
||||
export * from './relative-time.component.js';
|
||||
export default SlRelativeTime;
|
||||
SlRelativeTime.define('sl-relative-time');
|
||||
|
||||
89
src/components/resize-observer/resize-observer.component.ts
Normal file
89
src/components/resize-observer/resize-observer.component.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './resize-observer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary The Resize Observer component offers a thin, declarative interface to the [`ResizeObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
|
||||
* @documentation https://shoelace.style/components/resize-observer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more elements to watch for resizing.
|
||||
*
|
||||
* @event {{ entries: ResizeObserverEntry[] }} sl-resize - Emitted when the element is resized.
|
||||
*/
|
||||
export default class SlResizeObserver extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private resizeObserver: ResizeObserver;
|
||||
private observedElements: HTMLElement[] = [];
|
||||
|
||||
/** Disables the observer. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
this.emit('sl-resize', { detail: { entries } });
|
||||
});
|
||||
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.stopObserver();
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
private startObserver() {
|
||||
const slot = this.shadowRoot!.querySelector('slot');
|
||||
|
||||
if (slot !== null) {
|
||||
const elements = slot.assignedElements({ flatten: true }) as HTMLElement[];
|
||||
|
||||
// Unwatch previous elements
|
||||
this.observedElements.forEach(el => this.resizeObserver.unobserve(el));
|
||||
this.observedElements = [];
|
||||
|
||||
// Watch new elements
|
||||
elements.forEach(el => {
|
||||
this.resizeObserver.observe(el);
|
||||
this.observedElements.push(el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private stopObserver() {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
if (this.disabled) {
|
||||
this.stopObserver();
|
||||
} else {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-resize-observer': SlResizeObserver;
|
||||
}
|
||||
}
|
||||
@@ -1,90 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './resize-observer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary The Resize Observer component offers a thin, declarative interface to the [`ResizeObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
|
||||
* @documentation https://shoelace.style/components/resize-observer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more elements to watch for resizing.
|
||||
*
|
||||
* @event {{ entries: ResizeObserverEntry[] }} sl-resize - Emitted when the element is resized.
|
||||
*/
|
||||
@customElement('sl-resize-observer')
|
||||
export default class SlResizeObserver extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private resizeObserver: ResizeObserver;
|
||||
private observedElements: HTMLElement[] = [];
|
||||
|
||||
/** Disables the observer. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
this.emit('sl-resize', { detail: { entries } });
|
||||
});
|
||||
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.stopObserver();
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
private startObserver() {
|
||||
const slot = this.shadowRoot!.querySelector('slot');
|
||||
|
||||
if (slot !== null) {
|
||||
const elements = slot.assignedElements({ flatten: true }) as HTMLElement[];
|
||||
|
||||
// Unwatch previous elements
|
||||
this.observedElements.forEach(el => this.resizeObserver.unobserve(el));
|
||||
this.observedElements = [];
|
||||
|
||||
// Watch new elements
|
||||
elements.forEach(el => {
|
||||
this.resizeObserver.observe(el);
|
||||
this.observedElements.push(el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private stopObserver() {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
if (this.disabled) {
|
||||
this.stopObserver();
|
||||
} else {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-resize-observer': SlResizeObserver;
|
||||
}
|
||||
}
|
||||
import SlResizeObserver from './resize-observer.component.js';
|
||||
export * from './resize-observer.component.js';
|
||||
export default SlResizeObserver;
|
||||
SlResizeObserver.define('sl-resize-observer');
|
||||
|
||||
874
src/components/select/select.component.ts
Normal file
874
src/components/select/select.component.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { scrollIntoView } from '../../internal/scroll.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import SlPopup from '../popup/popup.component.js';
|
||||
import SlTag from '../tag/tag.component.js';
|
||||
import styles from './select.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
import type SlOption from '../option/option.component.js';
|
||||
import type SlRemoveEvent from '../../events/sl-remove.js';
|
||||
|
||||
/**
|
||||
* @summary Selects allow you to choose items from a menu of predefined options.
|
||||
* @documentation https://shoelace.style/components/select
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
* @dependency sl-popup
|
||||
* @dependency sl-tag
|
||||
*
|
||||
* @slot - The listbox options. Must be `<sl-option>` elements. You can use `<sl-divider>` to group items visually.
|
||||
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot prefix - Used to prepend a presentational icon or similar element to the combobox.
|
||||
* @slot clear-icon - An icon to use in lieu of the default clear icon.
|
||||
* @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-change - Emitted when the control's value changes.
|
||||
* @event sl-clear - Emitted when the control's value is cleared.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-show - Emitted when the select's menu opens.
|
||||
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the select's menu closes.
|
||||
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The select's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
|
||||
* @csspart prefix - The container that wraps the prefix slot.
|
||||
* @csspart display-input - The element that displays the selected option's label, an `<input>` element.
|
||||
* @csspart listbox - The listbox container where options are slotted.
|
||||
* @csspart tags - The container that houses option tags when `multiselect` is used.
|
||||
* @csspart tag - The individual tags that represent each multiselect option.
|
||||
* @csspart tag__base - The tag's base part.
|
||||
* @csspart tag__content - The tag's content part.
|
||||
* @csspart tag__remove-button - The tag's remove button.
|
||||
* @csspart tag__remove-button__base - The tag's remove button base part.
|
||||
* @csspart clear-button - The clear button.
|
||||
* @csspart expand-icon - The container that wraps the expand icon.
|
||||
*/
|
||||
export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon,
|
||||
'sl-popup': SlPopup,
|
||||
'sl-tag': SlTag
|
||||
};
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private typeToSelectString = '';
|
||||
private typeToSelectTimeout: number;
|
||||
|
||||
@query('.select') popup: SlPopup;
|
||||
@query('.select__combobox') combobox: HTMLSlotElement;
|
||||
@query('.select__display-input') displayInput: HTMLInputElement;
|
||||
@query('.select__value-input') valueInput: HTMLInputElement;
|
||||
@query('.select__listbox') listbox: HTMLSlotElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() displayLabel = '';
|
||||
@state() currentOption: SlOption;
|
||||
@state() selectedOptions: SlOption[] = [];
|
||||
|
||||
/** The name of the select, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/**
|
||||
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
|
||||
* value will be a space-delimited list of values based on the options selected.
|
||||
*/
|
||||
@property({
|
||||
converter: {
|
||||
fromAttribute: (value: string) => value.split(' '),
|
||||
toAttribute: (value: string[]) => value.join(' ')
|
||||
}
|
||||
})
|
||||
value: string | string[] = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue: string | string[] = '';
|
||||
|
||||
/** The select's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Placeholder text to show as a hint when the select is empty. */
|
||||
@property() placeholder = '';
|
||||
|
||||
/** Allows more than one option to be selected. */
|
||||
@property({ type: Boolean, reflect: true }) multiple = false;
|
||||
|
||||
/**
|
||||
* The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to
|
||||
* indicate the number of additional items that are selected. Set to 0 to remove the limit.
|
||||
*/
|
||||
@property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3;
|
||||
|
||||
/** Disables the select control. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Adds a clear button when the select is not empty. */
|
||||
@property({ type: Boolean }) clearable = false;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the select is open. You can toggle this attribute to show and hide the menu, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the select's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* Enable this option to prevent the listbox from being clipped when the component is placed inside a container with
|
||||
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
|
||||
*/
|
||||
@property({ type: Boolean }) hoist = false;
|
||||
|
||||
/** Draws a filled select. */
|
||||
@property({ type: Boolean, reflect: true }) filled = false;
|
||||
|
||||
/** Draws a pill-style select with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** The select's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/**
|
||||
* The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox
|
||||
* inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom';
|
||||
|
||||
/** The select's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** The select's required attribute. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.valueInput.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.valueInput.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Because this is a form control, it shouldn't be opened initially
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('focusin', this.handleDocumentFocusIn);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('focusin', this.handleDocumentFocusIn);
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.displayInput.setSelectionRange(0, 0);
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleDocumentFocusIn = (event: KeyboardEvent) => {
|
||||
// Close when focusing out of the select
|
||||
const path = event.composedPath();
|
||||
if (this && !path.includes(this)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isClearButton = target.closest('.select__clear') !== null;
|
||||
const isIconButton = target.closest('sl-icon-button') !== null;
|
||||
|
||||
// Ignore presses when the target is an icon button (e.g. the remove button in <sl-tag>)
|
||||
if (isClearButton || isIconButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close when pressing escape
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
|
||||
// buffer we _don't_ close it.
|
||||
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
// If it's not open, open it
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it is open, update the value based on the current selection and close it
|
||||
if (this.currentOption && !this.currentOption.disabled) {
|
||||
if (this.multiple) {
|
||||
this.toggleOptionSelection(this.currentOption);
|
||||
} else {
|
||||
this.setSelectedOptions(this.currentOption);
|
||||
}
|
||||
|
||||
// Emit after updating
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
|
||||
if (!this.multiple) {
|
||||
this.hide();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate options
|
||||
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||
const allOptions = this.getAllOptions();
|
||||
const currentIndex = allOptions.indexOf(this.currentOption);
|
||||
let newIndex = Math.max(0, currentIndex);
|
||||
|
||||
// Prevent scrolling
|
||||
event.preventDefault();
|
||||
|
||||
// Open it
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
|
||||
// If an option is already selected, stop here because we want that one to remain highlighted when the listbox
|
||||
// opens for the first time
|
||||
if (this.currentOption) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
newIndex = currentIndex + 1;
|
||||
if (newIndex > allOptions.length - 1) newIndex = 0;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
newIndex = currentIndex - 1;
|
||||
if (newIndex < 0) newIndex = allOptions.length - 1;
|
||||
} else if (event.key === 'Home') {
|
||||
newIndex = 0;
|
||||
} else if (event.key === 'End') {
|
||||
newIndex = allOptions.length - 1;
|
||||
}
|
||||
|
||||
this.setCurrentOption(allOptions[newIndex]);
|
||||
}
|
||||
|
||||
// All other "printable" keys trigger type to select
|
||||
if (event.key.length === 1 || event.key === 'Backspace') {
|
||||
const allOptions = this.getAllOptions();
|
||||
|
||||
// Don't block important key combos like CMD+R
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open, unless the key that triggered is backspace
|
||||
if (!this.open) {
|
||||
if (event.key === 'Backspace') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.show();
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
clearTimeout(this.typeToSelectTimeout);
|
||||
this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000);
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
this.typeToSelectString = this.typeToSelectString.slice(0, -1);
|
||||
} else {
|
||||
this.typeToSelectString += event.key.toLowerCase();
|
||||
}
|
||||
|
||||
for (const option of allOptions) {
|
||||
const label = option.getTextLabel().toLowerCase();
|
||||
|
||||
if (label.startsWith(this.typeToSelectString)) {
|
||||
this.setCurrentOption(option);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentMouseDown = (event: MouseEvent) => {
|
||||
// Close when clicking outside of the select
|
||||
const path = event.composedPath();
|
||||
if (this && !path.includes(this)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handleLabelClick() {
|
||||
this.displayInput.focus();
|
||||
}
|
||||
|
||||
private handleComboboxMouseDown(event: MouseEvent) {
|
||||
const path = event.composedPath();
|
||||
const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'sl-icon-button');
|
||||
|
||||
// Ignore disabled controls and clicks on tags (remove buttons)
|
||||
if (this.disabled || isIconButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
this.open = !this.open;
|
||||
}
|
||||
|
||||
private handleComboboxKeyDown(event: KeyboardEvent) {
|
||||
event.stopPropagation();
|
||||
this.handleDocumentKeyDown(event);
|
||||
}
|
||||
|
||||
private handleClearClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.value !== '') {
|
||||
this.setSelectedOptions([]);
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
|
||||
// Emit after update
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleClearMouseDown(event: MouseEvent) {
|
||||
// Don't lose focus or propagate events when clicking the clear button
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private handleOptionClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const option = target.closest('sl-option');
|
||||
const oldValue = this.value;
|
||||
|
||||
if (option && !option.disabled) {
|
||||
if (this.multiple) {
|
||||
this.toggleOptionSelection(option);
|
||||
} else {
|
||||
this.setSelectedOptions(option);
|
||||
}
|
||||
|
||||
// Set focus after updating so the value is announced by screen readers
|
||||
this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
// Emit after updating
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.multiple) {
|
||||
this.hide();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const allOptions = this.getAllOptions();
|
||||
const value = Array.isArray(this.value) ? this.value : [this.value];
|
||||
const values: string[] = [];
|
||||
|
||||
// Check for duplicate values in menu items
|
||||
if (customElements.get('sl-option')) {
|
||||
allOptions.forEach(option => values.push(option.value));
|
||||
|
||||
// Select only the options that match the new value
|
||||
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
|
||||
} else {
|
||||
// Rerun this handler when <sl-option> is registered
|
||||
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
|
||||
}
|
||||
}
|
||||
|
||||
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.disabled) {
|
||||
this.toggleOptionSelection(option, false);
|
||||
|
||||
// Emit after updating
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Gets an array of all <sl-option> elements
|
||||
private getAllOptions() {
|
||||
return [...this.querySelectorAll<SlOption>('sl-option')];
|
||||
}
|
||||
|
||||
// Gets the first <sl-option> element
|
||||
private getFirstOption() {
|
||||
return this.querySelector<SlOption>('sl-option');
|
||||
}
|
||||
|
||||
// Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one
|
||||
// option may be "current" at a time.
|
||||
private setCurrentOption(option: SlOption | null) {
|
||||
const allOptions = this.getAllOptions();
|
||||
|
||||
// Clear selection
|
||||
allOptions.forEach(el => {
|
||||
el.current = false;
|
||||
el.tabIndex = -1;
|
||||
});
|
||||
|
||||
// Select the target option
|
||||
if (option) {
|
||||
this.currentOption = option;
|
||||
option.current = true;
|
||||
option.tabIndex = 0;
|
||||
option.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Sets the selected option(s)
|
||||
private setSelectedOptions(option: SlOption | SlOption[]) {
|
||||
const allOptions = this.getAllOptions();
|
||||
const newSelectedOptions = Array.isArray(option) ? option : [option];
|
||||
|
||||
// Clear existing selection
|
||||
allOptions.forEach(el => (el.selected = false));
|
||||
|
||||
// Set the new selection
|
||||
if (newSelectedOptions.length) {
|
||||
newSelectedOptions.forEach(el => (el.selected = true));
|
||||
}
|
||||
|
||||
// Update selection, value, and display label
|
||||
this.selectionChanged();
|
||||
}
|
||||
|
||||
// Toggles an option's selected state
|
||||
private toggleOptionSelection(option: SlOption, force?: boolean) {
|
||||
if (force === true || force === false) {
|
||||
option.selected = force;
|
||||
} else {
|
||||
option.selected = !option.selected;
|
||||
}
|
||||
|
||||
this.selectionChanged();
|
||||
}
|
||||
|
||||
// This method must be called whenever the selection changes. It will update the selected options cache, the current
|
||||
// value, and the display value
|
||||
private selectionChanged() {
|
||||
// Update selected options cache
|
||||
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
|
||||
|
||||
// Update the value and display label
|
||||
if (this.multiple) {
|
||||
this.value = this.selectedOptions.map(el => el.value);
|
||||
|
||||
if (this.placeholder && this.value.length === 0) {
|
||||
// When no items are selected, keep the value empty so the placeholder shows
|
||||
this.displayLabel = '';
|
||||
} else {
|
||||
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
|
||||
}
|
||||
} else {
|
||||
this.value = this.selectedOptions[0]?.value ?? '';
|
||||
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
|
||||
}
|
||||
|
||||
// Update validity
|
||||
this.updateComplete.then(() => {
|
||||
this.formControlController.updateValidity();
|
||||
});
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Close the listbox when the control is disabled
|
||||
if (this.disabled) {
|
||||
this.open = false;
|
||||
this.handleOpenChange();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
handleValueChange() {
|
||||
const allOptions = this.getAllOptions();
|
||||
const value = Array.isArray(this.value) ? this.value : [this.value];
|
||||
|
||||
// Select only the options that match the new value
|
||||
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open && !this.disabled) {
|
||||
// Reset the current option
|
||||
this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
|
||||
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
this.listbox.hidden = false;
|
||||
this.popup.active = true;
|
||||
|
||||
// Select the appropriate option based on value after the listbox opens
|
||||
requestAnimationFrame(() => {
|
||||
this.setCurrentOption(this.currentOption);
|
||||
});
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'select.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
|
||||
// Make sure the current option is scrolled into view (required for Safari)
|
||||
if (this.currentOption) {
|
||||
scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto');
|
||||
}
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
const { keyframes, options } = getAnimation(this, 'select.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
this.listbox.hidden = true;
|
||||
this.popup.active = false;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the listbox. */
|
||||
async show() {
|
||||
if (this.open || this.disabled) {
|
||||
this.open = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the listbox. */
|
||||
async hide() {
|
||||
if (!this.open || this.disabled) {
|
||||
this.open = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.valueInput.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.valueInput.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.valueInput.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Sets focus on the control. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.displayInput.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the control. */
|
||||
blur() {
|
||||
this.displayInput.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
|
||||
const isPlaceholderVisible = this.placeholder && this.value.length === 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
id="label"
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
@click=${this.handleLabelClick}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<sl-popup
|
||||
class=${classMap({
|
||||
select: true,
|
||||
'select--standard': true,
|
||||
'select--filled': this.filled,
|
||||
'select--pill': this.pill,
|
||||
'select--open': this.open,
|
||||
'select--disabled': this.disabled,
|
||||
'select--multiple': this.multiple,
|
||||
'select--focused': this.hasFocus,
|
||||
'select--placeholder-visible': isPlaceholderVisible,
|
||||
'select--top': this.placement === 'top',
|
||||
'select--bottom': this.placement === 'bottom',
|
||||
'select--small': this.size === 'small',
|
||||
'select--medium': this.size === 'medium',
|
||||
'select--large': this.size === 'large'
|
||||
})}
|
||||
placement=${this.placement}
|
||||
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
||||
flip
|
||||
shift
|
||||
sync="width"
|
||||
auto-size="vertical"
|
||||
auto-size-padding="10"
|
||||
>
|
||||
<div
|
||||
part="combobox"
|
||||
class="select__combobox"
|
||||
slot="anchor"
|
||||
@keydown=${this.handleComboboxKeyDown}
|
||||
@mousedown=${this.handleComboboxMouseDown}
|
||||
>
|
||||
<slot part="prefix" name="prefix" class="select__prefix"></slot>
|
||||
|
||||
<input
|
||||
part="display-input"
|
||||
class="select__display-input"
|
||||
type="text"
|
||||
placeholder=${this.placeholder}
|
||||
.disabled=${this.disabled}
|
||||
.value=${this.displayLabel}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
readonly
|
||||
aria-controls="listbox"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-haspopup="listbox"
|
||||
aria-labelledby="label"
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-describedby="help-text"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
|
||||
${this.multiple
|
||||
? html`
|
||||
<div part="tags" class="select__tags">
|
||||
${this.selectedOptions.map((option, index) => {
|
||||
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
|
||||
return html`
|
||||
<sl-tag
|
||||
part="tag"
|
||||
exportparts="
|
||||
base:tag__base,
|
||||
content:tag__content,
|
||||
remove-button:tag__remove-button,
|
||||
remove-button__base:tag__remove-button__base
|
||||
"
|
||||
?pill=${this.pill}
|
||||
size=${this.size}
|
||||
removable
|
||||
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
|
||||
>
|
||||
${option.getTextLabel()}
|
||||
</sl-tag>
|
||||
`;
|
||||
} else if (index === this.maxOptionsVisible) {
|
||||
return html` <sl-tag size=${this.size}> +${this.selectedOptions.length - index} </sl-tag> `;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<input
|
||||
class="select__value-input"
|
||||
type="text"
|
||||
?disabled=${this.disabled}
|
||||
?required=${this.required}
|
||||
.value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
@focus=${() => this.focus()}
|
||||
@invalid=${this.handleInvalid}
|
||||
/>
|
||||
|
||||
${hasClearIcon
|
||||
? html`
|
||||
<button
|
||||
part="clear-button"
|
||||
class="select__clear"
|
||||
type="button"
|
||||
aria-label=${this.localize.term('clearEntry')}
|
||||
@mousedown=${this.handleClearMouseDown}
|
||||
@click=${this.handleClearClick}
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot name="clear-icon">
|
||||
<sl-icon name="x-circle-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<slot name="expand-icon" part="expand-icon" class="select__expand-icon">
|
||||
<sl-icon library="system" name="chevron-down"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="listbox"
|
||||
role="listbox"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-multiselectable=${this.multiple ? 'true' : 'false'}
|
||||
aria-labelledby="label"
|
||||
part="listbox"
|
||||
class="select__listbox"
|
||||
tabindex="-1"
|
||||
@mouseup=${this.handleOptionClick}
|
||||
@slotchange=${this.handleDefaultSlotChange}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</sl-popup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('select.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.9 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('select.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.9 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-select': SlSelect;
|
||||
}
|
||||
}
|
||||
@@ -1,871 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import '../popup/popup.js';
|
||||
import '../tag/tag.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { scrollIntoView } from '../../internal/scroll.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './select.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
import type SlOption from '../option/option.js';
|
||||
import type SlPopup from '../popup/popup.js';
|
||||
import type SlRemoveEvent from '../../events/sl-remove.js';
|
||||
|
||||
/**
|
||||
* @summary Selects allow you to choose items from a menu of predefined options.
|
||||
* @documentation https://shoelace.style/components/select
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
* @dependency sl-popup
|
||||
* @dependency sl-tag
|
||||
*
|
||||
* @slot - The listbox options. Must be `<sl-option>` elements. You can use `<sl-divider>` to group items visually.
|
||||
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot prefix - Used to prepend a presentational icon or similar element to the combobox.
|
||||
* @slot clear-icon - An icon to use in lieu of the default clear icon.
|
||||
* @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-change - Emitted when the control's value changes.
|
||||
* @event sl-clear - Emitted when the control's value is cleared.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-show - Emitted when the select's menu opens.
|
||||
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the select's menu closes.
|
||||
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The select's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
|
||||
* @csspart prefix - The container that wraps the prefix slot.
|
||||
* @csspart display-input - The element that displays the selected option's label, an `<input>` element.
|
||||
* @csspart listbox - The listbox container where options are slotted.
|
||||
* @csspart tags - The container that houses option tags when `multiselect` is used.
|
||||
* @csspart tag - The individual tags that represent each multiselect option.
|
||||
* @csspart tag__base - The tag's base part.
|
||||
* @csspart tag__content - The tag's content part.
|
||||
* @csspart tag__remove-button - The tag's remove button.
|
||||
* @csspart tag__remove-button__base - The tag's remove button base part.
|
||||
* @csspart clear-button - The clear button.
|
||||
* @csspart expand-icon - The container that wraps the expand icon.
|
||||
*/
|
||||
@customElement('sl-select')
|
||||
export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private typeToSelectString = '';
|
||||
private typeToSelectTimeout: number;
|
||||
|
||||
@query('.select') popup: SlPopup;
|
||||
@query('.select__combobox') combobox: HTMLSlotElement;
|
||||
@query('.select__display-input') displayInput: HTMLInputElement;
|
||||
@query('.select__value-input') valueInput: HTMLInputElement;
|
||||
@query('.select__listbox') listbox: HTMLSlotElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() displayLabel = '';
|
||||
@state() currentOption: SlOption;
|
||||
@state() selectedOptions: SlOption[] = [];
|
||||
|
||||
/** The name of the select, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/**
|
||||
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
|
||||
* value will be a space-delimited list of values based on the options selected.
|
||||
*/
|
||||
@property({
|
||||
converter: {
|
||||
fromAttribute: (value: string) => value.split(' '),
|
||||
toAttribute: (value: string[]) => value.join(' ')
|
||||
}
|
||||
})
|
||||
value: string | string[] = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue: string | string[] = '';
|
||||
|
||||
/** The select's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Placeholder text to show as a hint when the select is empty. */
|
||||
@property() placeholder = '';
|
||||
|
||||
/** Allows more than one option to be selected. */
|
||||
@property({ type: Boolean, reflect: true }) multiple = false;
|
||||
|
||||
/**
|
||||
* The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to
|
||||
* indicate the number of additional items that are selected. Set to 0 to remove the limit.
|
||||
*/
|
||||
@property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3;
|
||||
|
||||
/** Disables the select control. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Adds a clear button when the select is not empty. */
|
||||
@property({ type: Boolean }) clearable = false;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the select is open. You can toggle this attribute to show and hide the menu, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the select's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* Enable this option to prevent the listbox from being clipped when the component is placed inside a container with
|
||||
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
|
||||
*/
|
||||
@property({ type: Boolean }) hoist = false;
|
||||
|
||||
/** Draws a filled select. */
|
||||
@property({ type: Boolean, reflect: true }) filled = false;
|
||||
|
||||
/** Draws a pill-style select with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** The select's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/**
|
||||
* The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox
|
||||
* inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom';
|
||||
|
||||
/** The select's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** The select's required attribute. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.valueInput.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.valueInput.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Because this is a form control, it shouldn't be opened initially
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('focusin', this.handleDocumentFocusIn);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('focusin', this.handleDocumentFocusIn);
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.displayInput.setSelectionRange(0, 0);
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleDocumentFocusIn = (event: KeyboardEvent) => {
|
||||
// Close when focusing out of the select
|
||||
const path = event.composedPath();
|
||||
if (this && !path.includes(this)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isClearButton = target.closest('.select__clear') !== null;
|
||||
const isIconButton = target.closest('sl-icon-button') !== null;
|
||||
|
||||
// Ignore presses when the target is an icon button (e.g. the remove button in <sl-tag>)
|
||||
if (isClearButton || isIconButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close when pressing escape
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
|
||||
// buffer we _don't_ close it.
|
||||
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
// If it's not open, open it
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it is open, update the value based on the current selection and close it
|
||||
if (this.currentOption && !this.currentOption.disabled) {
|
||||
if (this.multiple) {
|
||||
this.toggleOptionSelection(this.currentOption);
|
||||
} else {
|
||||
this.setSelectedOptions(this.currentOption);
|
||||
}
|
||||
|
||||
// Emit after updating
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
|
||||
if (!this.multiple) {
|
||||
this.hide();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate options
|
||||
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||
const allOptions = this.getAllOptions();
|
||||
const currentIndex = allOptions.indexOf(this.currentOption);
|
||||
let newIndex = Math.max(0, currentIndex);
|
||||
|
||||
// Prevent scrolling
|
||||
event.preventDefault();
|
||||
|
||||
// Open it
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
|
||||
// If an option is already selected, stop here because we want that one to remain highlighted when the listbox
|
||||
// opens for the first time
|
||||
if (this.currentOption) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
newIndex = currentIndex + 1;
|
||||
if (newIndex > allOptions.length - 1) newIndex = 0;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
newIndex = currentIndex - 1;
|
||||
if (newIndex < 0) newIndex = allOptions.length - 1;
|
||||
} else if (event.key === 'Home') {
|
||||
newIndex = 0;
|
||||
} else if (event.key === 'End') {
|
||||
newIndex = allOptions.length - 1;
|
||||
}
|
||||
|
||||
this.setCurrentOption(allOptions[newIndex]);
|
||||
}
|
||||
|
||||
// All other "printable" keys trigger type to select
|
||||
if (event.key.length === 1 || event.key === 'Backspace') {
|
||||
const allOptions = this.getAllOptions();
|
||||
|
||||
// Don't block important key combos like CMD+R
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open, unless the key that triggered is backspace
|
||||
if (!this.open) {
|
||||
if (event.key === 'Backspace') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.show();
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
clearTimeout(this.typeToSelectTimeout);
|
||||
this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000);
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
this.typeToSelectString = this.typeToSelectString.slice(0, -1);
|
||||
} else {
|
||||
this.typeToSelectString += event.key.toLowerCase();
|
||||
}
|
||||
|
||||
for (const option of allOptions) {
|
||||
const label = option.getTextLabel().toLowerCase();
|
||||
|
||||
if (label.startsWith(this.typeToSelectString)) {
|
||||
this.setCurrentOption(option);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentMouseDown = (event: MouseEvent) => {
|
||||
// Close when clicking outside of the select
|
||||
const path = event.composedPath();
|
||||
if (this && !path.includes(this)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handleLabelClick() {
|
||||
this.displayInput.focus();
|
||||
}
|
||||
|
||||
private handleComboboxMouseDown(event: MouseEvent) {
|
||||
const path = event.composedPath();
|
||||
const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'sl-icon-button');
|
||||
|
||||
// Ignore disabled controls and clicks on tags (remove buttons)
|
||||
if (this.disabled || isIconButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
this.open = !this.open;
|
||||
}
|
||||
|
||||
private handleComboboxKeyDown(event: KeyboardEvent) {
|
||||
event.stopPropagation();
|
||||
this.handleDocumentKeyDown(event);
|
||||
}
|
||||
|
||||
private handleClearClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.value !== '') {
|
||||
this.setSelectedOptions([]);
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
|
||||
// Emit after update
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleClearMouseDown(event: MouseEvent) {
|
||||
// Don't lose focus or propagate events when clicking the clear button
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private handleOptionClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const option = target.closest('sl-option');
|
||||
const oldValue = this.value;
|
||||
|
||||
if (option && !option.disabled) {
|
||||
if (this.multiple) {
|
||||
this.toggleOptionSelection(option);
|
||||
} else {
|
||||
this.setSelectedOptions(option);
|
||||
}
|
||||
|
||||
// Set focus after updating so the value is announced by screen readers
|
||||
this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }));
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
// Emit after updating
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.multiple) {
|
||||
this.hide();
|
||||
this.displayInput.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const allOptions = this.getAllOptions();
|
||||
const value = Array.isArray(this.value) ? this.value : [this.value];
|
||||
const values: string[] = [];
|
||||
|
||||
// Check for duplicate values in menu items
|
||||
if (customElements.get('sl-option')) {
|
||||
allOptions.forEach(option => values.push(option.value));
|
||||
|
||||
// Select only the options that match the new value
|
||||
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
|
||||
} else {
|
||||
// Rerun this handler when <sl-option> is registered
|
||||
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
|
||||
}
|
||||
}
|
||||
|
||||
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.disabled) {
|
||||
this.toggleOptionSelection(option, false);
|
||||
|
||||
// Emit after updating
|
||||
this.updateComplete.then(() => {
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Gets an array of all <sl-option> elements
|
||||
private getAllOptions() {
|
||||
return [...this.querySelectorAll<SlOption>('sl-option')];
|
||||
}
|
||||
|
||||
// Gets the first <sl-option> element
|
||||
private getFirstOption() {
|
||||
return this.querySelector<SlOption>('sl-option');
|
||||
}
|
||||
|
||||
// Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one
|
||||
// option may be "current" at a time.
|
||||
private setCurrentOption(option: SlOption | null) {
|
||||
const allOptions = this.getAllOptions();
|
||||
|
||||
// Clear selection
|
||||
allOptions.forEach(el => {
|
||||
el.current = false;
|
||||
el.tabIndex = -1;
|
||||
});
|
||||
|
||||
// Select the target option
|
||||
if (option) {
|
||||
this.currentOption = option;
|
||||
option.current = true;
|
||||
option.tabIndex = 0;
|
||||
option.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Sets the selected option(s)
|
||||
private setSelectedOptions(option: SlOption | SlOption[]) {
|
||||
const allOptions = this.getAllOptions();
|
||||
const newSelectedOptions = Array.isArray(option) ? option : [option];
|
||||
|
||||
// Clear existing selection
|
||||
allOptions.forEach(el => (el.selected = false));
|
||||
|
||||
// Set the new selection
|
||||
if (newSelectedOptions.length) {
|
||||
newSelectedOptions.forEach(el => (el.selected = true));
|
||||
}
|
||||
|
||||
// Update selection, value, and display label
|
||||
this.selectionChanged();
|
||||
}
|
||||
|
||||
// Toggles an option's selected state
|
||||
private toggleOptionSelection(option: SlOption, force?: boolean) {
|
||||
if (force === true || force === false) {
|
||||
option.selected = force;
|
||||
} else {
|
||||
option.selected = !option.selected;
|
||||
}
|
||||
|
||||
this.selectionChanged();
|
||||
}
|
||||
|
||||
// This method must be called whenever the selection changes. It will update the selected options cache, the current
|
||||
// value, and the display value
|
||||
private selectionChanged() {
|
||||
// Update selected options cache
|
||||
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
|
||||
|
||||
// Update the value and display label
|
||||
if (this.multiple) {
|
||||
this.value = this.selectedOptions.map(el => el.value);
|
||||
|
||||
if (this.placeholder && this.value.length === 0) {
|
||||
// When no items are selected, keep the value empty so the placeholder shows
|
||||
this.displayLabel = '';
|
||||
} else {
|
||||
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
|
||||
}
|
||||
} else {
|
||||
this.value = this.selectedOptions[0]?.value ?? '';
|
||||
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
|
||||
}
|
||||
|
||||
// Update validity
|
||||
this.updateComplete.then(() => {
|
||||
this.formControlController.updateValidity();
|
||||
});
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Close the listbox when the control is disabled
|
||||
if (this.disabled) {
|
||||
this.open = false;
|
||||
this.handleOpenChange();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
handleValueChange() {
|
||||
const allOptions = this.getAllOptions();
|
||||
const value = Array.isArray(this.value) ? this.value : [this.value];
|
||||
|
||||
// Select only the options that match the new value
|
||||
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open && !this.disabled) {
|
||||
// Reset the current option
|
||||
this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
|
||||
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
this.listbox.hidden = false;
|
||||
this.popup.active = true;
|
||||
|
||||
// Select the appropriate option based on value after the listbox opens
|
||||
requestAnimationFrame(() => {
|
||||
this.setCurrentOption(this.currentOption);
|
||||
});
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'select.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
|
||||
// Make sure the current option is scrolled into view (required for Safari)
|
||||
if (this.currentOption) {
|
||||
scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto');
|
||||
}
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
const { keyframes, options } = getAnimation(this, 'select.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
this.listbox.hidden = true;
|
||||
this.popup.active = false;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the listbox. */
|
||||
async show() {
|
||||
if (this.open || this.disabled) {
|
||||
this.open = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the listbox. */
|
||||
async hide() {
|
||||
if (!this.open || this.disabled) {
|
||||
this.open = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.valueInput.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.valueInput.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.valueInput.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Sets focus on the control. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.displayInput.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the control. */
|
||||
blur() {
|
||||
this.displayInput.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
|
||||
const isPlaceholderVisible = this.placeholder && this.value.length === 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
id="label"
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
@click=${this.handleLabelClick}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<sl-popup
|
||||
class=${classMap({
|
||||
select: true,
|
||||
'select--standard': true,
|
||||
'select--filled': this.filled,
|
||||
'select--pill': this.pill,
|
||||
'select--open': this.open,
|
||||
'select--disabled': this.disabled,
|
||||
'select--multiple': this.multiple,
|
||||
'select--focused': this.hasFocus,
|
||||
'select--placeholder-visible': isPlaceholderVisible,
|
||||
'select--top': this.placement === 'top',
|
||||
'select--bottom': this.placement === 'bottom',
|
||||
'select--small': this.size === 'small',
|
||||
'select--medium': this.size === 'medium',
|
||||
'select--large': this.size === 'large'
|
||||
})}
|
||||
placement=${this.placement}
|
||||
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
||||
flip
|
||||
shift
|
||||
sync="width"
|
||||
auto-size="vertical"
|
||||
auto-size-padding="10"
|
||||
>
|
||||
<div
|
||||
part="combobox"
|
||||
class="select__combobox"
|
||||
slot="anchor"
|
||||
@keydown=${this.handleComboboxKeyDown}
|
||||
@mousedown=${this.handleComboboxMouseDown}
|
||||
>
|
||||
<slot part="prefix" name="prefix" class="select__prefix"></slot>
|
||||
|
||||
<input
|
||||
part="display-input"
|
||||
class="select__display-input"
|
||||
type="text"
|
||||
placeholder=${this.placeholder}
|
||||
.disabled=${this.disabled}
|
||||
.value=${this.displayLabel}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
readonly
|
||||
aria-controls="listbox"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-haspopup="listbox"
|
||||
aria-labelledby="label"
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-describedby="help-text"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
|
||||
${this.multiple
|
||||
? html`
|
||||
<div part="tags" class="select__tags">
|
||||
${this.selectedOptions.map((option, index) => {
|
||||
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
|
||||
return html`
|
||||
<sl-tag
|
||||
part="tag"
|
||||
exportparts="
|
||||
base:tag__base,
|
||||
content:tag__content,
|
||||
remove-button:tag__remove-button,
|
||||
remove-button__base:tag__remove-button__base
|
||||
"
|
||||
?pill=${this.pill}
|
||||
size=${this.size}
|
||||
removable
|
||||
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
|
||||
>
|
||||
${option.getTextLabel()}
|
||||
</sl-tag>
|
||||
`;
|
||||
} else if (index === this.maxOptionsVisible) {
|
||||
return html` <sl-tag size=${this.size}> +${this.selectedOptions.length - index} </sl-tag> `;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<input
|
||||
class="select__value-input"
|
||||
type="text"
|
||||
?disabled=${this.disabled}
|
||||
?required=${this.required}
|
||||
.value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
@focus=${() => this.focus()}
|
||||
@invalid=${this.handleInvalid}
|
||||
/>
|
||||
|
||||
${hasClearIcon
|
||||
? html`
|
||||
<button
|
||||
part="clear-button"
|
||||
class="select__clear"
|
||||
type="button"
|
||||
aria-label=${this.localize.term('clearEntry')}
|
||||
@mousedown=${this.handleClearMouseDown}
|
||||
@click=${this.handleClearClick}
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot name="clear-icon">
|
||||
<sl-icon name="x-circle-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<slot name="expand-icon" part="expand-icon" class="select__expand-icon">
|
||||
<sl-icon library="system" name="chevron-down"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="listbox"
|
||||
role="listbox"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-multiselectable=${this.multiple ? 'true' : 'false'}
|
||||
aria-labelledby="label"
|
||||
part="listbox"
|
||||
class="select__listbox"
|
||||
tabindex="-1"
|
||||
@mouseup=${this.handleOptionClick}
|
||||
@slotchange=${this.handleDefaultSlotChange}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</sl-popup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('select.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.9 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('select.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.9 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-select': SlSelect;
|
||||
}
|
||||
}
|
||||
import SlSelect from './select.component.js';
|
||||
export * from './select.component.js';
|
||||
export default SlSelect;
|
||||
SlSelect.define('sl-select');
|
||||
|
||||
47
src/components/skeleton/skeleton.component.ts
Normal file
47
src/components/skeleton/skeleton.component.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './skeleton.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Skeletons are used to provide a visual representation of where content will eventually be drawn.
|
||||
* @documentation https://shoelace.style/components/skeleton
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart indicator - The skeleton's indicator which is responsible for its color and animation.
|
||||
*
|
||||
* @cssproperty --border-radius - The skeleton's border radius.
|
||||
* @cssproperty --color - The color of the skeleton.
|
||||
* @cssproperty --sheen-color - The sheen color when the skeleton is in its loading state.
|
||||
*/
|
||||
export default class SlSkeleton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** Determines which effect the skeleton will use. */
|
||||
@property() effect: 'pulse' | 'sheen' | 'none' = 'none';
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
skeleton: true,
|
||||
'skeleton--pulse': this.effect === 'pulse',
|
||||
'skeleton--sheen': this.effect === 'sheen'
|
||||
})}
|
||||
>
|
||||
<div part="indicator" class="skeleton__indicator"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-skeleton': SlSkeleton;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user