UNPKG

bits-ui

Version:

The headless components for Svelte.

243 lines (242 loc) 8.86 kB
import { afterTick, attachRef, } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { getAriaDisabled, getAriaExpanded, getDataDisabled, getDataOpenClosed, getDataOrientation, } from "../../internal/attrs.js"; import { kbd } from "../../internal/kbd.js"; import { createBitsAttrs } from "../../internal/attrs.js"; import { RovingFocusGroup } from "../../internal/roving-focus-group.js"; const accordionAttrs = createBitsAttrs({ component: "accordion", parts: ["root", "trigger", "content", "item", "header"], }); const AccordionRootContext = new Context("Accordion.Root"); const AccordionItemContext = new Context("Accordion.Item"); class AccordionBaseState { opts; rovingFocusGroup; attachment; constructor(opts) { this.opts = opts; this.rovingFocusGroup = new RovingFocusGroup({ rootNode: this.opts.ref, candidateAttr: accordionAttrs.trigger, loop: this.opts.loop, orientation: this.opts.orientation, }); this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.opts.orientation.current), "data-disabled": getDataDisabled(this.opts.disabled.current), [accordionAttrs.root]: "", ...this.attachment, })); } class AccordionSingleState extends AccordionBaseState { opts; isMulti = false; constructor(opts) { super(opts); this.opts = opts; this.includesItem = this.includesItem.bind(this); this.toggleItem = this.toggleItem.bind(this); } includesItem(item) { return this.opts.value.current === item; } toggleItem(item) { this.opts.value.current = this.includesItem(item) ? "" : item; } } class AccordionMultiState extends AccordionBaseState { #value; isMulti = true; constructor(props) { super(props); this.#value = props.value; this.includesItem = this.includesItem.bind(this); this.toggleItem = this.toggleItem.bind(this); } includesItem(item) { return this.#value.current.includes(item); } toggleItem(item) { this.#value.current = this.includesItem(item) ? this.#value.current.filter((v) => v !== item) : [...this.#value.current, item]; } } export class AccordionRootState { static create(props) { const { type, ...rest } = props; const rootState = type === "single" ? new AccordionSingleState(rest) : new AccordionMultiState(rest); return AccordionRootContext.set(rootState); } } export class AccordionItemState { static create(props) { return AccordionItemContext.set(new AccordionItemState({ ...props, rootState: AccordionRootContext.get() })); } opts; root; isActive = $derived.by(() => this.root.includesItem(this.opts.value.current)); isDisabled = $derived.by(() => this.opts.disabled.current || this.root.opts.disabled.current); attachment; constructor(opts) { this.opts = opts; this.root = opts.rootState; this.updateValue = this.updateValue.bind(this); this.attachment = attachRef(this.opts.ref); } updateValue() { this.root.toggleItem(this.opts.value.current); } props = $derived.by(() => ({ id: this.opts.id.current, "data-state": getDataOpenClosed(this.isActive), "data-disabled": getDataDisabled(this.isDisabled), "data-orientation": getDataOrientation(this.root.opts.orientation.current), [accordionAttrs.item]: "", ...this.attachment, })); } export class AccordionTriggerState { opts; itemState; #root; #isDisabled = $derived.by(() => this.opts.disabled.current || this.itemState.opts.disabled.current || this.#root.opts.disabled.current); attachment; constructor(opts, itemState) { this.opts = opts; this.itemState = itemState; this.#root = itemState.root; this.onclick = this.onclick.bind(this); this.onkeydown = this.onkeydown.bind(this); this.attachment = attachRef(this.opts.ref); } static create(props) { return new AccordionTriggerState(props, AccordionItemContext.get()); } onclick(e) { if (this.#isDisabled || e.button !== 0) { e.preventDefault(); return; } this.itemState.updateValue(); } onkeydown(e) { if (this.#isDisabled) return; if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.itemState.updateValue(); return; } this.#root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); } props = $derived.by(() => ({ id: this.opts.id.current, disabled: this.#isDisabled, "aria-expanded": getAriaExpanded(this.itemState.isActive), "aria-disabled": getAriaDisabled(this.#isDisabled), "data-disabled": getDataDisabled(this.#isDisabled), "data-state": getDataOpenClosed(this.itemState.isActive), "data-orientation": getDataOrientation(this.#root.opts.orientation.current), [accordionAttrs.trigger]: "", tabindex: 0, onclick: this.onclick, onkeydown: this.onkeydown, ...this.attachment, })); } export class AccordionContentState { opts; item; attachment; #originalStyles = undefined; #isMountAnimationPrevented = false; #dimensions = $state({ width: 0, height: 0 }); open = $derived.by(() => this.opts.forceMount.current || this.item.isActive); constructor(opts, item) { this.opts = opts; this.item = item; this.#isMountAnimationPrevented = this.item.isActive; this.attachment = attachRef(this.opts.ref); // Prevent mount animations on initial render $effect(() => { const rAF = requestAnimationFrame(() => { this.#isMountAnimationPrevented = false; }); return () => cancelAnimationFrame(rAF); }); // Handle dimension updates watch([() => this.open, () => this.opts.ref.current], this.#updateDimensions); } static create(props) { return new AccordionContentState(props, AccordionItemContext.get()); } #updateDimensions = ([_, node]) => { if (!node) return; afterTick(() => { const element = this.opts.ref.current; if (!element) return; // store original styles on first run this.#originalStyles ??= { transitionDuration: element.style.transitionDuration, animationName: element.style.animationName, }; // temporarily disable animations for measurement element.style.transitionDuration = "0s"; element.style.animationName = "none"; const rect = element.getBoundingClientRect(); this.#dimensions = { width: rect.width, height: rect.height }; // restore animations if not initial mount if (!this.#isMountAnimationPrevented && this.#originalStyles) { element.style.transitionDuration = this.#originalStyles.transitionDuration; element.style.animationName = this.#originalStyles.animationName; } }); }; snippetProps = $derived.by(() => ({ open: this.item.isActive })); props = $derived.by(() => ({ id: this.opts.id.current, "data-state": getDataOpenClosed(this.item.isActive), "data-disabled": getDataDisabled(this.item.isDisabled), "data-orientation": getDataOrientation(this.item.root.opts.orientation.current), [accordionAttrs.content]: "", style: { "--bits-accordion-content-height": `${this.#dimensions.height}px`, "--bits-accordion-content-width": `${this.#dimensions.width}px`, }, ...this.attachment, })); } export class AccordionHeaderState { opts; item; attachment; constructor(opts, item) { this.opts = opts; this.item = item; this.attachment = attachRef(this.opts.ref); } static create(props) { return new AccordionHeaderState(props, AccordionItemContext.get()); } props = $derived.by(() => ({ id: this.opts.id.current, role: "heading", "aria-level": this.opts.level.current, "data-heading-level": this.opts.level.current, "data-state": getDataOpenClosed(this.item.isActive), "data-orientation": getDataOrientation(this.item.root.opts.orientation.current), [accordionAttrs.header]: "", ...this.attachment, })); }