UNPKG

bits-ui

Version:

The headless components for Svelte.

361 lines (360 loc) 11.7 kB
import { attachRef, boxWith, onDestroyEffect, } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { createBitsAttrs, boolToStr, getDataOpenClosed, boolToEmptyStrOrUndef, getDataTransitionAttrs, } from "../../internal/attrs.js"; import { kbd } from "../../internal/kbd.js"; import { PresenceManager } from "../../internal/presence-manager.svelte.js"; const dialogAttrs = createBitsAttrs({ component: "dialog", parts: ["content", "trigger", "overlay", "title", "description", "close", "cancel", "action"], }); const DialogRootContext = new Context("Dialog.Root | AlertDialog.Root"); export class DialogRootState { static create(opts) { const parent = DialogRootContext.getOr(null); return DialogRootContext.set(new DialogRootState(opts, parent)); } opts; triggerNode = $state(null); contentNode = $state(null); overlayNode = $state(null); descriptionNode = $state(null); contentId = $state(undefined); titleId = $state(undefined); triggerId = $state(undefined); descriptionId = $state(undefined); cancelNode = $state(null); nestedOpenCount = $state(0); depth; parent; contentPresence; overlayPresence; constructor(opts, parent) { this.opts = opts; this.parent = parent; this.depth = parent ? parent.depth + 1 : 0; this.handleOpen = this.handleOpen.bind(this); this.handleClose = this.handleClose.bind(this); this.contentPresence = new PresenceManager({ ref: boxWith(() => this.contentNode), open: this.opts.open, enabled: true, onComplete: () => { this.opts.onOpenChangeComplete.current(this.opts.open.current); }, }); this.overlayPresence = new PresenceManager({ ref: boxWith(() => this.overlayNode), open: this.opts.open, enabled: true, }); watch(() => this.opts.open.current, (isOpen) => { if (!this.parent) return; if (isOpen) { this.parent.incrementNested(); } else { this.parent.decrementNested(); } }, { lazy: true }); onDestroyEffect(() => { if (this.opts.open.current) { this.parent?.decrementNested(); } }); } handleOpen() { if (this.opts.open.current) return; this.opts.open.current = true; } handleClose() { if (!this.opts.open.current) return; this.opts.open.current = false; } getBitsAttr = (part) => { return dialogAttrs.getAttr(part, this.opts.variant.current); }; incrementNested() { this.nestedOpenCount++; this.parent?.incrementNested(); } decrementNested() { if (this.nestedOpenCount === 0) return; this.nestedOpenCount--; this.parent?.decrementNested(); } sharedProps = $derived.by(() => ({ "data-state": getDataOpenClosed(this.opts.open.current), })); } export class DialogTriggerState { static create(opts) { return new DialogTriggerState(opts, DialogRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref, (v) => { this.root.triggerNode = v; this.root.triggerId = v?.id; }); this.onclick = this.onclick.bind(this); this.onkeydown = this.onkeydown.bind(this); } onclick(e) { if (this.opts.disabled.current) return; if (e.button > 0) return; this.root.handleOpen(); } onkeydown(e) { if (this.opts.disabled.current) return; if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.root.handleOpen(); } } props = $derived.by(() => ({ id: this.opts.id.current, "aria-haspopup": "dialog", "aria-expanded": boolToStr(this.root.opts.open.current), "aria-controls": this.root.contentId, [this.root.getBitsAttr("trigger")]: "", onkeydown: this.onkeydown, onclick: this.onclick, disabled: this.opts.disabled.current ? true : undefined, ...this.root.sharedProps, ...this.attachment, })); } export class DialogCloseState { static create(opts) { return new DialogCloseState(opts, DialogRootContext.get()); } opts; root; attachment; 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.opts.disabled.current) return; if (e.button > 0) return; this.root.handleClose(); } onkeydown(e) { if (this.opts.disabled.current) return; if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.root.handleClose(); } } props = $derived.by(() => ({ id: this.opts.id.current, [this.root.getBitsAttr(this.opts.variant.current)]: "", onclick: this.onclick, onkeydown: this.onkeydown, disabled: this.opts.disabled.current ? true : undefined, tabindex: 0, ...this.root.sharedProps, ...this.attachment, })); } export class DialogActionState { static create(opts) { return new DialogActionState(opts, DialogRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, [this.root.getBitsAttr("action")]: "", ...this.root.sharedProps, ...this.attachment, })); } export class DialogTitleState { static create(opts) { return new DialogTitleState(opts, DialogRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.root.titleId = this.opts.id.current; this.attachment = attachRef(this.opts.ref); watch.pre(() => this.opts.id.current, (id) => { this.root.titleId = id; }); } props = $derived.by(() => ({ id: this.opts.id.current, role: "heading", "aria-level": this.opts.level.current, [this.root.getBitsAttr("title")]: "", ...this.root.sharedProps, ...this.attachment, })); } export class DialogDescriptionState { static create(opts) { return new DialogDescriptionState(opts, DialogRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.root.descriptionId = this.opts.id.current; this.attachment = attachRef(this.opts.ref, (v) => { this.root.descriptionNode = v; }); watch.pre(() => this.opts.id.current, (id) => { this.root.descriptionId = id; }); } props = $derived.by(() => ({ id: this.opts.id.current, [this.root.getBitsAttr("description")]: "", ...this.root.sharedProps, ...this.attachment, })); } export class DialogContentState { static create(opts) { return new DialogContentState(opts, DialogRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref, (v) => { this.root.contentNode = v; this.root.contentId = v?.id; }); } snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); props = $derived.by(() => ({ id: this.opts.id.current, role: this.root.opts.variant.current === "alert-dialog" ? "alertdialog" : "dialog", "aria-modal": "true", "aria-describedby": this.root.descriptionId, "aria-labelledby": this.root.titleId, [this.root.getBitsAttr("content")]: "", style: { pointerEvents: "auto", outline: this.root.opts.variant.current === "alert-dialog" ? "none" : undefined, "--bits-dialog-depth": this.root.depth, "--bits-dialog-nested-count": this.root.nestedOpenCount, // CSS containment isolates style/layout calculations from the rest of the page, // improving performance when there's a large DOM behind the dialog. // Paint is omitted so tooltips/selects can render outside dialog bounds. contain: "layout style", }, tabindex: this.root.opts.variant.current === "alert-dialog" ? -1 : undefined, "data-nested-open": boolToEmptyStrOrUndef(this.root.nestedOpenCount > 0), "data-nested": boolToEmptyStrOrUndef(this.root.parent !== null), ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), ...this.root.sharedProps, ...this.attachment, })); get shouldRender() { return this.root.contentPresence.shouldRender; } } export class DialogOverlayState { static create(opts) { return new DialogOverlayState(opts, DialogRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref, (v) => (this.root.overlayNode = v)); } snippetProps = $derived.by(() => ({ open: this.root.opts.open.current })); props = $derived.by(() => ({ id: this.opts.id.current, [this.root.getBitsAttr("overlay")]: "", style: { pointerEvents: "auto", "--bits-dialog-depth": this.root.depth, "--bits-dialog-nested-count": this.root.nestedOpenCount, }, "data-nested-open": boolToEmptyStrOrUndef(this.root.nestedOpenCount > 0), "data-nested": boolToEmptyStrOrUndef(this.root.parent !== null), ...getDataTransitionAttrs(this.root.overlayPresence.transitionStatus), ...this.root.sharedProps, ...this.attachment, })); get shouldRender() { return this.root.overlayPresence.shouldRender; } } export class AlertDialogCancelState { static create(opts) { return new AlertDialogCancelState(opts, DialogRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref, (v) => (this.root.cancelNode = v)); this.onclick = this.onclick.bind(this); this.onkeydown = this.onkeydown.bind(this); } onclick(e) { if (this.opts.disabled.current) return; if (e.button > 0) return; this.root.handleClose(); } onkeydown(e) { if (this.opts.disabled.current) return; if (e.key === kbd.SPACE || e.key === kbd.ENTER) { e.preventDefault(); this.root.handleClose(); } } props = $derived.by(() => ({ id: this.opts.id.current, [this.root.getBitsAttr("cancel")]: "", onclick: this.onclick, onkeydown: this.onkeydown, tabindex: 0, ...this.root.sharedProps, ...this.attachment, })); }