Skip to content

Commit

Permalink
docs: presence component (#1439)
Browse files Browse the repository at this point in the history
* chore: update presence

* chore: update starter

* chore: switch to animate presence

* chore: switch to unmount on exit

* docs: update
  • Loading branch information
segunadebayo authored Apr 23, 2024
1 parent 04cf34f commit 159b9f5
Show file tree
Hide file tree
Showing 26 changed files with 1,052 additions and 401 deletions.
6 changes: 6 additions & 0 deletions .changeset/giant-socks-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@zag-js/presence": minor
---

- Add `api.unmount` to programmatically unmount the component
- Add `api.skip` to skip initial animation
44 changes: 16 additions & 28 deletions .xstate/presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,11 @@ const {
} = actions;
const fetchMachine = createMachine({
initial: initialState,
context: {
"isPresent": false,
"isAnimationNone || isDisplayNone": false,
"wasPresent && isAnimating": false
},
context: {},
on: {
"NODE.SET": {
actions: ["setNode", "setStyles"]
},
"PRESENCE.CHANGED": [{
cond: "isPresent",
target: "mounted",
actions: ["setPrevAnimationName"]
}, {
cond: "isAnimationNone || isDisplayNone",
target: "unmounted",
actions: ["invokeOnExitComplete"]
}, {
cond: "wasPresent && isAnimating",
target: "unmountSuspended"
}, {
target: "unmounted",
actions: ["invokeOnExitComplete"]
}]
}
},
on: {
UPDATE_CONTEXT: {
Expand All @@ -44,7 +25,11 @@ const fetchMachine = createMachine({
states: {
mounted: {
on: {
UNMOUNT: "unmounted"
UNMOUNT: {
target: "unmounted",
actions: ["invokeOnExitComplete"]
},
"UNMOUNT.SUSPEND": "unmountSuspended"
}
},
unmountSuspended: {
Expand All @@ -57,13 +42,20 @@ const fetchMachine = createMachine({
"ANIMATION.END": {
target: "unmounted",
actions: ["invokeOnExitComplete"]
},
UNMOUNT: {
target: "unmounted",
actions: ["invokeOnExitComplete"]
}
}
},
unmounted: {
entry: ["clearPrevAnimationName"],
on: {
MOUNT: "mounted"
MOUNT: {
target: "mounted",
actions: ["setPrevAnimationName"]
}
}
}
}
Expand All @@ -75,9 +67,5 @@ const fetchMachine = createMachine({
};
})
},
guards: {
"isPresent": ctx => ctx["isPresent"],
"isAnimationNone || isDisplayNone": ctx => ctx["isAnimationNone || isDisplayNone"],
"wasPresent && isAnimating": ctx => ctx["wasPresent && isAnimating"]
}
guards: {}
});
8 changes: 8 additions & 0 deletions packages/docs/data/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -2195,13 +2195,21 @@
},
"presence": {
"api": {
"skip": {
"type": "boolean",
"description": "Whether the animation should be skipped."
},
"present": {
"type": "boolean",
"description": "Whether the node is present in the DOM."
},
"setNode": {
"type": "(node: HTMLElement) => void",
"description": "Function to set the node (as early as possible)"
},
"unmount": {
"type": "() => void",
"description": "Function to programmatically unmount the node"
}
},
"context": {
Expand Down
9 changes: 7 additions & 2 deletions packages/machines/presence/src/presence.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types"
import type { MachineApi, Send, State } from "./presence.types"

export function connect<T extends PropTypes>(state: State, send: Send, _normalize: NormalizeProps<T>): MachineApi {
const present = state.matches("mounted", "unmountSuspended")
return {
present: state.matches("mounted", "unmountSuspended"),
setNode(node: HTMLElement | null) {
skip: !state.context.initial && present,
present,
setNode(node) {
if (!node) return
send({ type: "NODE.SET", node })
},
unmount() {
send({ type: "UNMOUNT" })
},
}
}
94 changes: 51 additions & 43 deletions packages/machines/presence/src/presence.machine.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { createMachine, ref, guards } from "@zag-js/core"
import { createMachine, ref } from "@zag-js/core"
import type { MachineContext, MachineState, UserDefinedContext } from "./presence.types"

const { and, or } = guards

function getAnimationName(styles?: CSSStyleDeclaration | null) {
return styles?.animationName || "none"
}
Expand All @@ -12,42 +10,35 @@ export function machine(ctx: Partial<UserDefinedContext>) {
return createMachine<MachineContext, MachineState>(
{
initial: initialState,
watch: {
present: ["raisePresenceChange", "setPrevPresent"],
},

context: {
node: null,
styles: null,
prevAnimationName: "",
unmountAnimationName: null,
prevAnimationName: null,
present: false,
initial: false,
...ctx,
},

watch: {
present: ["setInitial", "syncPresence"],
},

on: {
"NODE.SET": {
actions: ["setNode", "setStyles"],
},
"PRESENCE.CHANGED": [
{
guard: "isPresent",
target: "mounted",
actions: ["setPrevAnimationName"],
},
{
guard: or("isAnimationNone", "isDisplayNone"),
target: "unmounted",
actions: ["invokeOnExitComplete"],
},
{
guard: and("wasPresent", "isAnimating"),
target: "unmountSuspended",
},
{ target: "unmounted", actions: ["invokeOnExitComplete"] },
],
},

states: {
mounted: {
on: {
UNMOUNT: "unmounted",
UNMOUNT: {
target: "unmounted",
actions: ["invokeOnExitComplete"],
},
"UNMOUNT.SUSPEND": "unmountSuspended",
},
},
unmountSuspended: {
Expand All @@ -61,25 +52,28 @@ export function machine(ctx: Partial<UserDefinedContext>) {
target: "unmounted",
actions: ["invokeOnExitComplete"],
},
UNMOUNT: {
target: "unmounted",
actions: ["invokeOnExitComplete"],
},
},
},
unmounted: {
entry: ["clearPrevAnimationName"],
on: {
MOUNT: "mounted",
MOUNT: {
target: "mounted",
actions: ["setPrevAnimationName"],
},
},
},
},
},
{
guards: {
isPresent: (ctx) => !!ctx.present,
isAnimationNone: (ctx) => getAnimationName(ctx.styles) === "none",
isDisplayNone: (ctx) => ctx.styles?.display === "none",
wasPresent: (ctx) => !!ctx.prevPresent,
isAnimating: (ctx) => ctx.prevAnimationName !== getAnimationName(ctx.styles),
},
actions: {
setInitial(ctx) {
ctx.initial = true
},
invokeOnExitComplete(ctx) {
ctx.onExitComplete?.()
},
Expand All @@ -90,19 +84,33 @@ export function machine(ctx: Partial<UserDefinedContext>) {
const win = evt.node.ownerDocument.defaultView || window
ctx.styles = ref(win.getComputedStyle(evt.node))
},
raisePresenceChange(_ctx, _evt, { send }) {
send("PRESENCE.CHANGED")
},
setPrevPresent(ctx) {
ctx.prevPresent = ctx.present
syncPresence(ctx, _evt, { send }) {
if (ctx.rafId) {
cancelAnimationFrame(ctx.rafId)
}

if (ctx.present) {
send({ type: "MOUNT", src: "presence.changed" })
return
}

ctx.rafId = requestAnimationFrame(() => {
const animationName = getAnimationName(ctx.styles)
ctx.unmountAnimationName = animationName
if (animationName === "none" || animationName === ctx.prevAnimationName || ctx.styles?.display === "none") {
send({ type: "UNMOUNT", src: "presence.changed" })
} else {
send({ type: "UNMOUNT.SUSPEND" })
}
})
},
setPrevAnimationName(ctx) {
requestAnimationFrame(() => {
ctx.prevAnimationName = getAnimationName(ctx.styles)
})
},
clearPrevAnimationName(ctx) {
ctx.prevAnimationName = ""
ctx.prevAnimationName = null
},
},
activities: {
Expand All @@ -117,16 +125,16 @@ export function machine(ctx: Partial<UserDefinedContext>) {
}

const onEnd = (event: AnimationEvent) => {
const currentAnimationName = getAnimationName(ctx.styles)
const isCurrentAnimation = currentAnimationName.includes(event.animationName)
if (event.target === node && isCurrentAnimation) {
send("ANIMATION.END")
const animationName = getAnimationName(ctx.styles)
if (event.target === node && animationName === ctx.unmountAnimationName) {
send({ type: "UNMOUNT", src: "animationend" })
}
}

node.addEventListener("animationstart", onStart)
node.addEventListener("animationcancel", onEnd)
node.addEventListener("animationend", onEnd)

return () => {
node.removeEventListener("animationstart", onStart)
node.removeEventListener("animationcancel", onEnd)
Expand Down
14 changes: 12 additions & 2 deletions packages/machines/presence/src/presence.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ interface PublicContext {
}

interface PrivateContext {
initial: boolean
node: HTMLElement | null
styles: CSSStyleDeclaration | null
prevPresent?: boolean
prevAnimationName: string
unmountAnimationName: string | null
prevAnimationName: string | null
rafId?: number
}

export interface UserDefinedContext extends PublicContext {}
Expand All @@ -39,6 +41,10 @@ export type Send = S.Send<S.AnyEventObject>
* -----------------------------------------------------------------------------*/

export interface MachineApi {
/**
* Whether the animation should be skipped.
*/
skip: boolean
/**
* Whether the node is present in the DOM.
*/
Expand All @@ -47,4 +53,8 @@ export interface MachineApi {
* Function to set the node (as early as possible)
*/
setNode(node: HTMLElement | null): void
/**
* Function to programmatically unmount the node
*/
unmount(): void
}
Loading

0 comments on commit 159b9f5

Please sign in to comment.