bits-ui
Version:
The headless components for Svelte.
161 lines (160 loc) • 5.96 kB
JavaScript
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,
}));
}