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

refactor: collapsible machine #1442

Merged
merged 5 commits into from
Apr 24, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/three-beers-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/collapsible": patch
---

Fix issue where initial height animation can sometimes run.
28 changes: 9 additions & 19 deletions .xstate/collapsible.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const fetchMachine = createMachine({
"isOpenControlled": false,
"isOpenControlled": false
},
entry: ["computeSize"],
exit: ["clearInitial"],
on: {
UPDATE_CONTEXT: {
actions: "updateContext"
Expand All @@ -27,43 +27,36 @@ const fetchMachine = createMachine({
states: {
closed: {
tags: ["closed"],
entry: ["computeSize"],
on: {
"CONTROLLED.OPEN": {
target: "open",
actions: ["computeSize"]
},
"CONTROLLED.OPEN": "open",
OPEN: [{
cond: "isOpenControlled",
actions: ["invokeOnOpen"]
}, {
target: "open",
actions: ["allowAnimation", "invokeOnOpen", "computeSize"]
actions: ["setInitial", "computeSize", "invokeOnOpen"]
}]
}
},
closing: {
tags: ["open"],
activities: ["trackAnimationEvents"],
on: {
"CONTROLLED.CLOSE": {
target: "closed",
actions: ["invokeOnExitComplete"]
},
"CONTROLLED.CLOSE": "closed",
"CONTROLLED.OPEN": "open",
OPEN: [{
cond: "isOpenControlled",
actions: ["invokeOnOpen"]
}, {
target: "open",
actions: ["allowAnimation", "invokeOnOpen"]
actions: ["setInitial", "invokeOnOpen"]
}],
CLOSE: [{
cond: "isOpenControlled",
actions: ["invokeOnClose"]
actions: ["invokeOnExitComplete"]
}, {
target: "closed",
actions: ["allowAnimation", "computeSize", "invokeOnExitComplete"]
actions: ["setInitial", "computeSize", "invokeOnExitComplete"]
}],
"ANIMATION.END": {
target: "closed",
Expand All @@ -74,16 +67,13 @@ const fetchMachine = createMachine({
open: {
tags: ["open"],
on: {
"CONTROLLED.CLOSE": {
target: "closing",
actions: ["computeSize"]
},
"CONTROLLED.CLOSE": "closing",
CLOSE: [{
cond: "isOpenControlled",
actions: ["invokeOnClose"]
}, {
target: "closing",
actions: ["allowAnimation", "computeSize"]
actions: ["setInitial", "computeSize", "invokeOnClose"]
}]
}
}
Expand Down
1 change: 1 addition & 0 deletions .xstate/presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
const fetchMachine = createMachine({
initial: initialState,
context: {},
exit: ["clearInitial"],
on: {
"NODE.SET": {
actions: ["setNode", "setStyles"]
Expand Down
7 changes: 4 additions & 3 deletions packages/machines/collapsible/src/collapsible.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
const width = state.context.width
const disabled = !!state.context.disabled

const skipMountAnimation = state.context.isMountAnimationPrevented && open
const skip = !state.context.initial && open

return {
disabled,
Expand All @@ -32,7 +32,7 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize

contentProps: normalize.element({
...parts.content.attrs,
"data-state": skipMountAnimation ? undefined : open ? "open" : "closed",
"data-state": skip ? undefined : open ? "open" : "closed",
id: dom.getContentId(state.context),
"data-disabled": dataAttr(disabled),
hidden: !visible,
Expand All @@ -51,7 +51,8 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
"data-disabled": dataAttr(disabled),
"aria-controls": dom.getContentId(state.context),
"aria-expanded": visible || false,
onClick() {
onClick(event) {
if (event.defaultPrevented) return
if (disabled) return
send({ type: open ? "CLOSE" : "OPEN", src: "trigger.click" })
},
Expand Down
65 changes: 34 additions & 31 deletions packages/machines/collapsible/src/collapsible.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,30 @@ export function machine(userContext: UserDefinedContext) {
...ctx,
height: 0,
width: 0,
isMountAnimationPrevented: !!ctx.open,
initial: false,
stylesRef: null,
unmountAnimationName: null,
},

watch: {
open: ["allowAnimation", "toggleVisibility"],
open: ["setInitial", "computeSize", "toggleVisibility"],
},

entry: ["computeSize"],
exit: ["clearInitial"],

states: {
closed: {
tags: ["closed"],
entry: ["computeSize"],
on: {
"CONTROLLED.OPEN": {
target: "open",
actions: ["computeSize"],
},
"CONTROLLED.OPEN": "open",
OPEN: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"],
},
{
target: "open",
actions: ["allowAnimation", "invokeOnOpen", "computeSize"],
actions: ["setInitial", "computeSize", "invokeOnOpen"],
},
],
},
Expand All @@ -51,10 +48,7 @@ export function machine(userContext: UserDefinedContext) {
tags: ["open"],
activities: ["trackAnimationEvents"],
on: {
"CONTROLLED.CLOSE": {
target: "closed",
actions: ["invokeOnExitComplete"],
},
"CONTROLLED.CLOSE": "closed",
"CONTROLLED.OPEN": "open",
OPEN: [
{
Expand All @@ -63,17 +57,17 @@ export function machine(userContext: UserDefinedContext) {
},
{
target: "open",
actions: ["allowAnimation", "invokeOnOpen"],
actions: ["setInitial", "invokeOnOpen"],
},
],
CLOSE: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"],
actions: ["invokeOnExitComplete"],
},
{
target: "closed",
actions: ["allowAnimation", "computeSize", "invokeOnExitComplete"],
actions: ["setInitial", "computeSize", "invokeOnExitComplete"],
},
],
"ANIMATION.END": {
Expand All @@ -82,21 +76,19 @@ export function machine(userContext: UserDefinedContext) {
},
},
},

open: {
tags: ["open"],
on: {
"CONTROLLED.CLOSE": {
target: "closing",
actions: ["computeSize"],
},
"CONTROLLED.CLOSE": "closing",
CLOSE: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"],
},
{
target: "closing",
actions: ["allowAnimation", "computeSize"],
actions: ["setInitial", "computeSize", "invokeOnClose"],
},
],
},
Expand All @@ -118,22 +110,23 @@ export function machine(userContext: UserDefinedContext) {
// if there's no animation, send ANIMATION.END immediately
const animationName = getComputedStyle(contentEl).animationName
const hasNoAnimation = !animationName || animationName === "none"

if (hasNoAnimation) {
send({ type: "ANIMATION.END" })
return
}

const onEnd = (event: AnimationEvent) => {
if (event.target !== contentEl) return
send({ type: "ANIMATION.END" })
const win = contentEl.ownerDocument.defaultView || window
const animationName = win.getComputedStyle(contentEl).animationName
if (event.target === contentEl && animationName === ctx.unmountAnimationName) {
send({ type: "ANIMATION.END" })
}
}

contentEl.addEventListener("animationend", onEnd)
contentEl.addEventListener("animationcancel", onEnd)

cleanup = () => {
contentEl.removeEventListener("animationend", onEnd)
contentEl.removeEventListener("animationcancel", onEnd)
}
})

Expand All @@ -144,11 +137,16 @@ export function machine(userContext: UserDefinedContext) {
},
},
actions: {
allowAnimation(ctx) {
ctx.isMountAnimationPrevented = false
setInitial(ctx) {
ctx.initial = true
},
clearInitial(ctx) {
ctx.initial = false
},
computeSize: (ctx) => {
raf(() => {
computeSize(ctx, evt) {
ctx._rafCleanup?.()

ctx._rafCleanup = raf(() => {
const contentEl = dom.getContentEl(ctx)
if (!contentEl) return

Expand All @@ -157,6 +155,11 @@ export function machine(userContext: UserDefinedContext) {
animationDuration: contentEl.style.animationDuration,
})

if (evt.type === "CLOSE" || !ctx.open) {
const win = contentEl.ownerDocument.defaultView || window
ctx.unmountAnimationName = win.getComputedStyle(contentEl).animationName
}

const hidden = contentEl.hidden

// block any animations/transitions so the element renders at its full dimensions
Expand All @@ -169,7 +172,7 @@ export function machine(userContext: UserDefinedContext) {
ctx.width = rect.width

// kick off any animations/transitions that were originally set up if it isn't the initial mount
if (!ctx.isMountAnimationPrevented) {
if (ctx.initial) {
contentEl.style.animationName = ctx.stylesRef.animationName
contentEl.style.animationDuration = ctx.stylesRef.animationDuration
}
Expand Down
14 changes: 12 additions & 2 deletions packages/machines/collapsible/src/collapsible.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,19 @@ interface PrivateContext {
stylesRef: Record<string, any> | null
/**
* @internal
* Whether the mount animation is prevented
* Whether the initial animation is allowed
*/
isMountAnimationPrevented: boolean
initial: boolean
/**
* @internal
* The requestAnimationFrame id
*/
_rafCleanup?: VoidFunction
/**
* @internal
* The unmount animation name
*/
unmountAnimationName: string | null
}

export type UserDefinedContext = RequiredBy<PublicContext, "id">
Expand Down
5 changes: 5 additions & 0 deletions packages/machines/presence/src/presence.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function machine(ctx: Partial<UserDefinedContext>) {
...ctx,
},

exit: ["clearInitial"],

watch: {
present: ["setInitial", "syncPresence"],
},
Expand Down Expand Up @@ -74,6 +76,9 @@ export function machine(ctx: Partial<UserDefinedContext>) {
setInitial(ctx) {
ctx.initial = true
},
clearInitial(ctx) {
ctx.initial = false
},
invokeOnExitComplete(ctx) {
ctx.onExitComplete?.()
},
Expand Down
4 changes: 2 additions & 2 deletions shared/src/css/collapsible.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
}

[data-scope="collapsible"][data-part="content"][data-state="open"] {
animation: slideDown 250ms cubic-bezier(0, 0, 0.38, 0.9);
animation: slideDown 200ms ease;
}

[data-scope="collapsible"][data-part="content"][data-state="closed"] {
animation: slideUp 200ms cubic-bezier(0, 0, 0.38, 0.9);
animation: slideUp 200ms ease;
}

@keyframes slideDown {
Expand Down
26 changes: 19 additions & 7 deletions starters/react/app/accordion/collapsible/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,38 @@ import { Accordion } from "@/components/accordion"

export default function Page() {
return (
<div style={{ padding: "40px", height: "200vh" }}>
<div style={{ padding: "40px", height: "200vh", maxWidth: "640px" }}>
<h1>Accordion / Collapsible</h1>
<Accordion
multiple
defaultValue={["home"]}
items={[
{
value: "home",
title: "Home",
description: "Home description",
title: "Lorem Ipsum",
description: `
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Suspendisse malesuada lacus ex, sit amet blandit leo lobortis eget.
proin in nisi elementum, egestas libero sed, pretium mi.
`,
},
{
value: "about",
title: "About",
description: "About description",
title: "Cake Ipsum",
description: `
Cake icing topping. I love sugar plum I love oat cake sweet.
I love oat cake sweet. I love oat cake sweet.
I love oat cake sweet. I love oat cake sweet.
`,
},
{
value: "contact",
title: "Contact",
description: "Contact description",
title: "Hummingbird Ipsum",
description: `
The humble cupcake. Tiramisu gingerbread jujubes sugar plum.
Sweet roll sweet roll I love marzipan I love.
fruitcake I love I love fruitcake.
`,
},
]}
/>
Expand Down
2 changes: 1 addition & 1 deletion starters/react/app/collapsible/controlled/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Collapsible } from "@/components/collapsible"
import { useState } from "react"

export default function Page() {
const [open, setOpen] = useState(false)
const [open, setOpen] = useState(true)

return (
<div style={{ padding: "40px", height: "200vh" }}>
Expand Down
Loading
Loading