Skip to content

Commit

Permalink
feat(machines): collapsible (#986)
Browse files Browse the repository at this point in the history
* feat(machines): collapsible

* feat: minimum working example

* docs: wip

* chore: add more examples

* feat: add tests

* feat: add height animation

* docs: wip

* fix: type

* refactor: move keyframes css to shared

* test: rename utils

* chore: wip

* chore: wip

* chore: wip

* fix: height px

* Create thin-flies-rhyme.md

* feat: working transition

* refactor: first pass

* refactor: collapsible

* chore: update examples

* fix: initial tags

---------

Co-authored-by: Segun Adebayo <[email protected]>
  • Loading branch information
Omikorin and segunadebayo authored Feb 9, 2024
1 parent 99c5a68 commit 99ea9b0
Show file tree
Hide file tree
Showing 50 changed files with 1,368 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/thin-flies-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/collapsible": minor
---

Add Collapsible machine
95 changes: 95 additions & 0 deletions .xstate/collapsible.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use strict";

var _xstate = require("xstate");
const {
actions,
createMachine,
assign
} = _xstate;
const {
choose
} = actions;
const fetchMachine = createMachine({
id: "collapsible",
initial: ctx.open ? "open" : "closed",
context: {
"isOpenControlled": false,
"isOpenControlled": false,
"isOpenControlled": false,
"isOpenControlled": false
},
entry: ["computeSize"],
exit: ["clearAnimationStyles"],
activities: ["trackMountAnimation"],
on: {
UPDATE_CONTEXT: {
actions: "updateContext"
}
},
states: {
closed: {
tags: ["closed"],
entry: ["computeSize"],
on: {
"CONTROLLED.OPEN": "open",
OPEN: [{
cond: "isOpenControlled",
actions: ["invokeOnOpen"]
}, {
target: "open",
actions: ["invokeOnOpen"]
}]
}
},
closing: {
tags: ["open"],
activities: ["trackAnimationEvents"],
on: {
"CONTROLLED.CLOSE": "closed",
"CONTROLLED.OPEN": "open",
OPEN: [{
cond: "isOpenControlled",
actions: ["invokeOnOpen"]
}, {
target: "open",
actions: ["invokeOnOpen"]
}],
CLOSE: [{
cond: "isOpenControlled",
actions: ["invokeOnClose"]
}, {
target: "closed",
actions: ["computeSize"]
}],
"ANIMATION.END": "closed"
}
},
open: {
tags: ["open"],
on: {
"CONTROLLED.CLOSE": {
target: "closing",
actions: ["computeSize"]
},
CLOSE: [{
cond: "isOpenControlled",
actions: ["invokeOnClose"]
}, {
target: "closing",
actions: ["computeSize"]
}]
}
}
}
}, {
actions: {
updateContext: assign((context, event) => {
return {
[event.contextKey]: true
};
})
},
guards: {
"isOpenControlled": ctx => ctx["isOpenControlled"]
}
});
3 changes: 1 addition & 2 deletions .xstate/presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ const fetchMachine = createMachine({
states: {
mounted: {
on: {
UNMOUNT: "unmounted",
"ANIMATION.OUT": "unmountSuspended"
UNMOUNT: "unmounted"
}
},
unmountSuspended: {
Expand Down
30 changes: 30 additions & 0 deletions e2e/collapsible.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test"
import { a11y, part } from "./_utils"

const trigger = part("trigger")
const content = part("content")

test.describe("collapsible", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/collapsible")
})

test("should have no accessibility violation", async ({ page }) => {
await a11y(page)
})

test("[toggle] should be open when clicked", async ({ page }) => {
await page.click(trigger)
await expect(page.locator(content)).toBeVisible()

await page.click(trigger)
await expect(page.locator(content)).not.toBeVisible()
})

test.skip("[closed] content should not be reachable via tab key", async ({ page }) => {
await page.click(trigger)
await page.click(trigger)
await page.keyboard.press("Tab")
await expect(page.getByRole("button", { name: "Open" })).toBeFocused()
})
})
34 changes: 34 additions & 0 deletions examples/next-app/app/collapsible/controlled/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client"

import { Collapsible } from "@/components/collapsible"
import { useState } from "react"

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

return (
<div style={{ padding: "40px", height: "200vh" }}>
<h1>Collapsible Controlled</h1>

<h1>{String(open)}</h1>

<button type="button" onClick={() => setOpen(true)}>
Open
</button>

<button type="button" onClick={() => setOpen(false)}>
Close
</button>

<Collapsible open={open} onOpenChange={({ open }) => setOpen(open)}>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum. <a href="#">Some Link</a>
</p>
</Collapsible>
</div>
)
}
1 change: 1 addition & 0 deletions examples/next-app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const routes = [
{ path: "/hover-card/controlled", name: "HoverCard - Controlled" },
{ path: "/tooltip/controlled", name: "Tooltip - Controlled" },
{ path: "/menu/controlled", name: "Menu - Controlled" },
{ path: "/collapsible/controlled", name: "Collapsible - Controlled" },
]

export default function Page() {
Expand Down
36 changes: 36 additions & 0 deletions examples/next-app/components/collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as collapsible from "@zag-js/collapsible"
import { normalizeProps, useMachine } from "@zag-js/react"
import { cloneElement, isValidElement, useId } from "react"

interface Props extends Omit<collapsible.Context, "id" | "__controlled"> {
defaultOpen?: boolean
trigger?: React.ReactNode
children: React.ReactNode
}

export function Collapsible(props: Props) {
const { trigger, children, open, defaultOpen, ...context } = props

const [state, send] = useMachine(
collapsible.machine({
id: useId(),
open: open ?? defaultOpen,
__controlled: open !== undefined,
}),
{
context: {
...context,
open,
},
},
)

const api = collapsible.connect(state, send, normalizeProps)

return (
<div {...api.rootProps}>
{isValidElement(trigger) ? cloneElement(trigger, api.triggerProps) : null}
<div {...api.contentProps}>{children}</div>
</div>
)
}
3 changes: 2 additions & 1 deletion examples/next-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@zag-js/carousel": "workspace:*",
"@zag-js/checkbox": "workspace:*",
"@zag-js/clipboard": "workspace:*",
"@zag-js/collapsible": "workspace:*",
"@zag-js/collection": "workspace:*",
"@zag-js/color-picker": "workspace:*",
"@zag-js/color-utils": "workspace:*",
Expand Down Expand Up @@ -91,4 +92,4 @@
"eslint-config-next": "14.1.0",
"typescript": "5.3.3"
}
}
}
3 changes: 2 additions & 1 deletion examples/next-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@zag-js/avatar": "workspace:*",
"@zag-js/carousel": "workspace:*",
"@zag-js/checkbox": "workspace:*",
"@zag-js/collapsible": "workspace:*",
"@zag-js/clipboard": "workspace:*",
"@zag-js/collection": "workspace:*",
"@zag-js/color-picker": "workspace:*",
Expand Down Expand Up @@ -94,4 +95,4 @@
"typescript": "5.3.3"
},
"license": "MIT"
}
}
51 changes: 51 additions & 0 deletions examples/next-ts/pages/collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as collapsible from "@zag-js/collapsible"
import { normalizeProps, useMachine } from "@zag-js/react"
import { collapsibleControls } from "@zag-js/shared"
import { useId } from "react"
import { StateVisualizer } from "../components/state-visualizer"
import { Toolbar } from "../components/toolbar"
import { useControls } from "../hooks/use-controls"

