@zag-js/collapsible
Version:
Core logic for the collapsible widget implemented as a state machine
323 lines (318 loc) • 9.37 kB
JavaScript
import { createAnatomy } from '@zag-js/anatomy';
import { raf, getComputedStyle, setStyle, getEventTarget, nextTick, dataAttr } from '@zag-js/dom-query';
import { createMachine } from '@zag-js/core';
import { createProps } from '@zag-js/types';
import { createSplitProps } from '@zag-js/utils';
// src/collapsible.anatomy.ts
var anatomy = createAnatomy("collapsible").parts("root", "trigger", "content", "indicator");
var parts = anatomy.build();
// src/collapsible.dom.ts
var getRootId = (ctx) => ctx.ids?.root ?? `collapsible:${ctx.id}`;
var getContentId = (ctx) => ctx.ids?.content ?? `collapsible:${ctx.id}:content`;
var getTriggerId = (ctx) => ctx.ids?.trigger ?? `collapsible:${ctx.id}:trigger`;
var getContentEl = (ctx) => ctx.getById(getContentId(ctx));
// src/collapsible.connect.ts
function connect(service, normalize) {
const { state, send, context, scope, prop } = service;
const visible = state.matches("open") || state.matches("closing");
const open = state.matches("open");
const { width, height } = context.get("size");
const disabled = !!prop("disabled");
const skip = !context.get("initial") && open;
const dir = "ltr";
return {
disabled,
visible,
open,
measureSize() {
send({ type: "size.measure" });
},
setOpen(nextOpen) {
const open2 = state.matches("open");
if (open2 === nextOpen) return;
send({ type: nextOpen ? "open" : "close" });
},
getRootProps() {
return normalize.element({
...parts.root.attrs,
"data-state": open ? "open" : "closed",
dir,
id: getRootId(scope)
});
},
getContentProps() {
return normalize.element({
...parts.content.attrs,
"data-collapsible": "",
"data-state": skip ? void 0 : open ? "open" : "closed",
id: getContentId(scope),
"data-disabled": dataAttr(disabled),
hidden: !visible,
style: {
"--height": height != null ? `${height}px` : void 0,
"--width": width != null ? `${width}px` : void 0
}
});
},
getTriggerProps() {
return normalize.element({
...parts.trigger.attrs,
id: getTriggerId(scope),
dir,
type: "button",
"data-state": open ? "open" : "closed",
"data-disabled": dataAttr(disabled),
"aria-controls": getContentId(scope),
"aria-expanded": visible || false,
onClick(event) {
if (event.defaultPrevented) return;
if (disabled) return;
send({ type: open ? "close" : "open" });
}
});
},
getIndicatorProps() {
return normalize.element({
...parts.indicator.attrs,
dir,
"data-state": open ? "open" : "closed",
"data-disabled": dataAttr(disabled)
});
}
};
}
var machine = createMachine({
initialState({ prop }) {
const open = prop("open") || prop("defaultOpen");
return open ? "open" : "closed";
},
context({ bindable }) {
return {
size: bindable(() => ({
defaultValue: { height: 0, width: 0 },
sync: true
})),
initial: bindable(() => ({
defaultValue: false
}))
};
},
refs() {
return {
cleanup: void 0,
stylesRef: void 0
};
},
watch({ track, prop, action }) {
track([() => prop("open")], () => {
action(["setInitial", "computeSize", "toggleVisibility"]);
});
},
exit: ["clearInitial", "cleanupNode"],
states: {
closed: {
on: {
"controlled.open": {
target: "open"
},
open: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["setInitial", "computeSize", "invokeOnOpen"]
}
]
}
},
closing: {
effects: ["trackExitAnimation"],
on: {
"controlled.close": {
target: "closed"
},
"controlled.open": {
target: "open"
},
open: [
{
guard: "isOpenControlled",
actions: ["invokeOnOpen"]
},
{
target: "open",
actions: ["setInitial", "invokeOnOpen"]
}
],
close: [
{
guard: "isOpenControlled",
actions: ["invokeOnExitComplete"]
},
{
target: "closed",
actions: ["setInitial", "computeSize", "invokeOnExitComplete"]
}
],
"animation.end": {
target: "closed",
actions: ["invokeOnExitComplete", "clearInitial"]
}
}
},
open: {
effects: ["trackEnterAnimation"],
on: {
"controlled.close": {
target: "closing"
},
close: [
{
guard: "isOpenControlled",
actions: ["invokeOnClose"]
},
{
target: "closing",
actions: ["setInitial", "computeSize", "invokeOnClose"]
}
],
"size.measure": {
actions: ["measureSize"]
},
"animation.end": {
actions: ["clearInitial"]
}
}
}
},
implementations: {
guards: {
isOpenControlled: ({ prop }) => prop("open") != void 0
},
effects: {
trackEnterAnimation: ({ send, scope }) => {
let cleanup;
const rafCleanup = raf(() => {
const contentEl = getContentEl(scope);
if (!contentEl) return;
const animationName = getComputedStyle(contentEl).animationName;
const hasNoAnimation = !animationName || animationName === "none";
if (hasNoAnimation) {
send({ type: "animation.end" });
return;
}
const onEnd = (event) => {
const target = getEventTarget(event);
if (target === contentEl) {
send({ type: "animation.end" });
}
};
contentEl.addEventListener("animationend", onEnd);
cleanup = () => {
contentEl.removeEventListener("animationend", onEnd);
};
});
return () => {
rafCleanup();
cleanup?.();
};
},
trackExitAnimation: ({ send, scope }) => {
let cleanup;
const rafCleanup = raf(() => {
const contentEl = getContentEl(scope);
if (!contentEl) return;
const animationName = getComputedStyle(contentEl).animationName;
const hasNoAnimation = !animationName || animationName === "none";
if (hasNoAnimation) {
send({ type: "animation.end" });
return;
}
const onEnd = (event) => {
const target = getEventTarget(event);
if (target === contentEl) {
send({ type: "animation.end" });
}
};
contentEl.addEventListener("animationend", onEnd);
const restoreStyles = setStyle(contentEl, {
animationFillMode: "forwards"
});
cleanup = () => {
contentEl.removeEventListener("animationend", onEnd);
nextTick(() => restoreStyles());
};
});
return () => {
rafCleanup();
cleanup?.();
};
}
},
actions: {
setInitial: ({ context, flush }) => {
flush(() => {
context.set("initial", true);
});
},
clearInitial: ({ context }) => {
context.set("initial", false);
},
cleanupNode: ({ refs }) => {
refs.set("stylesRef", null);
},
measureSize: ({ context, scope }) => {
const contentEl = getContentEl(scope);
if (!contentEl) return;
const { height, width } = contentEl.getBoundingClientRect();
context.set("size", { height, width });
},
computeSize: ({ refs, scope, context }) => {
refs.get("cleanup")?.();
const rafCleanup = raf(() => {
const contentEl = getContentEl(scope);
if (!contentEl) return;
const hidden = contentEl.hidden;
contentEl.style.animationName = "none";
contentEl.style.animationDuration = "0s";
contentEl.hidden = false;
const rect = contentEl.getBoundingClientRect();
context.set("size", { height: rect.height, width: rect.width });
if (context.get("initial")) {
contentEl.style.animationName = "";
contentEl.style.animationDuration = "";
}
contentEl.hidden = hidden;
});
refs.set("cleanup", rafCleanup);
},
invokeOnOpen: ({ prop }) => {
prop("onOpenChange")?.({ open: true });
},
invokeOnClose: ({ prop }) => {
prop("onOpenChange")?.({ open: false });
},
invokeOnExitComplete: ({ prop }) => {
prop("onExitComplete")?.();
},
toggleVisibility: ({ prop, send }) => {
send({ type: prop("open") ? "controlled.open" : "controlled.close" });
}
}
}
});
var props = createProps()([
"dir",
"disabled",
"getRootNode",
"id",
"ids",
"onExitComplete",
"onOpenChange",
"defaultOpen",
"open"
]);
var splitProps = createSplitProps(props);
export { anatomy, connect, machine, props, splitProps };