diff --git a/packages/app/src/components/stories.tsx b/packages/app/src/components/stories.tsx index 98afaaf8..4de99026 100644 --- a/packages/app/src/components/stories.tsx +++ b/packages/app/src/components/stories.tsx @@ -3,7 +3,12 @@ import { useTinyForm } from "@hiogawa/tiny-form/dist/react"; import { useTinyProgress } from "@hiogawa/tiny-progress/dist/react"; import { useTinyStoreStorage } from "@hiogawa/tiny-store/dist/react"; import { TOAST_POSITIONS, type ToastPosition } from "@hiogawa/tiny-toast"; -import { Transition } from "@hiogawa/tiny-transition/dist/react"; +import { + Transition, + TransitionV2, + useLaggedBoolean, + useTransitionManagerV2, +} from "@hiogawa/tiny-transition/dist/react"; import { ANTD_VARS } from "@hiogawa/unocss-preset-antd"; import { none, objectKeys, objectPickBy, range } from "@hiogawa/utils"; import { Debug, toSetSetState, useDelay } from "@hiogawa/utils-react"; @@ -484,6 +489,7 @@ export function StoryModal() { export function StorySlide() { const [show, setShow] = React.useState(true); + const manager = useTransitionManagerV2(show, { appear: true }); return (
@@ -507,7 +513,7 @@ export function StorySlide() { > hello from top/right - hello from bottom/left - + + {manager.state && ( +
+ hello from top/left +
+ )}
@@ -527,27 +547,76 @@ export function StorySlide() { export function StoryCollapse() { const [show, setShow] = React.useState(true); + // experiment with "lagged boolean" approach. + // limitations are + // - same `duration` has to be manually set in two places + // - though this is a speciafic difficultly of collpase. + // height animation requires accessing dom height directly. + const [show2, setShow2] = React.useState(true); + const lagged2 = useLaggedBoolean(show2, { duration: 500, appear: true }); + return (

Collapse

- +
+ + +
+
+          debug: {JSON.stringify({ show, show2, lagged2 })}
+        
-
Fixed Div
+
Fixed Div (1)
-
Collapsable Div
+
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
+
+
Fixed Div (2)
+ {lagged2 && ( +
+
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
+
+ )} +
); diff --git a/packages/tiny-toast/package.json b/packages/tiny-toast/package.json index 8a3465d7..e6de4871 100644 --- a/packages/tiny-toast/package.json +++ b/packages/tiny-toast/package.json @@ -41,8 +41,6 @@ "@hiogawa/tiny-react": "0.0.1-pre.10", "@hiogawa/tiny-transition": "workspace:*", "@hiogawa/unocss-preset-antd": "workspace:*", - "@hiogawa/utils": "1.6.1-pre.7", - "@hiogawa/utils-react": "^1.3.1-pre.0", "@types/react": "^18.2.14", "preact": "^10.15.1", "react": "^18.2.0" diff --git a/packages/tiny-transition/package.json b/packages/tiny-transition/package.json index f225cc58..3e9cf9c3 100644 --- a/packages/tiny-transition/package.json +++ b/packages/tiny-transition/package.json @@ -33,8 +33,6 @@ "release": "pnpm publish --no-git-checks --access public" }, "devDependencies": { - "@hiogawa/utils": "1.6.1-pre.7", - "@hiogawa/utils-react": "^1.3.1-pre.0", "@types/react": "^18.2.14", "react": "^18.2.0" }, diff --git a/packages/tiny-transition/src/core.ts b/packages/tiny-transition/src/core.ts index 806d0a77..b25bf273 100644 --- a/packages/tiny-transition/src/core.ts +++ b/packages/tiny-transition/src/core.ts @@ -157,7 +157,8 @@ function onTransitionEnd(el: HTMLElement, callback: () => void) { }; } -function computeTransitionTimeout(el: HTMLElement): number { +export function computeTransitionTimeout(el: HTMLElement): number { + // this probably also flush style/paint because of `getComputedStyle` const style = getComputedStyle(el); const [duration, delay] = [ style.transitionDuration, diff --git a/packages/tiny-transition/src/index.ts b/packages/tiny-transition/src/index.ts index e17292e1..f03aaf8e 100644 --- a/packages/tiny-transition/src/index.ts +++ b/packages/tiny-transition/src/index.ts @@ -1,2 +1,3 @@ export * from "./core"; export * from "./class"; +export * from "./lagged/core"; diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts new file mode 100644 index 00000000..985cccfe --- /dev/null +++ b/packages/tiny-transition/src/lagged/core.ts @@ -0,0 +1,98 @@ +// inspired by discussions in solid `createPresense` +// https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 +// https://github.com/solidjs-community/solid-primitives/pull/437 + +// animation in each direction requires two intemediate steps +// false --(true)----> enterFrom --(mount + next frame)--> enterTo ---(timeout)-> true +// <-(timeout)-- leaveTo <-(next frame)----------- leaveFrom <--(false)---- +export type LaggedBooleanState = + | boolean + | "enterFrom" + | "enterTo" + | "leaveFrom" + | "leaveTo"; + +export interface LaggedBooleanOptions { + duration: number; + appear?: boolean; +} + +export class LaggedBoolean { + private state: LaggedBooleanState; + private listeners = new Set<() => void>(); + private asyncOp = new AsyncOperation(); + + constructor(value: boolean, private options: LaggedBooleanOptions) { + this.state = options?.appear ? !value : value; + } + + get = () => this.state; + + set(value: boolean) { + if ( + value + ? this.state === false || + this.state === "leaveTo" || + this.state === "leaveFrom" + : this.state === true || + this.state === "enterFrom" || + this.state === "enterTo" + ) { + this.setLagged(value); + } + } + + private setLagged(value: boolean) { + this.asyncOp.dispose(); + + this.state = value ? "enterFrom" : "leaveFrom"; + this.notify(); + + // does react scheduling guarantee re-rendering between two `notify` separated by `requestAnimationFrame`? + // if that's not the case, `useLaggedBoolean` might directly see "enterTo" without passing through "enterFrom". + this.asyncOp.requestAnimationFrame(() => { + forceStyle(); // `appear` doesn't work reliably without this? + this.state = value ? "enterTo" : "leaveTo"; + this.notify(); + + this.asyncOp.setTimeout(() => { + this.state = value; + this.notify(); + }, this.options.duration); + }); + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + private notify() { + for (const listener of this.listeners) { + listener(); + } + } +} + +export class AsyncOperation { + private disposables = new Set<() => void>(); + + setTimeout(callback: () => void, ms: number) { + const id = setTimeout(callback, ms); + this.disposables.add(() => clearTimeout(id)); + } + + requestAnimationFrame(callback: () => void) { + const id = requestAnimationFrame(callback); + this.disposables.add(() => cancelAnimationFrame(id)); + } + + dispose() { + this.disposables.forEach((f) => f()); + this.disposables.clear(); + } +} + +export function forceStyle() { + typeof document.body.offsetHeight || console.log("unreachable"); +} diff --git a/packages/tiny-transition/src/lagged/react.tsx b/packages/tiny-transition/src/lagged/react.tsx new file mode 100644 index 00000000..45717737 --- /dev/null +++ b/packages/tiny-transition/src/lagged/react.tsx @@ -0,0 +1,151 @@ +import { objectMapValues, objectOmit } from "@hiogawa/utils"; +import { useMergeRefs, useStableCallback } from "@hiogawa/utils-react"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + TRANSITION_CLASS_TYPES, + type TransitionClassProps, + convertClassPropsToCallbackProps, +} from "../class"; +import { + TRANSITION_CALLBACK_TYPES, + type TransitionCallbackProps, + type TransitionCallbackType, + computeTransitionTimeout, +} from "../core"; +import { simpleForawrdRef } from "../react"; +import { + LaggedBoolean, + type LaggedBooleanOptions, + type LaggedBooleanState, +} from "./core"; +import { TransitionManagerV2 } from "./v2"; + +export function useTransitionManagerV2( + value: boolean, + options?: { appear?: boolean } +) { + const [manager] = useState(() => new TransitionManagerV2(value, options)); + + useEffect(() => { + manager.set(value); + }, [value]); + + useSyncExternalStore( + manager.subscribe, + () => manager.state, + () => manager.state + ); + + return manager; +} + +export function useLaggedBoolean( + value: boolean, + options: LaggedBooleanOptions & + Partial void>> +): LaggedBooleanState { + const [manager] = useState(() => new LaggedBoolean(value, options)); + + useEffect(() => { + manager.set(value); + }, [value]); + + return useSyncExternalStore( + useCallback((listener) => { + // implement state callback outside of core. + // we don't use `useEffect` deps since it would be clumsy to deal with StrictMode double effect. + return manager.subscribe(() => { + const state = manager.get(); + if (state === false) options.onLeft?.(); + if (state === "enterFrom") options.onEnterFrom?.(); + if (state === "enterTo") options.onEnterTo?.(); + if (state === true) options.onEntered?.(); + if (state === "leaveFrom") options.onLeaveFrom?.(); + if (state === "leaveTo") options.onLeaveTo?.(); + listener(); + }); + }, []), + manager.get, + manager.get + ); +} + +export const TransitionV2 = simpleForawrdRef(function TransitionV2( + props: { + show: boolean; + appear?: boolean; + render?: (props: Record) => React.ReactNode; + } & TransitionClassProps & + TransitionCallbackProps & { + // choose only common props from `JSX.IntrinsicElements["div"]` to simplify auto-complete + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; + }, + ref: React.ForwardedRef +) { + // deifne stable callbacks with ref element + const elRef = useRef(null); + const callbackProps = convertClassPropsToCallbackProps( + props.className, + props + ); + const stableCallbackPropsWithRef = objectMapValues( + callbackProps, + (callback) => + useStableCallback(() => { + if (callback && elRef.current) { + callback(elRef.current); + } + }) + ); + + // lagged state + const durationRef = useRef(0); + const state = useLaggedBoolean(props.show, { + get duration() { + return durationRef.current; + }, + appear: props.appear, + ...stableCallbackPropsWithRef, + }); + + const mergedRefs = useMergeRefs(ref, elRef, (el: HTMLElement | null) => { + if (el) { + // hacky way to deal with `onEnterFrom` + // since "enterFrom" event comes before we render dom... + callbackProps.onEnterFrom?.(el); + + // derive timeout duration + // (note that this "force styles" so it must be called after "onEnterFrom") + durationRef.current = computeTransitionTimeout(el); + } + }); + const render = props.render ?? defaultRender; + + return ( + <> + {state && + render({ + ref: mergedRefs, + ...objectOmit(props, [ + "show", + "appear", + "render", + ...TRANSITION_CLASS_TYPES, + ...TRANSITION_CALLBACK_TYPES, + ]), + })} + + ); +}); + +function defaultRender(props: any) { + return
; +} diff --git a/packages/tiny-transition/src/lagged/v2.ts b/packages/tiny-transition/src/lagged/v2.ts new file mode 100644 index 00000000..fcae8aed --- /dev/null +++ b/packages/tiny-transition/src/lagged/v2.ts @@ -0,0 +1,71 @@ +import { computeTransitionTimeout } from "../core"; +import { AsyncOperation, type LaggedBooleanState, forceStyle } from "./core"; + +export class TransitionManagerV2 { + state: LaggedBooleanState; + private el: HTMLElement | null = null; + private listeners = new Set<() => void>(); + private asyncOp = new AsyncOperation(); + + constructor(value: boolean, options?: { appear?: boolean }) { + this.state = options?.appear ? !value : value; + } + + set(value: boolean) { + const isTruthy = + this.state === true || + this.state === "enterFrom" || + this.state === "enterTo"; + if (value !== isTruthy) { + this.startTransition(value); + } + } + + ref = (el: HTMLElement | null) => { + this.el = el; + if (el) { + if (this.state === "enterFrom") { + this.startTransition(true); + } + } else { + this.state = false; + } + }; + + private startTransition(value: boolean) { + this.asyncOp.dispose(); + + this.update(value ? "enterFrom" : "leaveFrom"); + + // delay "enterTo" transition until mount + if (!this.el) { + return; + } + + const duration = computeTransitionTimeout(this.el); + + this.asyncOp.requestAnimationFrame(() => { + forceStyle(); // `appear` breaks without this. not entirely sure why. + this.update(value ? "enterTo" : "leaveTo"); + + this.asyncOp.setTimeout(() => { + this.update(value); + }, duration); + }); + } + + private update(state: LaggedBooleanState) { + if (this.state === state) { + return; + } + this.state = state; + for (const listener of this.listeners) { + listener(); + } + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; +} diff --git a/packages/tiny-transition/src/react.tsx b/packages/tiny-transition/src/react.tsx index 7c0a1255..c6d31bf0 100644 --- a/packages/tiny-transition/src/react.tsx +++ b/packages/tiny-transition/src/react.tsx @@ -11,9 +11,10 @@ import { type TransitionCallbackProps, TransitionManager, } from "./core"; +export * from "./lagged/react"; // cheat typing to simplify dts rollup -function simpleForawrdRef< +export function simpleForawrdRef< P, T, F extends (props: P, ref: React.ForwardedRef) => JSX.Element diff --git a/packages/tiny-transition/tsconfig.json b/packages/tiny-transition/tsconfig.json index 6221ff7f..4ce00595 100644 --- a/packages/tiny-transition/tsconfig.json +++ b/packages/tiny-transition/tsconfig.json @@ -3,6 +3,6 @@ "include": ["src"], "compilerOptions": { "types": ["vite/client"], - "jsx": "react" + "jsx": "react-jsx" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c857a25..0080dd99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,12 +208,6 @@ importers: '@hiogawa/unocss-preset-antd': specifier: workspace:* version: link:../lib - '@hiogawa/utils': - specifier: 1.6.1-pre.7 - version: 1.6.1-pre.7 - '@hiogawa/utils-react': - specifier: ^1.3.1-pre.0 - version: 1.3.1-pre.0(react@18.2.0) '@types/react': specifier: ^18.2.14 version: 18.2.21 @@ -226,12 +220,6 @@ importers: packages/tiny-transition: devDependencies: - '@hiogawa/utils': - specifier: 1.6.1-pre.7 - version: 1.6.1-pre.7 - '@hiogawa/utils-react': - specifier: ^1.3.1-pre.0 - version: 1.3.1-pre.0(react@18.2.0) '@types/react': specifier: ^18.2.14 version: 18.2.14 @@ -1405,10 +1393,6 @@ packages: react: 18.2.0 dev: true - /@hiogawa/utils@1.6.1-pre.7: - resolution: {integrity: sha512-KFSgUTQiv8WD+ludYGiSt2QeXvlpaN+81h/eZZM++FKJQZcWTyLFNR5h1/on1TpXGxzois6D3PRMPXQwDywuUQ==} - dev: true - /@hiogawa/utils@1.6.1-pre.9: resolution: {integrity: sha512-9RnMKYYp0BAK4xsRl9V9gxH6J4wQWL9hzFLfREAWiiB6let0csGruGvmXAFu8RKfhG7/0h6xkpg/TrCO1+RsYg==} dev: true