export default function Page() {
const controls = useControls(collapsibleControls)

const [state, send] = useMachine(
collapsible.machine({
id: useId(),
}),
{
context: controls.context,
},
)

const api = collapsible.connect(state, send, normalizeProps)

return (
<>
<main className="collapsible">
<div {...api.rootProps}>
<button {...api.triggerProps}>Collapsible Trigger</button>
<div {...api.contentProps}>
<p>
Lorem dfd dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna sfsd. Ut enim ad minimdfd v eniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum. <a href="#">Some Link</a>
</p>
</div>
</div>

<div>
<div>Toggle Controls</div>
<button onClick={api.open}>Open</button>
<button onClick={api.close}>Close</button>
</div>
</main>

<Toolbar controls={controls.ui} viz>
<StateVisualizer state={state} omit={["stylesRef"]} />
</Toolbar>
</>
)
}
1 change: 1 addition & 0 deletions examples/nuxt-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@zag-js/avatar": "workspace:*",
"@zag-js/carousel": "workspace:*",
"@zag-js/checkbox": "workspace:*",
"@zag-js/collapsible": "workspace:*",
"@zag-js/clipboard": "workspace:*",
"@zag-js/collection": "workspace:*",
"@zag-js/color-picker": "workspace:*",
Expand Down
37 changes: 37 additions & 0 deletions examples/nuxt-ts/pages/collapsible.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import * as collapsible from "@zag-js/collapsible"
import { collapsibleControls } from "@zag-js/shared"
import { normalizeProps, useMachine } from "@zag-js/vue"
const controls = useControls(collapsibleControls)
const [state, send] = useMachine(collapsible.machine({ id: "1" }), {
context: controls.context,
})
const api = computed(() => collapsible.connect(state.value, send, normalizeProps))
</script>

<template>
<main class="collapsible">
<div v-bind="api.rootProps">
<button v-bind="api.triggerProps">Collapsible Trigger</button>
<div v-bind="api.contentProps">
<p>
Lorem dfd dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna sfsd. Ut enim ad minimdfd v eniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim
id est laborum. <a href="#">Some Link</a>
</p>
</div>
</div>
</main>

<Toolbar>
<StateVisualizer :state="state" />
<template #controls>
<Controls :control="controls" :state="controls.context" />
</template>
</Toolbar>
</template>
3 changes: 2 additions & 1 deletion examples/preact-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@zag-js/avatar": "workspace:*",
"@zag-js/carousel": "workspace:*",
"@zag-js/checkbox": "workspace:*",
"@zag-js/collapsible": "workspace:*",
"@zag-js/clipboard": "workspace:*",
"@zag-js/collection": "workspace:*",
"@zag-js/color-picker": "workspace:*",
Expand Down Expand Up @@ -87,4 +88,4 @@
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}
}
3 changes: 2 additions & 1 deletion examples/solid-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@zag-js/avatar": "workspace:*",
"@zag-js/carousel": "workspace:*",
"@zag-js/checkbox": "workspace:*",
"@zag-js/collapsible": "workspace:*",
"@zag-js/clipboard": "workspace:*",
"@zag-js/collection": "workspace:*",
"@zag-js/color-picker": "workspace:*",
Expand Down Expand Up @@ -89,4 +90,4 @@
"lucide-solid": "^0.321.0",
"solid-js": "1.8.12"
}
}
}
Loading

0 comments on commit 99ea9b0

Please sign in to comment.