Packages
noora
0.83.2
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
js/Collapsible.js
import * as collapsible from "@zag-js/collapsible";
import {
getBooleanOption,
normalizeProps,
renderPart,
spreadProps,
} from "./util.js";
import { Component } from "./component.js";
import { VanillaMachine } from "./machine.js";
class Collapsible extends Component {
initMachine(context) {
return new VanillaMachine(collapsible.machine, context);
}
initApi() {
return collapsible.connect(this.machine.service, normalizeProps);
}
render() {
renderPart(this.el, "root", this.api);
renderPart(this.el, "root:content", this.api);
const root = this.el.querySelector(":scope > [data-part='root']");
if (!root) return;
const content = root.querySelector(":scope > [data-part='content']");
const trigger = [...root.querySelectorAll("[data-part='trigger']")].find(
(t) => !content?.contains(t),
);
if (trigger) spreadProps(trigger, this.api.getTriggerProps());
}
}
export default {
mounted() {
this.persistKey = this.el.dataset.persistKey;
// A label click on the previous page navigates here and replaces this
// group's DOM, so it defers its expand to us via a flag: mount closed and
// animate open on the fresh DOM, where the animation can't be cut off.
this.animateOpen = this.persistKey && this.flagGet();
if (this.animateOpen) this.flagClear();
this.collapsible = new Collapsible(this.el, this.context());
this.collapsible.init();
if (this.animateOpen) {
this.openRaf = requestAnimationFrame(() => {
this.openRaf = requestAnimationFrame(() =>
this.collapsible.api.setOpen(true),
);
});
} else if (this.persistKey && this.storageGet() == null) {
// Seed storage with the initial (path-derived) state so auto-opened
// groups are remembered across navigations too, not only toggled ones.
this.storageSet(this.collapsible.api.open);
}
// The navigable label only opens the group (never collapses it — that's
// the chevron's job), since clicking it also navigates to that page. When
// opening, the animation is handed to the destination (see above) rather
// than run here on the DOM navigation is about to discard.
this.label = this.el.querySelector(
':scope > [data-part="root"] > [data-part="header"] > [data-part="link"]',
);
if (this.label) {
this.onLabelClick = () => {
if (this.collapsible.api.open) return;
if (this.persistKey) {
this.storageSet(true);
this.flagSet();
} else {
this.collapsible.api.setOpen(true);
}
};
this.label.addEventListener("click", this.onLabelClick);
}
},
updated() {
// With persistence the stored client state is authoritative, so the
// server's path-derived `open` must not force a remembered group open or
// closed on navigation.
if (!this.persistKey) {
const shouldBeOpen = getBooleanOption(this.el, "open");
if (shouldBeOpen && !this.collapsible.api.open) {
this.collapsible.api.setOpen(true);
}
}
this.collapsible.render();
},
beforeDestroy() {
cancelAnimationFrame(this.openRaf);
this.label?.removeEventListener("click", this.onLabelClick);
this.collapsible.destroy();
},
storageKey() {
return `noora-collapsible:${this.persistKey}`;
},
storageGet() {
try {
return sessionStorage.getItem(this.storageKey());
} catch {
return null;
}
},
storageSet(open) {
try {
sessionStorage.setItem(this.storageKey(), String(open));
} catch {
// Storage unavailable (private mode / disabled); persistence is a
// best-effort enhancement, so silently skip it.
}
},
flagKey() {
return `noora-collapsible-anim:${this.persistKey}`;
},
flagGet() {
try {
return sessionStorage.getItem(this.flagKey()) != null;
} catch {
return false;
}
},
flagSet() {
try {
sessionStorage.setItem(this.flagKey(), "1");
} catch {
// Best-effort; without storage the group simply opens without animating.
}
},
flagClear() {
try {
sessionStorage.removeItem(this.flagKey());
} catch {
// Ignore — nothing to clear if storage is unavailable.
}
},
context() {
const stored = this.persistKey ? this.storageGet() : null;
let defaultOpen =
stored != null ? stored === "true" : getBooleanOption(this.el, "open");
// Mount closed when we're about to animate open, so the expand has a
// starting point.
if (this.animateOpen) defaultOpen = false;
return {
id: this.el.id,
disabled: getBooleanOption(this.el, "disabled"),
defaultOpen,
onOpenChange: (details) => {
if (this.persistKey) this.storageSet(details.open);
if (this.el.dataset.onOpenChange) {
this.pushEvent(this.el.dataset.onOpenChange, details);
}
},
};
},
};