kopia lustrzana https://github.com/shoelace-style/shoelace
Add animation registry and update alert/dialog
rodzic
1ca890b1e9
commit
a87596b3a1
|
@ -193,6 +193,32 @@
|
|||
return table.outerHTML;
|
||||
}
|
||||
|
||||
function createAnimationsTable(animations) {
|
||||
const table = document.createElement('table');
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${animations
|
||||
.map(
|
||||
animation => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(animation.name)}</code></td>
|
||||
<td>${escapeHtml(animation.description)}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
`;
|
||||
|
||||
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
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -6,6 +6,19 @@ Components with the <sl-badge type="warning" pill>Experimental</sl-badge> 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)
|
||||
|
|
|
@ -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`));
|
||||
}
|
||||
|
|
|
@ -23,10 +23,6 @@
|
|||
line-height: 1.6;
|
||||
color: var(--sl-color-gray-700);
|
||||
margin: inherit;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.alert__icon {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
<div
|
||||
|
@ -230,11 +238,9 @@ export default class SlDialog extends LitElement {
|
|||
class=${classMap({
|
||||
dialog: true,
|
||||
'dialog--open': this.open,
|
||||
'dialog--visible': this.isVisible,
|
||||
'dialog--has-footer': this.hasFooter
|
||||
})}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@transitionend=${this.handleTransitionEnd}
|
||||
>
|
||||
<div part="overlay" class="dialog__overlay" @click=${this.handleOverlayClick} tabindex="-1"></div>
|
||||
|
||||
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
interface ElementAnimation {
|
||||
keyframes: Keyframe[] | PropertyIndexedKeyframes;
|
||||
options?: KeyframeAnimationOptions;
|
||||
}
|
||||
|
||||
interface ElementAnimationMap {
|
||||
[animationName: string]: ElementAnimation;
|
||||
}
|
||||
|
||||
const defaultAnimationRegistry = new Map<String, ElementAnimation>();
|
||||
const customAnimationRegistry = new WeakMap<Element, ElementAnimationMap>();
|
||||
|
||||
//
|
||||
// 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 }
|
||||
};
|
||||
}
|
Ładowanie…
Reference in New Issue