diff --git a/docs/assets/plugins/metadata/metadata.js b/docs/assets/plugins/metadata/metadata.js index 17c5e213..72babe86 100644 --- a/docs/assets/plugins/metadata/metadata.js +++ b/docs/assets/plugins/metadata/metadata.js @@ -193,6 +193,32 @@ return table.outerHTML; } + function createAnimationsTable(animations) { + const table = document.createElement('table'); + table.innerHTML = ` + + + Name + Description + + + + ${animations + .map( + animation => ` + + ${escapeHtml(animation.name)} + ${escapeHtml(animation.description)} + + ` + ) + .join('')} + + `; + + return table.outerHTML; + } + function createDependenciesList(targetComponent, allComponents) { const ul = document.createElement('ul'); const dependencies = []; @@ -295,7 +321,7 @@ if (!component) { console.error('Component not found in metadata: ' + tag); - next(content); + return next(content); } let badgeType = 'info'; @@ -332,7 +358,7 @@ if (!component) { console.error('Component not found in metadata: ' + tag); - next(content); + return next(content); } if (component.props.length) { @@ -377,6 +403,15 @@ `; } + if (component.animations.length) { + result += ` + ## Animations + ${createAnimationsTable(component.animations)} + + Learn how to [customize animations](/getting-started/customizing#animations). + `; + } + if (component.dependencies.length) { result += ` ## Dependencies diff --git a/docs/getting-started/customizing.md b/docs/getting-started/customizing.md index 22b8934b..3fa0f744 100644 --- a/docs/getting-started/customizing.md +++ b/docs/getting-started/customizing.md @@ -106,3 +106,51 @@ Alternatively, you can set them inline directly on the element. ``` Not all components expose CSS custom properties. For those that do, they can be found in the component's API documentation. + +## Animations + +Some components use animation, such as when a dialog is shown or hidden. Animations are performed using the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) rather than CSS. However, you can still customize them through Shoelace's animation registry. If a component has customizable animations, they'll be listed in the "Animation" section of its documentation. + +To customize a default animation, use the `setDefaultAnimation()` method. The function accepts an animation name (found in the component's docs) and an object with `keyframes` and `options`. + +This example will make all dialogs use a custom show animation. + +```js +import { setDefaultAnimation } from '/dist/utilities/animation-registry.js'; + +// Change the default animation for all dialogs +setDefaultAnimation('dialog.show', { + keyframes: [ + { transform: 'rotate(-10deg) scale(0.5)', opacity: '0' }, + { transform: 'rotate(0deg) scale(1)', opacity: '1' } + ], + options: { + duration: 500 + } +}); +``` + +If you only want to target a single component, use the `setAnimation()` method instead. This function accepts an element, an animation name, and an object comprised of animation `keyframes` and `options`. + +In this example, only the target dialog will use a custom show animation. + +```js +import { setAnimation } from '/dist/utilities/animation-registry.js'; + +// Change the animation for a single dialog +const dialog = document.querySelector('#my-dialog'); + +setAnimation(dialog, 'dialog.show', { + keyframes: [ + { transform: 'rotate(-10deg) scale(0.5)', opacity: '0' }, + { transform: 'rotate(0deg) scale(1)', opacity: '1' } + ], + options: { + duration: 500 + } +}); +``` + +To learn more about creating Web Animations, refer to the documentation for [`Element.animate()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/animate). + +?> Animations respect the users `prefers-reduced-motion` setting. When this setting is enabled, animations will not be played. To disable animations for all users, set `options.duration` to `0`. diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 9dbf7de5..3b37070f 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -6,6 +6,19 @@ Components with the Experimental badge _During the beta period, these restrictions may be relaxed in the event of a mission-critical bug._ 🐛 +## Next + +This release changes how components animate. In previous versions, CSS transitions were used for most show/hide animations. Transitions are problematic due to the way `transitionend` works. This event fires once _per transition_, and it's impossible to know which transition to look for when users can customize any possible CSS property. Because of this, components previously required the `opacity` property to transition. If a user were to prevent `opacity` from transitioning, the `sl-after-show|hide` events would never emit. + +CSS animations, on the other hand, have a more reliable `animationend` event. Alas, we can't use them because `@keyframes` don't cascade and can't be injected into a shadow DOM via CSS. + +The most elegant solution I found was to use the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API), which offers more control over animations at the expense of customizations being done in JavaScript. Fortunately, through the [Animation Registry](/getting-started/customizing#animations), you can customize animations globally and/or per component with a minimal amount of code. + + +- Added the Animation Registry +- Updated animations for `sl-alert`, `sl-dialog`, ... to use the Animation Registry instead of CSS transitions +- Improved a11y by respecting `prefers-reduced-motion` for all show/hide animations + ## 2.0.0-beta.40 - 🚨 BREAKING: renamed `sl-responsive-embed` to `sl-responsive-media` and added support for images and videos [#436](https://github.com/shoelace-style/shoelace/issues/436) diff --git a/scripts/make-metadata.cjs b/scripts/make-metadata.cjs index eba4d21b..8e654c0f 100644 --- a/scripts/make-metadata.cjs +++ b/scripts/make-metadata.cjs @@ -99,6 +99,7 @@ components.map(async component => { const slots = tags.filter(item => item.tag === 'slot'); const parts = tags.filter(item => item.tag === 'part'); const customProperties = tags.filter(item => item.tag === 'customproperty'); + const animations = tags.filter(item => item.tag === 'animation'); api.since = tags.find(item => item.tag === 'since').text.trim(); api.status = tags.find(item => item.tag === 'status').text.trim(); @@ -106,6 +107,7 @@ components.map(async component => { api.slots = slots.map(tag => splitText(tag.text)); api.parts = parts.map(tag => splitText(tag.text)); api.cssCustomProperties = customProperties.map(tag => splitText(tag.text)); + api.animations = animations.map(tag => splitText(tag.text)); } else { console.error(chalk.yellow(`Missing comment block for ${component.name} - skipping metadata`)); } diff --git a/src/components/alert/alert.scss b/src/components/alert/alert.scss index 8e33dd26..6743b080 100644 --- a/src/components/alert/alert.scss +++ b/src/components/alert/alert.scss @@ -23,10 +23,6 @@ line-height: 1.6; color: var(--sl-color-gray-700); margin: inherit; - - &[hidden] { - display: none; - } } .alert__icon { diff --git a/src/components/alert/alert.ts b/src/components/alert/alert.ts index 67fb1b2d..0a1affb3 100644 --- a/src/components/alert/alert.ts +++ b/src/components/alert/alert.ts @@ -1,37 +1,13 @@ import { LitElement, html, unsafeCSS } from 'lit'; import { customElement, property, query } from 'lit/decorators'; import { classMap } from 'lit-html/directives/class-map'; -import { event, EventEmitter, watch } from '../../internal/decorators'; import { animateTo, stopAnimations } from '../../internal/animate'; +import { event, EventEmitter, watch } from '../../internal/decorators'; +import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; import styles from 'sass:./alert.scss'; const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' }); -// -// TODO - At the component level, expose `animationSettings` which will work like this: -// -// alert.animationSettings = { -// show: { -// keyframes: [], -// options: {} -// }, -// hide: { -// keyframes: [], -// options: {} -// } -// }; -// -// TODO - To allow users to change the default value for all alerts, export a `setAnimationDefaults()` function. When no -// animationSettings are provided, we'll use the defaults. -// -// TODO - In the changelog, describe why these changes are being made: -// -// - CSS transitions are more easily customizable, but not reliable due to reflow hacks and now knowing which -// transition to wait for via transitionend. -// - Web Animations API is more reliable at the expense of being harder to customize. However, providing the -// setAnimationDefaults() function gives you complete control over individual component animations with one call. -// - /** * @since 2.0 * @status stable @@ -47,6 +23,9 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl * @part close-button - The close button. * * @customProperty --box-shadow - The alert's box shadow. + * + * @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 LitElement { @@ -111,17 +90,10 @@ export default class SlAlert extends LitElement { this.restartAutoHide(); } - // Animate in await stopAnimations(this.base); this.base.hidden = false; - await animateTo( - this.base, - [ - { opacity: 0, transform: 'scale(0.8)' }, - { opacity: 1, transform: 'scale(1)' } - ], - { duration: 250 } - ); + const animation = getAnimation(this, 'alert.show'); + await animateTo(this.base, animation.keyframes, animation.options); this.slAfterShow.emit(); } @@ -142,16 +114,9 @@ export default class SlAlert extends LitElement { clearTimeout(this.autoHideTimeout); - // Animate out await stopAnimations(this.base); - await animateTo( - this.base, - [ - { opacity: 1, transform: 'scale(1)' }, - { opacity: 0, transform: 'scale(0.8)' } - ], - { duration: 250 } - ); + const animation = getAnimation(this, 'alert.hide'); + await animateTo(this.base, animation.keyframes, animation.options); this.base.hidden = true; this.slAfterHide.emit(); @@ -262,6 +227,22 @@ export default class SlAlert extends LitElement { } } +setDefaultAnimation('alert.show', { + keyframes: [ + { opacity: 0, transform: 'scale(0.8)' }, + { opacity: 1, transform: 'scale(1)' } + ], + options: { duration: 150 } +}); + +setDefaultAnimation('alert.hide', { + keyframes: [ + { opacity: 1, transform: 'scale(1)' }, + { opacity: 0, transform: 'scale(0.8)' } + ], + options: { duration: 150 } +}); + declare global { interface HTMLElementTagNameMap { 'sl-alert': SlAlert; diff --git a/src/components/dialog/dialog.scss b/src/components/dialog/dialog.scss index a0ca0ffb..5a8b9f41 100644 --- a/src/components/dialog/dialog.scss +++ b/src/components/dialog/dialog.scss @@ -20,10 +20,6 @@ bottom: 0; left: 0; z-index: var(--sl-z-index-dialog); - - &:not(.dialog--visible) { - @include hide.hidden; - } } .dialog__panel { @@ -36,9 +32,6 @@ background-color: var(--sl-panel-background-color); border-radius: var(--sl-border-radius-medium); box-shadow: var(--sl-shadow-x-large); - opacity: 0; - transform: scale(0.8); - transition: var(--sl-transition-medium) opacity, var(--sl-transition-medium) transform; &:focus { outline: none; @@ -106,10 +99,4 @@ bottom: 0; left: 0; background-color: var(--sl-overlay-background-color); - opacity: 0; - transition: var(--sl-transition-medium) opacity; -} - -.dialog--open .dialog__overlay { - opacity: 1; } diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index ccfe0834..65ef5063 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -2,11 +2,13 @@ import { LitElement, html, unsafeCSS } from 'lit'; import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit-html/directives/class-map'; import { ifDefined } from 'lit-html/directives/if-defined'; +import { animateTo, stopAnimations } from '../../internal/animate'; import { event, EventEmitter, watch } from '../../internal/decorators'; import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll'; import { hasSlot } from '../../internal/slot'; import { isPreventScrollSupported } from '../../internal/support'; import Modal from '../../internal/modal'; +import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry'; import styles from 'sass:./dialog.scss'; const hasPreventScroll = isPreventScrollSupported(); @@ -36,6 +38,11 @@ let id = 0; * @customProperty --header-spacing - The amount of padding to use for the header. * @customProperty --body-spacing - The amount of padding to use for the body. * @customProperty --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.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 LitElement { @@ -43,15 +50,14 @@ export default class SlDialog extends LitElement { @query('.dialog') dialog: HTMLElement; @query('.dialog__panel') panel: HTMLElement; + @query('.dialog__overlay') overlay: HTMLElement; private componentId = `dialog-${++id}`; + private hasInitialized = false; private modal: Modal; private originalTrigger: HTMLElement | null; - private willShow = false; - private willHide = false; @state() private hasFooter = false; - @state() private isVisible = false; /** Indicates whether or not the dialog is open. You can use this in lieu of the show/hide methods. */ @property({ type: Boolean, reflect: true }) open = false; @@ -101,14 +107,23 @@ export default class SlDialog extends LitElement { } } + async firstUpdated() { + // Set initial visibility + this.dialog.hidden = !this.open; + + // Set the initialized flag after the first update is complete + await this.updateComplete; + this.hasInitialized = true; + } + disconnectedCallback() { super.disconnectedCallback(); unlockBodyScrolling(this); } /** Shows the dialog */ - show() { - if (this.willShow) { + async show() { + if (!this.hasInitialized) { return; } @@ -119,47 +134,44 @@ export default class SlDialog extends LitElement { } this.originalTrigger = document.activeElement as HTMLElement; - this.willShow = true; - this.isVisible = true; this.open = true; this.modal.activate(); lockBodyScrolling(this); - if (this.open) { - if (hasPreventScroll) { - // Wait for the next frame before setting initial focus so the dialog is technically visible - requestAnimationFrame(() => { - const slInitialFocus = this.slInitialFocus.emit(); - if (!slInitialFocus.defaultPrevented) { - this.panel.focus({ preventScroll: true }); - } - }); - } else { - // Once Safari supports { preventScroll: true } we can remove this nasty little hack, but until then we need to - // wait for the transition to complete before setting focus, otherwise the panel may render in a buggy way - // that's out of view initially. - // - // Fiddle: https://jsfiddle.net/g6buoafq/1/ - // Safari: https://bugs.webkit.org/show_bug.cgi?id=178583 - // - this.dialog.addEventListener( - 'transitionend', - () => { - const slInitialFocus = this.slInitialFocus.emit(); - if (!slInitialFocus.defaultPrevented) { - this.panel.focus(); - } - }, - { once: true } - ); + await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]); + this.dialog.hidden = false; + + // Browsers that support el.focus({ preventScroll }) can set initial focus immediately + if (hasPreventScroll) { + const slInitialFocus = this.slInitialFocus.emit(); + if (!slInitialFocus.defaultPrevented) { + this.panel.focus({ preventScroll: true }); } } + + const panelAnimation = getAnimation(this, 'dialog.show'); + const overlayAnimation = getAnimation(this, 'dialog.overlay.show'); + await Promise.all([ + animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options), + animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options) + ]); + + // Browsers that don't support el.focus({ preventScroll }) have to wait for the animation to finish before initial + // focus to prevent scrolling issues. See: https://caniuse.com/mdn-api_htmlelement_focus_preventscroll_option + if (!hasPreventScroll) { + const slInitialFocus = this.slInitialFocus.emit(); + if (!slInitialFocus.defaultPrevented) { + this.panel.focus({ preventScroll: true }); + } + } + + this.slAfterShow.emit(); } /** Hides the dialog */ - hide() { - if (this.willHide) { + async hide() { + if (!this.hasInitialized) { return; } @@ -169,15 +181,27 @@ export default class SlDialog extends LitElement { return; } - this.willHide = true; this.open = false; this.modal.deactivate(); + await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]); + const panelAnimation = getAnimation(this, 'dialog.hide'); + const overlayAnimation = getAnimation(this, 'dialog.overlay.hide'); + await Promise.all([ + animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options), + animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options) + ]); + this.dialog.hidden = true; + + unlockBodyScrolling(this); + // Restore focus to the original trigger const trigger = this.originalTrigger; if (trigger && typeof trigger.focus === 'function') { setTimeout(() => trigger.focus()); } + + this.slAfterHide.emit(); } handleCloseClick() { @@ -207,22 +231,6 @@ export default class SlDialog extends LitElement { this.hasFooter = hasSlot(this, 'footer'); } - handleTransitionEnd(event: TransitionEvent) { - const target = event.target as HTMLElement; - - // Ensure we only emit one event when the target element is no longer visible - if (event.propertyName === 'opacity' && target.classList.contains('dialog__panel')) { - this.isVisible = this.open; - this.willShow = false; - this.willHide = false; - this.open ? this.slAfterShow.emit() : this.slAfterHide.emit(); - - if (!this.open) { - unlockBodyScrolling(this); - } - } - } - render() { return html`
@@ -278,6 +284,32 @@ export default class SlDialog extends LitElement { } } +setDefaultAnimation('dialog.show', { + keyframes: [ + { opacity: 0, transform: 'scale(0.8)' }, + { opacity: 1, transform: 'scale(1)' } + ], + options: { duration: 150 } +}); + +setDefaultAnimation('dialog.hide', { + keyframes: [ + { opacity: 1, transform: 'scale(1)' }, + { opacity: 0, transform: 'scale(0.8)' } + ], + options: { duration: 150 } +}); + +setDefaultAnimation('dialog.overlay.show', { + keyframes: [{ opacity: 0 }, { opacity: 1 }], + options: { duration: 150 } +}); + +setDefaultAnimation('dialog.overlay.hide', { + keyframes: [{ opacity: 1 }, { opacity: 0 }], + options: { duration: 150 } +}); + declare global { interface HTMLElementTagNameMap { 'sl-dialog': SlDialog; diff --git a/src/internal/animate.ts b/src/internal/animate.ts index aaea034f..f3a6cd63 100644 --- a/src/internal/animate.ts +++ b/src/internal/animate.ts @@ -1,43 +1,47 @@ -export function prefersReducedMotion() { - const query = window.matchMedia('(prefers-reduced-motion: reduce)'); - return query?.matches; -} - // -// Performs a finite, keyframe-based animation. Returns a promise that resolves when the animation finishes or cancels. +// Animates an element using keyframes. Returns a promise that resolves after the animation completes or gets canceled. // -export async function animateTo( +export function animateTo( el: HTMLElement, keyframes: Keyframe[] | PropertyIndexedKeyframes, options?: KeyframeAnimationOptions ) { return new Promise(async resolve => { - if (options) { - if (options.duration === Infinity) { - throw new Error('Promise-based animations must be finite.'); - } - - if (prefersReducedMotion()) { - options.duration = 0; - } + if (options?.duration === Infinity) { + throw new Error('Promise-based animations must be finite.'); } - const animation = el.animate(keyframes, options); + const animation = el.animate(keyframes, { + fill: 'both', + ...options, + duration: prefersReducedMotion() ? 0 : options!.duration + }); + animation.addEventListener('cancel', resolve, { once: true }); animation.addEventListener('finish', resolve, { once: true }); }); } // -// Stops all active animations on the target element. Returns a promise that resolves when all animations are canceled. +// Tells if the user has enabled the "reduced motion" setting in their browser or OS. // -export async function stopAnimations(el: HTMLElement) { - await Promise.all( - el.getAnimations().map(animation => { +export function prefersReducedMotion() { + const query = window.matchMedia('(prefers-reduced-motion: reduce)'); + return query?.matches; +} + +// +// Stops all active animations on the target element. Returns a promise that resolves after all animations are canceled. +// +export function stopAnimations(el: HTMLElement) { + return Promise.all( + el.getAnimations().map((animation: any) => { return new Promise(resolve => { + const handleAnimationEvent = requestAnimationFrame(resolve); + + animation.addEventListener('cancel', () => handleAnimationEvent, { once: true }); + animation.addEventListener('finish', () => handleAnimationEvent, { once: true }); animation.cancel(); - animation.addEventListener('cancel', resolve, { once: true }); - animation.addEventListener('finish', resolve, { once: true }); }); }) ); diff --git a/src/utilities/animation-registry.ts b/src/utilities/animation-registry.ts new file mode 100644 index 00000000..a297ac4f --- /dev/null +++ b/src/utilities/animation-registry.ts @@ -0,0 +1,55 @@ +interface ElementAnimation { + keyframes: Keyframe[] | PropertyIndexedKeyframes; + options?: KeyframeAnimationOptions; +} + +interface ElementAnimationMap { + [animationName: string]: ElementAnimation; +} + +const defaultAnimationRegistry = new Map(); +const customAnimationRegistry = new WeakMap(); + +// +// Sets a default animation. Components should use the `name.animation` for primary animations and `name.part.animation` +// for secondary animations, e.g. `alert.show` and `dialog.overlay.show`. +// +export function setDefaultAnimation(animationName: string, animation: ElementAnimation) { + defaultAnimationRegistry.set(animationName, animation); +} + +// +// Sets a custom animation for the specified element. +// +export function setAnimation(el: Element, animationName: string, animation: ElementAnimation) { + customAnimationRegistry.set( + el, + Object.assign({}, customAnimationRegistry.get(el), { + [animationName]: animation + }) + ); +} + +// +// Gets an element's custom animation. Falls back to the default animation if no custom one is found. +// +export function getAnimation(el: Element, animationName: string) { + const customAnimation = customAnimationRegistry.get(el); + + // Check for a custom animation + if (customAnimation && customAnimation[animationName]) { + return customAnimation[animationName]; + } + + // Check for a default animation + const defaultAnimation = defaultAnimationRegistry.get(animationName); + if (defaultAnimation) { + return defaultAnimation; + } + + // Fall back to an empty animation + return { + keyframes: [], + options: { duration: 0 } + }; +}