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;
|
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) {
|
function createDependenciesList(targetComponent, allComponents) {
|
||||||
const ul = document.createElement('ul');
|
const ul = document.createElement('ul');
|
||||||
const dependencies = [];
|
const dependencies = [];
|
||||||
|
@ -295,7 +321,7 @@
|
||||||
|
|
||||||
if (!component) {
|
if (!component) {
|
||||||
console.error('Component not found in metadata: ' + tag);
|
console.error('Component not found in metadata: ' + tag);
|
||||||
next(content);
|
return next(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
let badgeType = 'info';
|
let badgeType = 'info';
|
||||||
|
@ -332,7 +358,7 @@
|
||||||
|
|
||||||
if (!component) {
|
if (!component) {
|
||||||
console.error('Component not found in metadata: ' + tag);
|
console.error('Component not found in metadata: ' + tag);
|
||||||
next(content);
|
return next(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (component.props.length) {
|
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) {
|
if (component.dependencies.length) {
|
||||||
result += `
|
result += `
|
||||||
## Dependencies
|
## 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.
|
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._ 🐛
|
_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
|
## 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)
|
- 🚨 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 slots = tags.filter(item => item.tag === 'slot');
|
||||||
const parts = tags.filter(item => item.tag === 'part');
|
const parts = tags.filter(item => item.tag === 'part');
|
||||||
const customProperties = tags.filter(item => item.tag === 'customproperty');
|
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.since = tags.find(item => item.tag === 'since').text.trim();
|
||||||
api.status = tags.find(item => item.tag === 'status').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.slots = slots.map(tag => splitText(tag.text));
|
||||||
api.parts = parts.map(tag => splitText(tag.text));
|
api.parts = parts.map(tag => splitText(tag.text));
|
||||||
api.cssCustomProperties = customProperties.map(tag => splitText(tag.text));
|
api.cssCustomProperties = customProperties.map(tag => splitText(tag.text));
|
||||||
|
api.animations = animations.map(tag => splitText(tag.text));
|
||||||
} else {
|
} else {
|
||||||
console.error(chalk.yellow(`Missing comment block for ${component.name} - skipping metadata`));
|
console.error(chalk.yellow(`Missing comment block for ${component.name} - skipping metadata`));
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,10 +23,6 @@
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--sl-color-gray-700);
|
color: var(--sl-color-gray-700);
|
||||||
margin: inherit;
|
margin: inherit;
|
||||||
|
|
||||||
&[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert__icon {
|
.alert__icon {
|
||||||
|
|
|
@ -1,37 +1,13 @@
|
||||||
import { LitElement, html, unsafeCSS } from 'lit';
|
import { LitElement, html, unsafeCSS } from 'lit';
|
||||||
import { customElement, property, query } from 'lit/decorators';
|
import { customElement, property, query } from 'lit/decorators';
|
||||||
import { classMap } from 'lit-html/directives/class-map';
|
import { classMap } from 'lit-html/directives/class-map';
|
||||||
import { event, EventEmitter, watch } from '../../internal/decorators';
|
|
||||||
import { animateTo, stopAnimations } from '../../internal/animate';
|
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';
|
import styles from 'sass:./alert.scss';
|
||||||
|
|
||||||
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
|
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
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
|
@ -47,6 +23,9 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
|
||||||
* @part close-button - The close button.
|
* @part close-button - The close button.
|
||||||
*
|
*
|
||||||
* @customProperty --box-shadow - The alert's box shadow.
|
* @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')
|
@customElement('sl-alert')
|
||||||
export default class SlAlert extends LitElement {
|
export default class SlAlert extends LitElement {
|
||||||
|
@ -111,17 +90,10 @@ export default class SlAlert extends LitElement {
|
||||||
this.restartAutoHide();
|
this.restartAutoHide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate in
|
|
||||||
await stopAnimations(this.base);
|
await stopAnimations(this.base);
|
||||||
this.base.hidden = false;
|
this.base.hidden = false;
|
||||||
await animateTo(
|
const animation = getAnimation(this, 'alert.show');
|
||||||
this.base,
|
await animateTo(this.base, animation.keyframes, animation.options);
|
||||||
[
|
|
||||||
{ opacity: 0, transform: 'scale(0.8)' },
|
|
||||||
{ opacity: 1, transform: 'scale(1)' }
|
|
||||||
],
|
|
||||||
{ duration: 250 }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.slAfterShow.emit();
|
this.slAfterShow.emit();
|
||||||
}
|
}
|
||||||
|
@ -142,16 +114,9 @@ export default class SlAlert extends LitElement {
|
||||||
|
|
||||||
clearTimeout(this.autoHideTimeout);
|
clearTimeout(this.autoHideTimeout);
|
||||||
|
|
||||||
// Animate out
|
|
||||||
await stopAnimations(this.base);
|
await stopAnimations(this.base);
|
||||||
await animateTo(
|
const animation = getAnimation(this, 'alert.hide');
|
||||||
this.base,
|
await animateTo(this.base, animation.keyframes, animation.options);
|
||||||
[
|
|
||||||
{ opacity: 1, transform: 'scale(1)' },
|
|
||||||
{ opacity: 0, transform: 'scale(0.8)' }
|
|
||||||
],
|
|
||||||
{ duration: 250 }
|
|
||||||
);
|
|
||||||
this.base.hidden = true;
|
this.base.hidden = true;
|
||||||
|
|
||||||
this.slAfterHide.emit();
|
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 {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'sl-alert': SlAlert;
|
'sl-alert': SlAlert;
|
||||||
|
|
|
@ -20,10 +20,6 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: var(--sl-z-index-dialog);
|
z-index: var(--sl-z-index-dialog);
|
||||||
|
|
||||||
&:not(.dialog--visible) {
|
|
||||||
@include hide.hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog__panel {
|
.dialog__panel {
|
||||||
|
@ -36,9 +32,6 @@
|
||||||
background-color: var(--sl-panel-background-color);
|
background-color: var(--sl-panel-background-color);
|
||||||
border-radius: var(--sl-border-radius-medium);
|
border-radius: var(--sl-border-radius-medium);
|
||||||
box-shadow: var(--sl-shadow-x-large);
|
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 {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
@ -106,10 +99,4 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
background-color: var(--sl-overlay-background-color);
|
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 { customElement, property, query, state } from 'lit/decorators';
|
||||||
import { classMap } from 'lit-html/directives/class-map';
|
import { classMap } from 'lit-html/directives/class-map';
|
||||||
import { ifDefined } from 'lit-html/directives/if-defined';
|
import { ifDefined } from 'lit-html/directives/if-defined';
|
||||||
|
import { animateTo, stopAnimations } from '../../internal/animate';
|
||||||
import { event, EventEmitter, watch } from '../../internal/decorators';
|
import { event, EventEmitter, watch } from '../../internal/decorators';
|
||||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
||||||
import { hasSlot } from '../../internal/slot';
|
import { hasSlot } from '../../internal/slot';
|
||||||
import { isPreventScrollSupported } from '../../internal/support';
|
import { isPreventScrollSupported } from '../../internal/support';
|
||||||
import Modal from '../../internal/modal';
|
import Modal from '../../internal/modal';
|
||||||
|
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
|
||||||
import styles from 'sass:./dialog.scss';
|
import styles from 'sass:./dialog.scss';
|
||||||
|
|
||||||
const hasPreventScroll = isPreventScrollSupported();
|
const hasPreventScroll = isPreventScrollSupported();
|
||||||
|
@ -36,6 +38,11 @@ let id = 0;
|
||||||
* @customProperty --header-spacing - The amount of padding to use for the header.
|
* @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 --body-spacing - The amount of padding to use for the body.
|
||||||
* @customProperty --footer-spacing - The amount of padding to use for the footer.
|
* @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')
|
@customElement('sl-dialog')
|
||||||
export default class SlDialog extends LitElement {
|
export default class SlDialog extends LitElement {
|
||||||
|
@ -43,15 +50,14 @@ export default class SlDialog extends LitElement {
|
||||||
|
|
||||||
@query('.dialog') dialog: HTMLElement;
|
@query('.dialog') dialog: HTMLElement;
|
||||||
@query('.dialog__panel') panel: HTMLElement;
|
@query('.dialog__panel') panel: HTMLElement;
|
||||||
|
@query('.dialog__overlay') overlay: HTMLElement;
|
||||||
|
|
||||||
private componentId = `dialog-${++id}`;
|
private componentId = `dialog-${++id}`;
|
||||||
|
private hasInitialized = false;
|
||||||
private modal: Modal;
|
private modal: Modal;
|
||||||
private originalTrigger: HTMLElement | null;
|
private originalTrigger: HTMLElement | null;
|
||||||
private willShow = false;
|
|
||||||
private willHide = false;
|
|
||||||
|
|
||||||
@state() private hasFooter = 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. */
|
/** 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;
|
@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() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
unlockBodyScrolling(this);
|
unlockBodyScrolling(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows the dialog */
|
/** Shows the dialog */
|
||||||
show() {
|
async show() {
|
||||||
if (this.willShow) {
|
if (!this.hasInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,47 +134,44 @@ export default class SlDialog extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.originalTrigger = document.activeElement as HTMLElement;
|
this.originalTrigger = document.activeElement as HTMLElement;
|
||||||
this.willShow = true;
|
|
||||||
this.isVisible = true;
|
|
||||||
this.open = true;
|
this.open = true;
|
||||||
this.modal.activate();
|
this.modal.activate();
|
||||||
|
|
||||||
lockBodyScrolling(this);
|
lockBodyScrolling(this);
|
||||||
|
|
||||||
if (this.open) {
|
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||||
if (hasPreventScroll) {
|
this.dialog.hidden = false;
|
||||||
// Wait for the next frame before setting initial focus so the dialog is technically visible
|
|
||||||
requestAnimationFrame(() => {
|
// Browsers that support el.focus({ preventScroll }) can set initial focus immediately
|
||||||
const slInitialFocus = this.slInitialFocus.emit();
|
if (hasPreventScroll) {
|
||||||
if (!slInitialFocus.defaultPrevented) {
|
const slInitialFocus = this.slInitialFocus.emit();
|
||||||
this.panel.focus({ preventScroll: true });
|
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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 */
|
/** Hides the dialog */
|
||||||
hide() {
|
async hide() {
|
||||||
if (this.willHide) {
|
if (!this.hasInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,15 +181,27 @@ export default class SlDialog extends LitElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.willHide = true;
|
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.modal.deactivate();
|
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
|
// Restore focus to the original trigger
|
||||||
const trigger = this.originalTrigger;
|
const trigger = this.originalTrigger;
|
||||||
if (trigger && typeof trigger.focus === 'function') {
|
if (trigger && typeof trigger.focus === 'function') {
|
||||||
setTimeout(() => trigger.focus());
|
setTimeout(() => trigger.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.slAfterHide.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCloseClick() {
|
handleCloseClick() {
|
||||||
|
@ -207,22 +231,6 @@ export default class SlDialog extends LitElement {
|
||||||
this.hasFooter = hasSlot(this, 'footer');
|
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() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
|
@ -230,11 +238,9 @@ export default class SlDialog extends LitElement {
|
||||||
class=${classMap({
|
class=${classMap({
|
||||||
dialog: true,
|
dialog: true,
|
||||||
'dialog--open': this.open,
|
'dialog--open': this.open,
|
||||||
'dialog--visible': this.isVisible,
|
|
||||||
'dialog--has-footer': this.hasFooter
|
'dialog--has-footer': this.hasFooter
|
||||||
})}
|
})}
|
||||||
@keydown=${this.handleKeyDown}
|
@keydown=${this.handleKeyDown}
|
||||||
@transitionend=${this.handleTransitionEnd}
|
|
||||||
>
|
>
|
||||||
<div part="overlay" class="dialog__overlay" @click=${this.handleOverlayClick} tabindex="-1"></div>
|
<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 {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'sl-dialog': SlDialog;
|
'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,
|
el: HTMLElement,
|
||||||
keyframes: Keyframe[] | PropertyIndexedKeyframes,
|
keyframes: Keyframe[] | PropertyIndexedKeyframes,
|
||||||
options?: KeyframeAnimationOptions
|
options?: KeyframeAnimationOptions
|
||||||
) {
|
) {
|
||||||
return new Promise(async resolve => {
|
return new Promise(async resolve => {
|
||||||
if (options) {
|
if (options?.duration === Infinity) {
|
||||||
if (options.duration === Infinity) {
|
throw new Error('Promise-based animations must be finite.');
|
||||||
throw new Error('Promise-based animations must be finite.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefersReducedMotion()) {
|
|
||||||
options.duration = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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('cancel', resolve, { once: true });
|
||||||
animation.addEventListener('finish', 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) {
|
export function prefersReducedMotion() {
|
||||||
await Promise.all(
|
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
el.getAnimations().map(animation => {
|
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 => {
|
return new Promise(resolve => {
|
||||||
|
const handleAnimationEvent = requestAnimationFrame(resolve);
|
||||||
|
|
||||||
|
animation.addEventListener('cancel', () => handleAnimationEvent, { once: true });
|
||||||
|
animation.addEventListener('finish', () => handleAnimationEvent, { once: true });
|
||||||
animation.cancel();
|
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