Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tiny-transition): add LaggedBoolean #92

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 80 additions & 11 deletions packages/app/src/components/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -484,6 +489,7 @@ export function StoryModal() {

export function StorySlide() {
const [show, setShow] = React.useState(true);
const manager = useTransitionManagerV2(show, { appear: true });

return (
<div className="flex flex-col items-center gap-3 m-2">
Expand All @@ -507,7 +513,7 @@ export function StorySlide() {
>
<span className="border px-2 py-1">hello from top/right</span>
</Transition>
<Transition
<TransitionV2
appear
show={show}
className="absolute bottom-2 left-2 inline-block duration-500 transform"
Expand All @@ -517,7 +523,21 @@ export function StorySlide() {
leaveTo="translate-x-[-200%]"
>
<span className="border px-2 py-1">hello from bottom/left</span>
</Transition>
</TransitionV2>
{manager.state && (
<div
ref={manager.ref}
className={cls(
"absolute top-2 left-2 inline-block duration-500 transform",
manager.state === "enterFrom" && "translate-y-[-200%]",
manager.state === "enterTo" && "translate-y-0",
manager.state === "leaveFrom" && "translate-y-0",
manager.state === "leaveTo" && "translate-y-[-200%]"
)}
>
<span className="border px-2 py-1">hello from top/left</span>
</div>
)}
</div>
</section>
</div>
Expand All @@ -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 (
<div className="flex flex-col items-center gap-3 m-2">
<section className="flex flex-col gap-3 w-full max-w-lg border p-3">
<h2 className="text-xl">Collapse</h2>
<button
className="antd-btn antd-btn-default px-2"
onClick={() => setShow(!show)}
>
{show ? "Uncollapse" : "Collapse"}
</button>
<div className="flex gap-2">
<button
className="flex-1 antd-btn antd-btn-default px-2"
onClick={() => setShow(!show)}
>
{show ? "Uncollapse" : "Collapse"} (1)
</button>
<button
className="flex-1 antd-btn antd-btn-default px-2"
onClick={() => setShow2(!show2)}
>
{show2 ? "Uncollapse" : "Collapse"} (2)
</button>
</div>
<pre className="text-sm text-colorTextSecondary">
debug: {JSON.stringify({ show, show2, lagged2 })}
</pre>
<div className="flex flex-col p-3 border">
<div>Fixed Div</div>
<div>Fixed Div (1)</div>
<Transition
appear
show={show}
className="duration-500 overflow-hidden"
enterFrom="opacity-0"
enterTo="opacity-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
{...getCollapseProps()}
>
<div className="pt-3">Collapsable Div</div>
<div>
<div className="pt-3">Collapsable Div</div>
<div className="pt-3">Collapsable Div</div>
<div className="pt-3">Collapsable Div</div>
<div className="pt-3">Collapsable Div</div>
</div>
</Transition>
</div>
<div className="flex flex-col p-3 border">
<div>Fixed Div (2)</div>
{lagged2 && (
<div
className={cls(
"duration-500 overflow-hidden",
lagged2 === "enterFrom" && "max-h-0 opacity-0",
lagged2 === "enterTo" && "max-h-[144px] opacity-100",
lagged2 === "leaveFrom" && "max-h-[144px] opacity-100",
lagged2 === "leaveTo" && "max-h-0 opacity-0"
)}
>
<div>
<div className="pt-3">Collapsable Div</div>
<div className="pt-3">Collapsable Div</div>
<div className="pt-3">Collapsable Div</div>
<div className="pt-3">Collapsable Div</div>
</div>
</div>
)}
</div>
</section>
</div>
);
Expand Down
2 changes: 0 additions & 2 deletions packages/tiny-toast/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 0 additions & 2 deletions packages/tiny-transition/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/tiny-transition/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/tiny-transition/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./core";
export * from "./class";
export * from "./lagged/core";
98 changes: 98 additions & 0 deletions packages/tiny-transition/src/lagged/core.ts
Original file line number Diff line number Diff line change
@@ -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");
}
Loading