Skip to content

Commit

Permalink
refactor: collapsible machine (#1442)
Browse files Browse the repository at this point in the history
* refactor: collapsible

* chore: updat

* chore: update

* chore: update

* chore: update
  • Loading branch information
segunadebayo authored Apr 24, 2024
1 parent d28ea96 commit 14658ee
Show file tree
Hide file tree
Showing 11 changed files with 93 additions and 66 deletions.
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

0 comments on commit 14658ee

Please sign in to comment.