From c915b14c5875ec584768d961512dd59a511b1627 Mon Sep 17 00:00:00 2001 From: mantou132 <709922234@qq.com> Date: Sun, 24 Dec 2023 18:27:57 +0800 Subject: [PATCH] Unified animation Closed #108 --- packages/duoyun-ui/src/elements/drawer.ts | 9 +- packages/duoyun-ui/src/elements/modal.ts | 40 +++++---- packages/duoyun-ui/src/lib/animations.ts | 32 ++++++- packages/gem/src/lib/utils.ts | 104 +++++++++++++++++++++- packages/gem/src/test/utils.test.ts | 25 ++++++ 5 files changed, 178 insertions(+), 32 deletions(-) diff --git a/packages/duoyun-ui/src/elements/drawer.ts b/packages/duoyun-ui/src/elements/drawer.ts index 6be866d4..cd1a8a8f 100644 --- a/packages/duoyun-ui/src/elements/drawer.ts +++ b/packages/duoyun-ui/src/elements/drawer.ts @@ -1,6 +1,8 @@ import { adoptedStyle, customElement } from '@mantou/gem/lib/decorators'; import { createCSSSheet, css } from '@mantou/gem/lib/utils'; +import { slideInLeft, slideOutRight } from '../lib/animations'; + import { DuoyunModalElement, ModalOptions } from './modal'; const style = createCSSSheet(css` @@ -17,11 +19,6 @@ const style = createCSSSheet(css` max-height: none; border-radius: 0; } - @keyframes showDialog { - from { - transform: translate(100%, 0); - } - } `); /** @@ -33,6 +30,8 @@ export class DuoyunDrawerElement extends DuoyunModalElement { constructor(options: ModalOptions) { super(options); this.addEventListener('maskclick', () => this.close(null)); + this.openAnimation = slideInLeft; + this.closeAnimation = slideOutRight; } } diff --git a/packages/duoyun-ui/src/elements/modal.ts b/packages/duoyun-ui/src/elements/modal.ts index e736efb5..ca30e5ae 100644 --- a/packages/duoyun-ui/src/elements/modal.ts +++ b/packages/duoyun-ui/src/elements/modal.ts @@ -21,7 +21,7 @@ import { theme } from '../lib/theme'; import { locale } from '../lib/locale'; import { hotkeys } from '../lib/hotkeys'; import { setBodyInert } from '../lib/utils'; -import { commonAnimationOptions, fadeOut } from '../lib/animations'; +import { commonAnimationOptions, fadeIn, fadeOut, slideInUp } from '../lib/animations'; import './button'; import './divider'; @@ -49,22 +49,9 @@ const style = createCSSSheet(css` .mask { inset: 0; background-color: rgba(0, 0, 0, calc(${theme.maskAlpha} + 0.2)); - animation: showMask 0.15s ${theme.timingFunction} forwards; - } - @keyframes showMask { - from { - opacity: 0; - } } .dialog { outline: none; - animation: showDialog 0.15s ${theme.timingFunction} forwards; - } - @keyframes showDialog { - from { - transform: translate(0, 50%); - opacity: 0; - } } .main { box-sizing: border-box; @@ -171,7 +158,11 @@ export class DuoyunModalElement extends GemElement { @property header?: string | TemplateResult; @property body?: string | TemplateResult; + @property openAnimation: PropertyIndexedKeyframes | Keyframe[] = slideInUp; + @property closeAnimation: PropertyIndexedKeyframes | Keyframe[] = fadeOut; + @refobject maskRef: RefObject; + @refobject dialogRef: RefObject; @refobject bodyRef: RefObject; @part static dialog: string; @@ -201,7 +192,7 @@ export class DuoyunModalElement extends GemElement { }); }).finally(async () => { restoreInert(); - await modal.#closeAnimate().finished; + await modal.#closeAnimate(); modal.remove(); }); } @@ -266,15 +257,25 @@ export class DuoyunModalElement extends GemElement { ); }; - #closeAnimate = () => this.animate(fadeOut, commonAnimationOptions); + #openAnimate = () => { + this.maskRef.element?.animate(fadeIn, commonAnimationOptions); + (this.dialogRef.element || this.bodyRef.element)?.animate(this.openAnimation, commonAnimationOptions); + }; + + #closeAnimate = () => + Promise.all([ + this.maskRef.element?.animate(fadeOut, commonAnimationOptions).finished, + (this.dialogRef.element || this.bodyRef.element)?.animate(this.closeAnimation, commonAnimationOptions).finished, + ]); mounted = () => { this.effect( async () => { if (this.open) { !this.shadowRoot?.activeElement && this.focus(); + this.#openAnimate(); } else if (this.closing) { - await this.#closeAnimate().finished; + await this.#closeAnimate(); this.closing = false; this.update(); } @@ -289,22 +290,23 @@ export class DuoyunModalElement extends GemElement { if (!this.open && !this.closing) return html``; return html` -
+
${this.customize ? html` ` : html`
( type StyleProp = keyof CSSStyleDeclaration | `--${string}`; -export type StyleObject = Partial>; +export type StyleObject = Partial>; // 不支持 webkit 属性 export function styleMap(object: StyleObject) { return objectMapToString(object, ';', (key, value) => - value !== undefined ? `${camelToKebabCase(key)}:${value}` : '', + value !== undefined && value !== null ? `${camelToKebabCase(key)}:${value}` : '', ); } -export function classMap(object: Record) { +export function classMap(object: Record) { return objectMapToString(object, ' ', (key, value) => (value ? key : '')); } export const partMap = classMap; // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/exportparts -export function exportPartsMap(object: Record) { +export function exportPartsMap(object: Record) { return objectMapToString(object, ',', (key, value) => value === true || key === value ? key : value ? `${key}:${value}` : '', ); } + +declare global { + interface PropertyIndexedKeyframes extends StyleObject {} + interface Keyframe extends StyleObject {} +} + +/** + * @example + * ```css + * animation: 150ms ease 0ms showMask;` + * @keyframes showMask { + * from { + * opacity: 0; + * } + * } + * ``` + */ +// export function createCSSAnimation( +// keyframes: PropertyIndexedKeyframes | Keyframe[], +// options?: number | (KeyframeEffectOptions & { name: string }), +// ) { +// const frames = new Map(); +// if (Array.isArray(keyframes)) { +// keyframes.forEach((keyframe, index) => { +// const offset = keyframes.length === 1 ? 1 : keyframe.offset || index / (keyframes.length - 1); +// frames.set(offset, { +// ...keyframe, +// // https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Keyframe_Formats#attributes +// cssOffset: undefined, +// offset: keyframe.cssOffset, +// composite: undefined, +// animationComposition: keyframe.composite, +// easing: undefined, +// animationTimingFunction: keyframe.easing, +// } as StyleObject); +// }); +// } else { +// const offsetList: (number | null)[] = Array.isArray(keyframes.offset) +// ? keyframes.offset +// : keyframes.offset === undefined +// ? [] +// : [keyframes.offset]; +// if (offsetList.length && offsetList.at(-1) !== 1) offsetList.push(1); + +// const setStyle = (offset: number, key: string, value: string | number | null | undefined) => { +// const style = frames.get(offset) || {}; +// switch (key) { +// case 'offset': +// break; +// case 'cssOffset': +// Reflect.set(style, 'offset', value); +// break; +// case 'composite': +// Reflect.set(style, 'animationComposition', value); +// break; +// case 'easing': +// Reflect.set(style, 'animationTimingFunction', value); +// break; +// default: +// Reflect.set(style, key, value); +// } +// frames.set(offset, style); +// }; +// for (const key in keyframes) { +// const framesValue = keyframes[key]; +// !Array.isArray(framesValue) +// ? setStyle(1, key, framesValue) +// : framesValue.length === 1 +// ? setStyle(1, key, framesValue[0]) +// : framesValue.forEach((value, index) => +// setStyle(offsetList[index] ?? index / (framesValue.length - 1), key, value), +// ); +// } +// } + +// let framesStr = ''; +// frames.forEach((rules, offset) => { +// framesStr += `${(offset * 100).toFixed()}%{${styleMap(rules)}}`; +// }); + +// if (options) { +// const { +// // 只能使用 ms 数字 +// duration = 0, +// easing = '', +// delay = 0, +// iterations = 1, +// direction = '', +// fill = '', +// name = `ani-${randomStr()}`, +// } = typeof options === 'number' ? ({ duration: options } as Exclude) : options; +// return `${duration}ms ${easing} ${delay}ms ${iterations} ${direction} ${fill} ${name};@keyframes ${name}{${framesStr}}`; +// } + +// return framesStr; +// } diff --git a/packages/gem/src/test/utils.test.ts b/packages/gem/src/test/utils.test.ts index 3329656c..73068a85 100644 --- a/packages/gem/src/test/utils.test.ts +++ b/packages/gem/src/test/utils.test.ts @@ -12,6 +12,7 @@ import { classMap, exportPartsMap, absoluteLocation, + createCSSAnimation, } from '../lib/utils'; declare global { @@ -110,4 +111,28 @@ describe('utils 测试', () => { expect(exportPartsMap({ foo: 'bar', content: 'content', false: false })).to.equal(`,foo:bar,content,`); expect(exportPartsMap({ foo: 'bar', content: true })).to.equal(`,foo:bar,content,`); }); + it('createCSSAnimation', () => { + expect(createCSSAnimation([{ opacity: 0 }])).to.equal('100%{;opacity:0;}'); + expect(createCSSAnimation([{ opacity: 1 }, { opacity: 0 }])).to.equal('0%{;opacity:1;}100%{;opacity:0;}'); + expect(createCSSAnimation([{ opacity: 1 }, { opacity: 0.1, offset: 0.7 }, { opacity: 0 }])).to.equal( + '0%{;opacity:1;}70%{;opacity:0.1;}100%{;opacity:0;}', + ); + expect(createCSSAnimation({ opacity: [0] })).to.equal('100%{;opacity:0;}'); + expect(createCSSAnimation({ opacity: [1, 0] })).to.equal('0%{;opacity:1;}100%{;opacity:0;}'); + expect(createCSSAnimation({ opacity: [1, 0], offset: [0, 0.7] })).to.equal( + '0%{;opacity:1;}70%{;opacity:0;}100%{;}', + ); + expect(createCSSAnimation({ opacity: [1, 0], backgroundColor: ['red', 'yellow', 'green'] })).to.equal( + '0%{;opacity:1;background-color:red;}100%{;opacity:0;background-color:green;}50%{;background-color:yellow;}', + ); + expect( + createCSSAnimation({ + opacity: [1, 0], + backgroundColor: ['red', 'yellow', 'green'], + offset: [0, 0.7], + }), + ).to.equal( + '0%{;opacity:1;background-color:red;}70%{;opacity:0;background-color:yellow;}100%{;background-color:green;}', + ); + }); });