UNPKG

bits-ui

Version:

The headless components for Svelte.

161 lines (160 loc) 5.96 kB
import { afterTick, attachRef, box, } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { createBitsAttrs, getAriaExpanded, getDataDisabled, getDataOpenClosed, } from "../../internal/attrs.js"; import { kbd } from "../../internal/kbd.js"; import { OpenChangeComplete } from "../../internal/open-change-complete.js"; const collapsibleAttrs = createBitsAttrs({ component: "collapsible", parts: ["root", "content", "trigger"], }); const CollapsibleRootContext = new Context("Collapsible.Root"); export class CollapsibleRootState { static create(opts) { return CollapsibleRootContext.set(new CollapsibleRootState(opts)); } opts; attachment; contentNode = $state(null); contentId = $state(undefined); constructor(opts) { this.opts = opts; this.toggleOpen = this.toggleOpen.bind(this); this.attachment = attachRef(this.opts.ref); new OpenChangeComplete({ ref: box.with(() => this.contentNode), open: this.opts.open, onComplete: () => { this.opts.onOpenChangeComplete.current(this.opts.open.current); }, }); } toggleOpen() { this.opts.open.current = !this.opts.open.current; } props = $derived.by(() => ({ id: this.opts.id.current, "data-state": getDataOpenClosed(this.opts.open.current), "data-disabled": getDataDisabled(this.opts.disabled.current), [collapsibleAttrs.root]: "", ...this.attachment, })); } export class CollapsibleContentState { static create(opts) { return new CollapsibleContentState(opts, CollapsibleRootContext.get()); } opts; root; attachment; present = $derived.by(() => this.opts.forceMount.current || this.root.opts.open.current); #originalStyles; #isMountAnimationPrevented = $state(false); #width = $state(0); #height = $state(0); constructor(opts, root) { this.opts = opts; this.root = root; this.#isMountAnimationPrevented = root.opts.open.current; this.root.contentId = this.opts.id.current; this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v)); watch.pre(() => this.opts.id.current, (id) => { this.root.contentId = id; }); $effect.pre(() => { const rAF = requestAnimationFrame(() => { this.#isMountAnimationPrevented = false; }); return () => { cancelAnimationFrame(rAF); }; }); watch([() => this.opts.ref.current, () => this.present], ([node]) => { if (!node) return; afterTick(() => { if (!this.opts.ref.current) return; // get the dimensions of the element this.#originalStyles = this.#originalStyles || { transitionDuration: node.style.transitionDuration, animationName: node.style.animationName, }; // block any animations/transitions so the element renders at full dimensions node.style.transitionDuration = "0s"; node.style.animationName = "none"; const rect = node.getBoundingClientRect(); this.#height = rect.height; this.#width = rect.width; // unblock any animations/transitions that were originally set if not the initial render if (!this.#isMountAnimationPrevented) { const { animationName, transitionDuration } = this.#originalStyles; node.style.transitionDuration = transitionDuration; node.style.animationName = animationName; } }); }); } snippetProps = $derived.by(() => ({ open: this.root.opts.open.current, })); props = $derived.by(() => ({ id: this.opts.id.current, style: { "--bits-collapsible-content-height": this.#height ? `${this.#height}px` : undefined, "--bits-collapsible-content-width": this.#width ? `${this.#width}px` : undefined, }, "data-state": getDataOpenClosed(this.root.opts.open.current), "data-disabled": getDataDisabled(this.root.opts.disabled.current), [collapsibleAttrs.content]: "", ...this.attachment, })); } export class CollapsibleTriggerState { static create(opts) { return new CollapsibleTriggerState(opts, CollapsibleRootContext.get()); } opts; root; attachment; #isDisabled = $derived.by(() => this.opts.disabled.current || this.root.opts.disabled.current); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); this.onclick = this.onclick.bind(this); this.onkeydown = this.onkeydown.bind(this); } onclick(e) { if (this.#isDisabled) return; if (e.button !== 0) return e.preventDefault(); this.root.toggleOpen(); } onkeydown(e) { if (this.#isDisabled) return; if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.root.toggleOpen(); } } props = $derived.by(() => ({ id: this.opts.id.current, type: "button", disabled: this.#isDisabled, "aria-controls": this.root.contentId, "aria-expanded": getAriaExpanded(this.root.opts.open.current), "data-state": getDataOpenClosed(this.root.opts.open.current), "data-disabled": getDataDisabled(this.#isDisabled), [collapsibleAttrs.trigger]: "", // onclick: this.onclick, onkeydown: this.onkeydown, ...this.attachment, })); }