From 7b2bf96c5d8bdb152b325b68b239de46415833f0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 7 Sep 2023 15:16:36 +0900 Subject: [PATCH] feat: replace headlessui Transition --- packages/app/src/components/transition.tsx | 242 +++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 packages/app/src/components/transition.tsx diff --git a/packages/app/src/components/transition.tsx b/packages/app/src/components/transition.tsx new file mode 100644 index 00000000..89a52415 --- /dev/null +++ b/packages/app/src/components/transition.tsx @@ -0,0 +1,242 @@ +// the use case of @headlessui/react is limited to simple usage of Transition +// so implement minimal version on own own + +import { typedBoolean } from "@hiogawa/utils"; +import React from "react"; + +// TODO: rename "enter" => "entering"? +type TransitionState = "enter" | "leaving" | "left"; + +interface TransitionClassProps { + className?: string; + enterFrom?: string; + enterTo?: string; + // TODO + // enter?: string; + // entered?: string; + // leave?: string; + leaveFrom?: string; + leaveTo?: string; +} + +interface TransitionEventProps { + // TODO + // beforeEnter?: () => void; + // afterEnter?: () => void; + // beforeLeave?: () => void; + // afterLeave?: () => void; +} + +export function Transition2( + props: React.PropsWithChildren< + { show?: boolean } & TransitionClassProps & TransitionEventProps + > +) { + const [state, setState] = React.useState("left"); + + React.useEffect(() => { + if (props.show && state !== "enter") { + setState("enter"); + } + if (!props.show && state === "enter") { + setState("leaving"); + } + }, [props.show, state]); + + return ( + <> + {state !== "left" && ( + + )} + + ); +} + +function TransitionInner( + props: React.PropsWithChildren< + { + state: TransitionState; + setState: (v: TransitionState) => void; + } & TransitionClassProps & + TransitionEventProps + > +) { + const [manager] = React.useState( + () => + new TransitionManager({ + classes: { + className: splitClass(props.className ?? ""), + enterFrom: splitClass(props.enterFrom ?? ""), + enterTo: splitClass(props.enterTo ?? ""), + leaveFrom: splitClass(props.leaveFrom ?? ""), + leaveTo: splitClass(props.leaveTo ?? ""), + }, + onChange(state) { + props.setState(state); + }, + }) + ); + + React.useSyncExternalStore( + React.useCallback((onStorechange) => manager.subscribe(onStorechange), []), + () => manager.state + ); + + // attach to document + React.useEffect(() => { + manager.startEnter(); + }, []); + + // leave + React.useEffect(() => { + if (props.state === "leaving") { + manager.startLeave(); + } + }, [props.state]); + + return ( +
{ + el ? manager.onCreate(el) : manager.onDestroy(); + }, [])} + > + {props.children} +
+ ); +} + +class TransitionManager { + private listeners = new Set<() => void>(); + private disposables = new Set<() => void>(); + state: TransitionState = "left"; + el?: HTMLElement; + + constructor( + private options: { + classes: { + className: string[]; + enterFrom: string[]; + enterTo: string[]; + leaveFrom: string[]; + leaveTo: string[]; + }; + onChange: (state: TransitionState) => void; + } + ) {} + + onCreate(el: HTMLElement) { + this.el = el; + const classes = this.options.classes; + + // enterFrom + el.classList.remove(...Object.values(classes).flat()); + el.classList.add(...classes.className, ...classes.enterFrom); + } + + onDestroy() { + this.dispose(); + this.el = undefined; + } + + startEnter() { + if (!this.el) return; + this.dispose(); + const el = this.el; + const classes = this.options.classes; + + // enterFrom + el.classList.remove(...Object.values(classes).flat()); + el.classList.add(...classes.className, ...classes.enterFrom); + forceStyle(el); + + // enterFrom => enterTo + el.classList.remove(...classes.enterFrom); + el.classList.add(...classes.enterTo); + + // notify after transition + // this.disposables.add(onTransitionEnd(el, () => {})); + } + + startLeave() { + if (!this.el) return; + this.dispose(); + const el = this.el; + const classes = this.options.classes; + + // leaveFrom + el.classList.remove(...Object.values(classes).flat()); + el.classList.add(...classes.className, ...classes.leaveFrom); + forceStyle(el); + + // leaveFrom => leaveTo + el.classList.remove(...classes.leaveFrom); + el.classList.add(...classes.leaveTo); + + // notify after transition + this.disposables.add(onTransitionEnd(el, () => this.notify("left"))); + } + + private dispose() { + this.disposables.forEach((f) => f()); + this.disposables.clear(); + } + + // api for React.useSyncExternalStore + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(state: TransitionState) { + if (this.listeners.size === 0) return; + this.options.onChange(state); + this.listeners.forEach((f) => f()); + } +} + +// +// utils +// + +function onTransitionEnd(el: HTMLElement, callback: () => void) { + // watch `transitionend` + const handler = (e: HTMLElementEventMap["transitionend"]) => { + if (e.target === e.currentTarget) { + dispose(); + callback(); + } + }; + el.addEventListener("transitionend", handler); + + // also setup `transitionDuration` timeout as a fallback + const duration = getComputedStyle(el).transitionDuration; + const subscription = window.setTimeout(() => { + dispose(); + callback(); + }, parseDuration(duration)); + + function dispose() { + el.removeEventListener("transitionend", handler); + window.clearTimeout(subscription); + } + + return dispose; +} + +function parseDuration(s: string): number { + if (s.endsWith("ms")) { + return Number(s.slice(0, -2)); + } + if (s.endsWith("s")) { + return Number(s.slice(0, -1)) * 1000; + } + return 0; +} + +function splitClass(c: string): string[] { + return c.split(" ").filter(typedBoolean); +} + +function forceStyle(el: Element) { + window.getComputedStyle(el).transition ?? console.log("unreachable"); +}