UNPKG

bits-ui

Version:

The headless components for Svelte.

191 lines (190 loc) 6.73 kB
import { SvelteMap } from "svelte/reactivity"; import { attachRef } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { createBitsAttrs, getAriaOrientation, getAriaSelected, getDataDisabled, getDataOrientation, getDisabled, getHidden, } from "../../internal/attrs.js"; import { kbd } from "../../internal/kbd.js"; import { RovingFocusGroup } from "../../internal/roving-focus-group.js"; const tabsAttrs = createBitsAttrs({ component: "tabs", parts: ["root", "list", "trigger", "content"], }); const TabsRootContext = new Context("Tabs.Root"); export class TabsRootState { static create(opts) { return TabsRootContext.set(new TabsRootState(opts)); } opts; attachment; rovingFocusGroup; triggerIds = $state([]); // holds the trigger ID for each value to associate it with the content valueToTriggerId = new SvelteMap(); // holds the content ID for each value to associate it with the trigger valueToContentId = new SvelteMap(); constructor(opts) { this.opts = opts; this.attachment = attachRef(opts.ref); this.rovingFocusGroup = new RovingFocusGroup({ candidateAttr: tabsAttrs.trigger, rootNode: this.opts.ref, loop: this.opts.loop, orientation: this.opts.orientation, }); } registerTrigger(id, value) { this.triggerIds.push(id); this.valueToTriggerId.set(value, id); // returns the deregister function return () => { this.triggerIds = this.triggerIds.filter((triggerId) => triggerId !== id); this.valueToTriggerId.delete(value); }; } registerContent(id, value) { this.valueToContentId.set(value, id); // returns the deregister function return () => { this.valueToContentId.delete(value); }; } setValue(v) { this.opts.value.current = v; } props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.opts.orientation.current), [tabsAttrs.root]: "", ...this.attachment, })); } export class TabsListState { static create(opts) { return new TabsListState(opts, TabsRootContext.get()); } opts; root; attachment; #isDisabled = $derived.by(() => this.root.opts.disabled.current); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, role: "tablist", "aria-orientation": getAriaOrientation(this.root.opts.orientation.current), "data-orientation": getDataOrientation(this.root.opts.orientation.current), [tabsAttrs.list]: "", "data-disabled": getDataDisabled(this.#isDisabled), ...this.attachment, })); } export class TabsTriggerState { static create(opts) { return new TabsTriggerState(opts, TabsRootContext.get()); } opts; root; attachment; #tabIndex = $state(0); #isActive = $derived.by(() => this.root.opts.value.current === this.opts.value.current); #isDisabled = $derived.by(() => this.opts.disabled.current || this.root.opts.disabled.current); #ariaControls = $derived.by(() => this.root.valueToContentId.get(this.opts.value.current)); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); watch([() => this.opts.id.current, () => this.opts.value.current], ([id, value]) => { return this.root.registerTrigger(id, value); }); $effect(() => { this.root.triggerIds.length; if (this.#isActive || !this.root.opts.value.current) { this.#tabIndex = 0; } else { this.#tabIndex = -1; } }); this.onfocus = this.onfocus.bind(this); this.onclick = this.onclick.bind(this); this.onkeydown = this.onkeydown.bind(this); } #activate() { if (this.root.opts.value.current === this.opts.value.current) return; this.root.setValue(this.opts.value.current); } onfocus(_) { if (this.root.opts.activationMode.current !== "automatic" || this.#isDisabled) return; this.#activate(); } onclick(_) { if (this.#isDisabled) return; this.#activate(); } onkeydown(e) { if (this.#isDisabled) return; if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.#activate(); return; } this.root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); } props = $derived.by(() => ({ id: this.opts.id.current, role: "tab", "data-state": getTabDataState(this.#isActive), "data-value": this.opts.value.current, "data-orientation": getDataOrientation(this.root.opts.orientation.current), "data-disabled": getDataDisabled(this.#isDisabled), "aria-selected": getAriaSelected(this.#isActive), "aria-controls": this.#ariaControls, [tabsAttrs.trigger]: "", disabled: getDisabled(this.#isDisabled), tabindex: this.#tabIndex, // onclick: this.onclick, onfocus: this.onfocus, onkeydown: this.onkeydown, ...this.attachment, })); } export class TabsContentState { static create(opts) { return new TabsContentState(opts, TabsRootContext.get()); } opts; root; attachment; #isActive = $derived.by(() => this.root.opts.value.current === this.opts.value.current); #ariaLabelledBy = $derived.by(() => this.root.valueToTriggerId.get(this.opts.value.current)); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); watch([() => this.opts.id.current, () => this.opts.value.current], ([id, value]) => { return this.root.registerContent(id, value); }); } props = $derived.by(() => ({ id: this.opts.id.current, role: "tabpanel", hidden: getHidden(!this.#isActive), tabindex: 0, "data-value": this.opts.value.current, "data-state": getTabDataState(this.#isActive), "aria-labelledby": this.#ariaLabelledBy, "data-orientation": getDataOrientation(this.root.opts.orientation.current), [tabsAttrs.content]: "", ...this.attachment, })); } function getTabDataState(condition) { return condition ? "active" : "inactive"; }