Add animation registry and update alert/dialog

pull/463/head
Cory LaViska 2021-05-21 17:53:53 -04:00
rodzic 1ca890b1e9
commit a87596b3a1
10 zmienionych plików z 293 dodań i 140 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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`.

Wyświetl plik

@ -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)

Wyświetl plik

@ -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`));
}

Wyświetl plik

@ -23,10 +23,6 @@
line-height: 1.6;
color: var(--sl-color-gray-700);
margin: inherit;
&[hidden] {
display: none;
}
}
.alert__icon {

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -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 });
});
})
);

Wyświetl plik

@ -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 }
};
}