diff --git a/packages/transition-group/dev/list-page.tsx b/packages/transition-group/dev/list-page.tsx index 6d86bc855..4cd1f896a 100644 --- a/packages/transition-group/dev/list-page.tsx +++ b/packages/transition-group/dev/list-page.tsx @@ -1,23 +1,68 @@ import { + createEffect, + createMemo, createRenderEffect, createResource, + mapArray, onCleanup, onMount, Suspense, - untrack, useTransition, } from "solid-js"; import { resolveElements } from "@solid-primitives/refs"; import { Component, createSignal, For, Show } from "solid-js"; -import { createListTransition } from "../src"; +import { createListTransition, ExitMethod, InterruptMethod } from "../src"; + +const animationOptions = { duration: 1000, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }; const grayOutOnDispose = (el: HTMLElement) => { onCleanup(() => { - el.style.filter = "grayscale(60%)"; + el.style.filter = "grayscale(100%)"; el.style.zIndex = "0"; }); }; +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +function fixPosition(el: HTMLElement) { + const style = getComputedStyle(el); + if (style.position !== "absolute" && style.position !== "fixed") { + const { width, height } = style; + const startRect = el.getBoundingClientRect(); + el.style.position = "absolute"; + el.style.width = width; + el.style.height = height; + addTransform(el, startRect); + } +} + +function unfixPosition(el: HTMLElement) { + el.style.removeProperty("position"); + el.style.removeProperty("width"); + el.style.removeProperty("height"); + removeTransform(el); +} + +function addTransform(el: HTMLElement, startRect: Rect) { + const endRect = el.getBoundingClientRect(); + if (startRect.x !== endRect.x || startRect.y !== endRect.y) { + const style = getComputedStyle(el); + const transform = style.transform === "none" ? "" : style.transform; + el.style.transform = `${transform} translate(${startRect.x - endRect.x}px, ${ + startRect.y - endRect.y + }px)`; + } +} + +function removeTransform(el: HTMLElement) { + el.style.removeProperty("transform"); +} + const ListPage: Component = () => { const [show, setShow] = createSignal(false); const [show1, setShow1] = createSignal(false); @@ -34,9 +79,185 @@ const ListPage: Component = () => { // const appear = localStorage.getItem("transition-group-appear") === "true"; const appear = true; + const [exitMethod, setExitMethod] = createSignal("move-to-end"); + const [interruptMethod, setInterruptMethod] = createSignal("cancel"); + + createEffect(() => { + console.log(exitMethod(), interruptMethod()); + }); + + const Content: Component = () => { + createRenderEffect(res); + + const node0 = ( +
{ + onMount(() => console.log("mounted", el.isConnected)); + grayOutOnDispose(el); + }} + > + ID 0 +
+ ); + + const resolved = resolveElements( + () => ( + <> +

Hello

+ World + {show() && node0} + +
+ ID 1 +
+
+ +
+ ID 2 +
+
+ ID 3 +
+
+ + {({ n }, i) => ( +
+ setList(p => { + const copy = p.slice(); + copy.splice(i(), 1); + return copy; + }) + } + ref={grayOutOnDispose} + > + {n + 1}. +
+ )} +
+ + ), + (el): el is HTMLElement => el instanceof HTMLElement, + ); + + const transition = createMemo(() => { + return createListTransition(resolved.toArray, { + appear, + interruptMethod: interruptMethod(), + exitMethod: exitMethod(), + }); + }); + + const els = mapArray( + () => transition()(), + ([el, { state, useEnter, useExit, useRemain }]) => { + createEffect(() => { + console.log("state", state(), el); + }); + + useEnter(() => { + console.log("enter", el); + + for (const animation of el.getAnimations()) { + animation.commitStyles(); + animation.cancel(); + } + + const { top: top1, left: left1 } = el.getBoundingClientRect(); + + return () => { + el.removeAttribute("style"); + const { top: top2, left: left2 } = el.getBoundingClientRect(); + + return el + .animate( + [ + { + opacity: 0, + transform: `translate(${left1 - left2}px, ${top1 - top2}px) translateY(-30px)`, + }, + { + opacity: 1, + transform: `translate(0px, 0px) translateY(0px)`, + }, + ], + animationOptions, + ) + .finished.catch(() => {}); + }; + }); + + useExit(() => { + console.log("exit", el); + + for (const animation of el.getAnimations()) { + animation.commitStyles(); + animation.cancel(); + } + + const { top: top1, left: left1 } = el.getBoundingClientRect(); + + return () => { + el.style.position = "absolute"; + el.style.transform = ""; + const { top: top2, left: left2 } = el.getBoundingClientRect(); + + return el + .animate( + [ + { + transform: `translate(${left1 - left2}px, ${top1 - top2}px) translateY(0px)`, + }, + { + opacity: 0, + transform: `translate(${left1 - left2}px, ${top1 - top2}px) translateY(30px)`, + }, + ], + animationOptions, + ) + .finished.catch(() => {}); + }; + }); + + useRemain(() => { + // console.log("remain", el); + + for (const animation of el.getAnimations()) { + animation.commitStyles(); + animation.cancel(); + } + + const { top: top1, left: left1 } = el.getBoundingClientRect(); + + return () => { + el.style.transform = ""; + const { top: top2, left: left2 } = el.getBoundingClientRect(); + + return el + .animate( + [ + { transform: `translate(${left1 - left2}px, ${top1 - top2}px)` }, + { opacity: 1, transform: `translate(0px, 0px)` }, + ], + animationOptions, + ) + .finished.catch(() => {}); + }; + }); + + return el; + }, + ); + + return <>{els()}; + }; + return ( <> -
+
+ +
@@ -95,130 +342,7 @@ const ListPage: Component = () => {
Suspended

}> - {untrack(() => { - // track the resource - createRenderEffect(res); - - const resolved = resolveElements( - () => ( - <> -

Hello

- World - {show() && ( -
{ - onMount(() => console.log("mounted", el.isConnected)); - grayOutOnDispose(el); - }} - > - ID 0 -
- )} - -
- ID 1 -
-
- -
- ID 2 -
-
- ID 3 -
-
- - {({ n }, i) => ( -
- setList(p => { - const copy = p.slice(); - copy.splice(i(), 1); - return copy; - }) - } - ref={grayOutOnDispose} - > - {n + 1}. -
- )} -
- - ), - (el): el is HTMLElement => el instanceof HTMLElement, - ); - - const options = { duration: 600, easing: "cubic-bezier(0.4, 0, 0.2, 1)" }; - - const transition = createListTransition(resolved.toArray, { - appear, - onChange({ added, finishRemoved, unchanged, removed }) { - added.forEach(el => { - queueMicrotask(() => { - if (!el.isConnected) return; - el.style.opacity = "0"; - el.style.transform = "translateY(10px)"; - el.animate( - [ - { opacity: 0, transform: "translateY(-36px)" }, - { opacity: 1, transform: "translateY(0)" }, - ], - { ...options, fill: "both" }, - ); - }); - }); - - unchanged.forEach(el => { - const { left: left1, top: top1 } = el.getBoundingClientRect(); - if (!el.isConnected) return; - queueMicrotask(() => { - const { left: left2, top: top2 } = el.getBoundingClientRect(); - el.animate( - [ - { transform: `translate(${left1 - left2}px, ${top1 - top2}px)` }, - { transform: "none" }, - ], - options, - ); - }); - }); - - const removedRects = removed.map(el => el.getBoundingClientRect()); - removed.forEach(el => { - el.style.transform = "none"; - el.style.position = "absolute"; - }); - queueMicrotask(() => { - removed.forEach((el, i) => { - if (!el.isConnected) return finishRemoved([el]); - - const { left: left1, top: top1 } = removedRects[i]!; - const { left: left2, top: top2 } = el.getBoundingClientRect(); - - const a = el.animate( - [ - { transform: `translate(${left1 - left2}px, ${top1 - top2}px)` }, - { - opacity: 0, - transform: `translate(${left1 - left2}px, ${top1 - top2 + 36}px)`, - }, - ], - options, - ); - - i === removed.length - 1 && - a.finished - .then(() => finishRemoved(removed)) - .catch(() => finishRemoved(removed)); - }); - }); - }, - }); - - return <>{transition()}; - })} +
diff --git a/packages/transition-group/src/index.ts b/packages/transition-group/src/index.ts index 7e4dfa19d..4830f35e1 100644 --- a/packages/transition-group/src/index.ts +++ b/packages/transition-group/src/index.ts @@ -5,12 +5,13 @@ import { untrack, $TRACK, createComputed, - createMemo, useTransition, } from "solid-js"; import { isServer } from "solid-js/web"; +import { arrayEquals, makeSetItem, trackTransitionPending } from "./utils"; const noop = () => {}; +const noopAsync = async () => {}; const noopTransition = (el: any, done: () => void) => done(); export type TransitionMode = "out-in" | "in-out" | "parallel"; @@ -163,16 +164,18 @@ export type OnListChange = (payload: { export type ExitMethod = "remove" | "move-to-end" | "keep-index"; -export type ListTransitionOptions = { - /** - * A function to be called when the list changes. {@link OnListChange} - * - * It receives the list of current, added, removed, and unchanged elements. - * It also receives a callback to be called when the removed elements are finished animating (they can be removed from the DOM). - */ - onChange: OnListChange; +export type InterruptMethod = "cancel" | "wait" | "none"; + +export type ListTransitionOptions = { /** whether to run the transition on the initial elements. Defaults to `false` */ appear?: boolean; + /** + * This controls what happens when a transition is interrupted. This can happen when an element exits before an enter transition has completed, or vice versa. {@link InterruptMethod} + * - `"cancel"` (default) abandons the current transition and starts the interrupting one as soon as it happens. + * - `"wait"` waits for the current transition to complete before starting the most recently interrupting transition. + * - `"none"` ignores the interrupting transition entirely + */ + interruptMethod?: InterruptMethod; /** * This controls how the elements exit. {@link ExitMethod} * - `"remove"` removes the element immediately. @@ -182,6 +185,121 @@ export type ListTransitionOptions = { exitMethod?: ExitMethod; }; +type TransitionItemState = "initial" | "entering" | "entered" | "exiting" | "exited"; + +type TransitionCallback = () => () => Promise; + +type TransitionControl = () => () => Promise; + +type TransitionItemContext = { + state: Accessor; + useEnter(callback: TransitionCallback): void; + useExit(callback: TransitionCallback): void; + useRemain(callback: TransitionCallback): void; +}; + +type TransitionItemControls = { + enter: TransitionControl; + exit: TransitionControl; + remain: TransitionControl; +}; + +type TransitionItem = [T, TransitionItemContext, TransitionItemControls]; + +class TransitionInterruptError extends Error { + static ignore(error: any) { + if (!(error instanceof TransitionInterruptError)) { + throw error; + } + } +} + +function createTransitionItem(el: T, options: ListTransitionOptions): TransitionItem { + const [state, setState] = createSignal("initial"); + const cancelSet = new Set<() => void>(); + const enterCallbacks = new Set(); + const exitCallbacks = new Set(); + const remainCallbacks = new Set(); + let controlIsRunning: boolean = false; + + const makeTransitionControl: ( + callbackSet: Set, + startState: TransitionItemState, + endState: TransitionItemState, + ) => TransitionControl = + options.interruptMethod === "none" + ? (callbackSet, startState, endState) => () => { + if (controlIsRunning) { + return noopAsync; + } + + controlIsRunning = true; + + const callbacks = Array.from(callbackSet).map(callback => callback()); + setState(startState); + + return () => + Promise.all(callbacks.map(callback => callback())) + .then(() => { + setState(endState); + }) + .finally(() => { + controlIsRunning = false; + }); + } + : (callbackSet, startState, endState) => () => { + for (const cancel of cancelSet) { + cancel(); + } + + let cancel: () => void; + + const cancelPromise = new Promise((_, reject) => { + cancel = () => { + reject(new TransitionInterruptError()); + }; + cancelSet.add(cancel); + }); + + const callbacks = Array.from(callbackSet).map(callback => callback()); + + setState(startState); + + return () => + Promise.race([Promise.all(callbacks.map(callback => callback())), cancelPromise]) + .then(() => { + setState(endState); + }) + .finally(() => { + cancelSet.delete(cancel); + }); + }; + + return [ + el, + { + state, + useEnter(callback: TransitionCallback) { + makeSetItem(enterCallbacks, callback); + }, + useExit(callback: TransitionCallback) { + makeSetItem(exitCallbacks, callback); + }, + useRemain(callback: TransitionCallback) { + makeSetItem(remainCallbacks, callback); + }, + }, + { + enter: makeTransitionControl(enterCallbacks, "entering", "entered"), + exit: makeTransitionControl(exitCallbacks, "exiting", "exited"), + remain() { + return () => + Promise.all(Array.from(remainCallbacks).map(callback => callback()())).then(noop); + }, + }, + ]; +} + /** * Create an element list transition interface for changes to the list of elements. * It can be used to implement own transition effect, or a custom ``-like component. @@ -213,93 +331,133 @@ export type ListTransitionOptions = { */ export function createListTransition( source: Accessor, - options: ListTransitionOptions, -): Accessor { + options: ListTransitionOptions, +): Accessor<[T, TransitionItemContext][]> { const initSource = untrack(source); if (isServer) { - const copy = initSource.slice(); + const copy = initSource.slice().map<[T, TransitionItemContext]>(item => [ + item, + { + state: () => "initial", + useEnter() {}, + useExit() {}, + useRemain() {}, + }, + ]); return () => copy; } - const { onChange } = options; - // if appear is enabled, the initial transition won't have any previous elements. // otherwise the elements will match and transition skipped, or transitioned if the source is different from the initial value let prevSet: ReadonlySet = new Set(options.appear ? undefined : initSource); - const exiting = new WeakSet(); + + const [result, setResult] = createSignal[]>( + options.appear ? [] : initSource.slice().map(el => createTransitionItem(el, options)), + { equals: arrayEquals }, + ); const [toRemove, setToRemove] = createSignal([], { equals: false }); + const [isTransitionPending] = useTransition(); - const finishRemoved: (els: T[]) => void = + const finishRemoved: (el: T) => void = options.exitMethod === "remove" ? noop - : els => { - setToRemove(p => (p.push.apply(p, els), p)); - for (const el of els) exiting.delete(el); + : el => { + setToRemove(p => [...p, el]); }; - const handleRemoved: (els: T[], el: T, i: number) => void = + const handleExiting: (items: TransitionItem[], item: TransitionItem, i: number) => void = options.exitMethod === "remove" ? noop : options.exitMethod === "keep-index" - ? (els, el, i) => els.splice(i, 0, el) - : (els, el) => els.push(el); - - return createMemo( - prev => { - const elsToRemove = toRemove(); - const sourceList = source(); - (sourceList as any)[$TRACK]; // top level store tracking - - if (untrack(isTransitionPending)) { - // wait for pending transition to end before animating - isTransitionPending(); - return prev; - } - - if (elsToRemove.length) { - const next = prev.filter(e => !elsToRemove.includes(e)); - elsToRemove.length = 0; - onChange({ list: next, added: [], removed: [], unchanged: next, finishRemoved }); - return next; - } + ? (items, item, i) => items.splice(i, 0, item) + : (items, item) => items.push(item); + + createComputed(() => { + console.log("computed"); + const sourceList = source(); + const elsToRemove = toRemove(); + (sourceList as any)[$TRACK]; // top level store tracking + + trackTransitionPending(isTransitionPending, () => { + untrack(() => { + const prev = result(); + + if (elsToRemove.length) { + console.log("elsToRemove", elsToRemove); + const next = prev.filter(([el]) => !elsToRemove.includes(el)); + elsToRemove.length = 0; + setResult(next); + return; + } - return untrack(() => { const nextSet: ReadonlySet = new Set(sourceList); - const next: T[] = sourceList.slice(); - - const added: T[] = []; - const removed: T[] = []; - const unchanged: T[] = []; - - for (const el of sourceList) { - (prevSet.has(el) ? unchanged : added).push(el); + const next: TransitionItem[] = []; + const entering: TransitionItem[] = []; + const exiting: TransitionItem[] = []; + const remaining: TransitionItem[] = []; + + for (let i = 0; i < sourceList.length; i++) { + const el = sourceList[i]!; + if (prevSet.has(el)) { + const item = prev.find(([prevEl]) => prevEl === el)!; + next.push(item); + remaining.push(item); + } else { + console.log("add", el); + const item = createTransitionItem(el, options); + next.push(item); + entering.push(item); + } } - let nothingChanged = !added.length; for (let i = 0; i < prev.length; i++) { - const el = prev[i]!; + const item = prev[i]!; + const [el] = item; if (!nextSet.has(el)) { - if (!exiting.has(el)) { - removed.push(el); - exiting.add(el); + handleExiting(next, item, i); + if (prevSet.has(el)) { + console.log("remove", el); + exiting.push(item); } - handleRemoved(next, el, i); } - if (nothingChanged && el !== next[i]) nothingChanged = false; } - // skip if nothing changed - if (!removed.length && nothingChanged) return prev; + prevSet = nextSet; + setResult(next); + + queueMicrotask(() => { + const callbacks: Array<() => Promise> = []; + + for (let i = 0; i < exiting.length; i++) { + const [el, , controls] = exiting[i]!; + const callback = controls.exit(); + callbacks.push(() => + callback().then(() => { + finishRemoved(el); + }), + ); + } - onChange({ list: next, added, removed, unchanged, finishRemoved }); + for (let i = 0; i < entering.length; i++) { + const [, , controls] = entering[i]!; + callbacks.push(controls.enter()); + } - prevSet = nextSet; - return next; + for (let i = 0; i < remaining.length; i++) { + const [, , controls] = remaining[i]!; + callbacks.push(controls.remain()); + } + + for (let i = 0; i < callbacks.length; i++) { + callbacks[i]?.().catch(TransitionInterruptError.ignore); + } + }); }); - }, - options.appear ? [] : initSource.slice(), - ); + }); + }); + + return result as unknown as Accessor<[T, TransitionItemContext][]>; } diff --git a/packages/transition-group/src/utils.ts b/packages/transition-group/src/utils.ts new file mode 100644 index 000000000..2deefc5d6 --- /dev/null +++ b/packages/transition-group/src/utils.ts @@ -0,0 +1,25 @@ +import { Accessor, onCleanup, untrack } from "solid-js"; + +export function makeSetItem(set: Set, item: T) { + set.add(item); + onCleanup(() => { + set.delete(item); + }); +} + +export function arrayEquals>(a: T, b: T): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function trackTransitionPending(isPending: Accessor, callback: () => void): void { + if (untrack(isPending)) { + isPending(); + return; + } else { + callback(); + } +} diff --git a/site/src/routes/playground/playground.scss b/site/src/routes/playground/playground.scss index 4d06ec720..e9eeab798 100644 --- a/site/src/routes/playground/playground.scss +++ b/site/src/routes/playground/playground.scss @@ -15,10 +15,15 @@ Styles applied in the package playgrounds. @apply font-mono text-sm leading-tight; } - button { + button, + select { @apply border-1 flex cursor-pointer select-none items-center justify-center rounded border-teal-500 bg-teal-600 p-3 py-2 font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:bg-teal-700 disabled:saturate-50 disabled:hover:bg-teal-700; } + label:has(select) { + @apply flex items-center gap-2; + } + .wrapper-h { @apply flex items-center justify-center space-x-4 space-y-0 rounded-2xl bg-gray-700 p-6; }