359 lines
11 KiB
JavaScript
359 lines
11 KiB
JavaScript
import { attachRef, boxWith, onDestroyEffect, } from "svelte-toolbelt";
|
|
import { Context, watch } from "runed";
|
|
import { createBitsAttrs, boolToStr, getDataOpenClosed, boolToEmptyStrOrUndef, } 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),
|
|
...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),
|
|
...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,
|
|
}));
|
|
}
|