diff --git a/package-lock.json b/package-lock.json index 6adcd880..1969ec06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@ctrl/tinycolor": "^3.5.0", "@floating-ui/dom": "^1.2.1", "@lit-labs/react": "^1.1.1", + "@open-wc/scoped-elements": "^2.2.0", "@shoelace-style/animations": "^1.1.0", "@shoelace-style/localize": "^3.1.1", "composed-offset-position": "^0.0.4", "lit": "^2.7.5", - "qr-creator": "^1.0.0" + "qr-creator": "^1.0.0", + "web-component-define": "^2.0.10" }, "devDependencies": { "@11ty/eleventy": "^2.0.1", @@ -1658,19 +1660,17 @@ "dev": true }, "node_modules/@open-wc/dedupe-mixin": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.1.tgz", - "integrity": "sha512-ukowSvzpZQDUH0Y3znJTsY88HkiGk3Khc0WGpIPhap1xlerieYi27QBg6wx/nTurpWfU6XXXsx9ocxDYCdtw0Q==", - "dev": true + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz", + "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==" }, "node_modules/@open-wc/scoped-elements": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz", - "integrity": "sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==", - "dev": true, + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-wc/scoped-elements/-/scoped-elements-2.2.0.tgz", + "integrity": "sha512-Qe+vWsuVHFzUkdChwlmJGuQf9cA3I+QOsSHULV/6qf6wsqLM2/32svNRH+rbBIMwiPEwzZprZlkvkqQRucYnVA==", "dependencies": { "@lit/reactive-element": "^1.0.0", - "@open-wc/dedupe-mixin": "^1.3.0" + "@open-wc/dedupe-mixin": "^1.4.0" } }, "node_modules/@open-wc/semantic-dom-diff": { @@ -16744,6 +16744,15 @@ "defaults": "^1.0.3" } }, + "node_modules/web-component-define": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/web-component-define/-/web-component-define-2.0.10.tgz", + "integrity": "sha512-gwkjTFdG8eE8fxI4+RZUCQRy06SSSkCyLpQ1YSCsA+z8ZLlnmqLX/3B3WD2ZraVRtyje3hLXS8bxL8CK1/bZYQ==", + "dependencies": { + "@lit/reactive-element": "^1.6.1", + "@open-wc/dedupe-mixin": "^1.3.1" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -18379,19 +18388,17 @@ } }, "@open-wc/dedupe-mixin": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.1.tgz", - "integrity": "sha512-ukowSvzpZQDUH0Y3znJTsY88HkiGk3Khc0WGpIPhap1xlerieYi27QBg6wx/nTurpWfU6XXXsx9ocxDYCdtw0Q==", - "dev": true + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz", + "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==" }, "@open-wc/scoped-elements": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz", - "integrity": "sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==", - "dev": true, + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-wc/scoped-elements/-/scoped-elements-2.2.0.tgz", + "integrity": "sha512-Qe+vWsuVHFzUkdChwlmJGuQf9cA3I+QOsSHULV/6qf6wsqLM2/32svNRH+rbBIMwiPEwzZprZlkvkqQRucYnVA==", "requires": { "@lit/reactive-element": "^1.0.0", - "@open-wc/dedupe-mixin": "^1.3.0" + "@open-wc/dedupe-mixin": "^1.4.0" } }, "@open-wc/semantic-dom-diff": { @@ -29899,6 +29906,15 @@ "defaults": "^1.0.3" } }, + "web-component-define": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/web-component-define/-/web-component-define-2.0.10.tgz", + "integrity": "sha512-gwkjTFdG8eE8fxI4+RZUCQRy06SSSkCyLpQ1YSCsA+z8ZLlnmqLX/3B3WD2ZraVRtyje3hLXS8bxL8CK1/bZYQ==", + "requires": { + "@lit/reactive-element": "^1.6.1", + "@open-wc/dedupe-mixin": "^1.3.1" + } + }, "webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 7148d5a3..4e97a9f7 100644 --- a/package.json +++ b/package.json @@ -67,11 +67,13 @@ "@ctrl/tinycolor": "^3.5.0", "@floating-ui/dom": "^1.2.1", "@lit-labs/react": "^1.1.1", + "@open-wc/scoped-elements": "^2.2.0", "@shoelace-style/animations": "^1.1.0", "@shoelace-style/localize": "^3.1.1", "composed-offset-position": "^0.0.4", "lit": "^2.7.5", - "qr-creator": "^1.0.0" + "qr-creator": "^1.0.0", + "web-component-define": "^2.0.10" }, "devDependencies": { "@11ty/eleventy": "^2.0.1", diff --git a/src/components/alert/alert.component.ts b/src/components/alert/alert.component.ts new file mode 100644 index 00000000..d35c460e --- /dev/null +++ b/src/components/alert/alert.component.ts @@ -0,0 +1,249 @@ +import '../icon-button/icon-button'; +import { animateTo, stopAnimations } from '../../internal/animate'; +import { classMap } from 'lit/directives/class-map.js'; +import { property, query } from 'lit/decorators.js'; +import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; +import { HasSlotController } from '../../internal/slot'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize'; +import { waitForEvent } from '../../internal/event'; +import { watch } from '../../internal/watch'; +import ShoelaceElement from '../../internal/shoelace-element'; +import styles from './alert.styles'; +import type { CSSResultGroup } from 'lit'; +import SlIconButton from '../icon-button/icon-button'; + +const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' }); + +/** + * @summary Alerts are used to display important messages inline or as toast notifications. + * @documentation https://shoelace.style/components/alert + * @status stable + * @since 2.0 + * + * @dependency sl-icon-button + * + * @slot - The alert's main content. + * @slot icon - An icon to show in the alert. Works best with ``. + * + * @event sl-show - Emitted when the alert opens. + * @event sl-after-show - Emitted after the alert opens and all animations are complete. + * @event sl-hide - Emitted when the alert closes. + * @event sl-after-hide - Emitted after the alert closes and all animations are complete. + * + * @csspart base - The component's base wrapper. + * @csspart icon - The container that wraps the optional icon. + * @csspart message - The container that wraps the alert's main content. + * @csspart close-button - The close button, an ``. + * @csspart close-button__base - The close button's exported `base` part. + * + * @animation alert.show - The animation to use when showing the alert. + * @animation alert.hide - The animation to use when hiding the alert. + */ +export default class SlAlert extends ShoelaceElement { + static styles: CSSResultGroup = styles; + + static get scopedElements () { + return { + 'sl-icon-button': SlIconButton + } + } + + private autoHideTimeout: number; + private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix'); + private readonly localize = new LocalizeController(this); + + @query('[part~="base"]') base: HTMLElement; + + /** + * Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can + * use the `show()` and `hide()` methods and this attribute will reflect the alert's open state. + */ + @property({ type: Boolean, reflect: true }) open = false; + + /** Enables a close button that allows the user to dismiss the alert. */ + @property({ type: Boolean, reflect: true }) closable = false; + + /** The alert's theme variant. */ + @property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary'; + + /** + * The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with + * the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning + * the alert will not close on its own. + */ + @property({ type: Number }) duration = Infinity; + + firstUpdated() { + this.base.hidden = !this.open; + } + + private restartAutoHide() { + clearTimeout(this.autoHideTimeout); + if (this.open && this.duration < Infinity) { + this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration); + } + } + + private handleCloseClick() { + this.hide(); + } + + private handleMouseMove() { + this.restartAutoHide(); + } + + @watch('open', { waitUntilFirstUpdate: true }) + async handleOpenChange() { + if (this.open) { + // Show + this.emit('sl-show'); + + if (this.duration < Infinity) { + this.restartAutoHide(); + } + + await stopAnimations(this.base); + this.base.hidden = false; + const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() }); + await animateTo(this.base, keyframes, options); + + this.emit('sl-after-show'); + } else { + // Hide + this.emit('sl-hide'); + + clearTimeout(this.autoHideTimeout); + + await stopAnimations(this.base); + const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() }); + await animateTo(this.base, keyframes, options); + this.base.hidden = true; + + this.emit('sl-after-hide'); + } + } + + @watch('duration') + handleDurationChange() { + this.restartAutoHide(); + } + + /** Shows the alert. */ + async show() { + if (this.open) { + return undefined; + } + + this.open = true; + return waitForEvent(this, 'sl-after-show'); + } + + /** Hides the alert */ + async hide() { + if (!this.open) { + return undefined; + } + + this.open = false; + return waitForEvent(this, 'sl-after-hide'); + } + + /** + * Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when + * dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by + * calling this method again. The returned promise will resolve after the alert is hidden. + */ + async toast() { + return new Promise(resolve => { + if (toastStack.parentElement === null) { + document.body.append(toastStack); + } + + toastStack.appendChild(this); + + // Wait for the toast stack to render + requestAnimationFrame(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition + this.clientWidth; + this.show(); + }); + + this.addEventListener( + 'sl-after-hide', + () => { + toastStack.removeChild(this); + resolve(); + + // Remove the toast stack from the DOM when there are no more alerts + if (toastStack.querySelector('sl-alert') === null) { + toastStack.remove(); + } + }, + { once: true } + ); + }); + } + + render() { + return html` + + `; + } +} + +setDefaultAnimation('alert.show', { + keyframes: [ + { opacity: 0, scale: 0.8 }, + { opacity: 1, scale: 1 } + ], + options: { duration: 250, easing: 'ease' } +}); + +setDefaultAnimation('alert.hide', { + keyframes: [ + { opacity: 1, scale: 1 }, + { opacity: 0, scale: 0.8 } + ], + options: { duration: 250, easing: 'ease' } +}); + +declare global { + interface HTMLElementTagNameMap { + 'sl-alert': SlAlert; + } +} diff --git a/src/components/alert/alert.ts b/src/components/alert/alert.ts index 19f5519c..3a706406 100644 --- a/src/components/alert/alert.ts +++ b/src/components/alert/alert.ts @@ -1,244 +1,5 @@ -import '../icon-button/icon-button'; -import { animateTo, stopAnimations } from '../../internal/animate'; -import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property, query } from 'lit/decorators.js'; -import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; -import { HasSlotController } from '../../internal/slot'; -import { html } from 'lit'; -import { LocalizeController } from '../../utilities/localize'; -import { waitForEvent } from '../../internal/event'; -import { watch } from '../../internal/watch'; -import ShoelaceElement from '../../internal/shoelace-element'; -import styles from './alert.styles'; -import type { CSSResultGroup } from 'lit'; +import SlAlert from "./alert.component" +export * from "./alert.component" -const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' }); - -/** - * @summary Alerts are used to display important messages inline or as toast notifications. - * @documentation https://shoelace.style/components/alert - * @status stable - * @since 2.0 - * - * @dependency sl-icon-button - * - * @slot - The alert's main content. - * @slot icon - An icon to show in the alert. Works best with ``. - * - * @event sl-show - Emitted when the alert opens. - * @event sl-after-show - Emitted after the alert opens and all animations are complete. - * @event sl-hide - Emitted when the alert closes. - * @event sl-after-hide - Emitted after the alert closes and all animations are complete. - * - * @csspart base - The component's base wrapper. - * @csspart icon - The container that wraps the optional icon. - * @csspart message - The container that wraps the alert's main content. - * @csspart close-button - The close button, an ``. - * @csspart close-button__base - The close button's exported `base` part. - * - * @animation alert.show - The animation to use when showing the alert. - * @animation alert.hide - The animation to use when hiding the alert. - */ - -@customElement('sl-alert') -export default class SlAlert extends ShoelaceElement { - static styles: CSSResultGroup = styles; - - private autoHideTimeout: number; - private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix'); - private readonly localize = new LocalizeController(this); - - @query('[part~="base"]') base: HTMLElement; - - /** - * Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can - * use the `show()` and `hide()` methods and this attribute will reflect the alert's open state. - */ - @property({ type: Boolean, reflect: true }) open = false; - - /** Enables a close button that allows the user to dismiss the alert. */ - @property({ type: Boolean, reflect: true }) closable = false; - - /** The alert's theme variant. */ - @property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary'; - - /** - * The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with - * the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning - * the alert will not close on its own. - */ - @property({ type: Number }) duration = Infinity; - - firstUpdated() { - this.base.hidden = !this.open; - } - - private restartAutoHide() { - clearTimeout(this.autoHideTimeout); - if (this.open && this.duration < Infinity) { - this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration); - } - } - - private handleCloseClick() { - this.hide(); - } - - private handleMouseMove() { - this.restartAutoHide(); - } - - @watch('open', { waitUntilFirstUpdate: true }) - async handleOpenChange() { - if (this.open) { - // Show - this.emit('sl-show'); - - if (this.duration < Infinity) { - this.restartAutoHide(); - } - - await stopAnimations(this.base); - this.base.hidden = false; - const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() }); - await animateTo(this.base, keyframes, options); - - this.emit('sl-after-show'); - } else { - // Hide - this.emit('sl-hide'); - - clearTimeout(this.autoHideTimeout); - - await stopAnimations(this.base); - const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() }); - await animateTo(this.base, keyframes, options); - this.base.hidden = true; - - this.emit('sl-after-hide'); - } - } - - @watch('duration') - handleDurationChange() { - this.restartAutoHide(); - } - - /** Shows the alert. */ - async show() { - if (this.open) { - return undefined; - } - - this.open = true; - return waitForEvent(this, 'sl-after-show'); - } - - /** Hides the alert */ - async hide() { - if (!this.open) { - return undefined; - } - - this.open = false; - return waitForEvent(this, 'sl-after-hide'); - } - - /** - * Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when - * dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by - * calling this method again. The returned promise will resolve after the alert is hidden. - */ - async toast() { - return new Promise(resolve => { - if (toastStack.parentElement === null) { - document.body.append(toastStack); - } - - toastStack.appendChild(this); - - // Wait for the toast stack to render - requestAnimationFrame(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition - this.clientWidth; - this.show(); - }); - - this.addEventListener( - 'sl-after-hide', - () => { - toastStack.removeChild(this); - resolve(); - - // Remove the toast stack from the DOM when there are no more alerts - if (toastStack.querySelector('sl-alert') === null) { - toastStack.remove(); - } - }, - { once: true } - ); - }); - } - - render() { - return html` - - `; - } -} - -setDefaultAnimation('alert.show', { - keyframes: [ - { opacity: 0, scale: 0.8 }, - { opacity: 1, scale: 1 } - ], - options: { duration: 250, easing: 'ease' } -}); - -setDefaultAnimation('alert.hide', { - keyframes: [ - { opacity: 1, scale: 1 }, - { opacity: 0, scale: 0.8 } - ], - options: { duration: 250, easing: 'ease' } -}); - -declare global { - interface HTMLElementTagNameMap { - 'sl-alert': SlAlert; - } -} +export default SlAlert +window.customElements.define("sl-alert", SlAlert) diff --git a/src/components/animated-image/animated-image.ts b/src/components/animated-image/animated-image.ts index 71aaf6e8..e939a21d 100644 --- a/src/components/animated-image/animated-image.ts +++ b/src/components/animated-image/animated-image.ts @@ -5,6 +5,7 @@ import { watch } from '../../internal/watch'; import ShoelaceElement from '../../internal/shoelace-element'; import styles from './animated-image.styles'; import type { CSSResultGroup } from 'lit'; +import SlIcon from '../icon/icon'; /** * @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction. @@ -28,6 +29,9 @@ import type { CSSResultGroup } from 'lit'; @customElement('sl-animated-image') export default class SlAnimatedImage extends ShoelaceElement { static styles: CSSResultGroup = styles; + static scopedElements = { + 'sl-icon': SlIcon + } @query('.animated-image__animated') animatedImage: HTMLImageElement; diff --git a/src/internal/shoelace-element.ts b/src/internal/shoelace-element.ts index 962dcef9..d5252340 100644 --- a/src/internal/shoelace-element.ts +++ b/src/internal/shoelace-element.ts @@ -1,5 +1,6 @@ import { LitElement } from 'lit'; import { property } from 'lit/decorators.js'; +import { ScopedElementsMixin } from "@open-wc/scoped-elements" // Match event type name strings that are registered on GlobalEventHandlersEventMap... type EventTypeRequiresDetail = T extends keyof GlobalEventHandlersEventMap @@ -62,11 +63,13 @@ type GetCustomEventType = T extends keyof GlobalEventHandlersEventMap // `keyof ValidEventTypeMap` is equivalent to `keyof GlobalEventHandlersEventMap` but gives a nicer error message type ValidEventTypeMap = EventTypesWithRequiredDetail | EventTypesWithoutRequiredDetail; -export default class ShoelaceElement extends LitElement { +export default class ShoelaceElement extends ScopedElementsMixin(LitElement) { // Make localization attributes reactive @property() dir: string; @property() lang: string; + static scopedElements: Record + /** Emits a custom event with more convenient defaults. */ emit( name: EventTypeDoesNotRequireDetail,