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