Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions docs/src/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,122 @@
],
"propsAlt": "export type PinInputProps = {\n /**\n * The value for the Pin Input.\n *\n * When passing a getter, it will be used as source of truth,\n * meaning that the value only changes when the getter returns a new value.\n *\n * Otherwise, if passing a static value, it'll serve as the default value.\n *\n *\n * @default ''\n */\n value?: MaybeGetter<string | undefined>;\n /**\n * Called when the `PinInput` instance tries to change the value.\n */\n onValueChange?: (value: string) => void;\n\n /**\n * The amount of digits in the Pin Input.\n *\n * @default 4\n */\n maxLength?: MaybeGetter<number | undefined>;\n /**\n * An optional placeholder to display when the input is empty.\n *\n * @default '○'\n */\n placeholder?: MaybeGetter<string | undefined>;\n\n /**\n * If `true`, prevents the user from interacting with the input.\n *\n * @default false\n */\n disabled?: MaybeGetter<boolean | undefined>;\n\n /**\n * If the input should be masked like a password.\n *\n * @default false\n */\n mask?: MaybeGetter<boolean | undefined>;\n\n /**\n * What characters the input accepts.\n *\n * @default 'text'\n */\n type?: MaybeGetter<\"alphanumeric\" | \"numeric\" | \"text\" | undefined>;\n};"
},
"NativeDialog": {
"constructorProps": [
{
"name": "open",
"type": "MaybeGetter<boolean> | undefined",
"description": "If the dialog is open.",
"defaultValue": "false",
"optional": true
},
{
"name": "onOpenChange",
"type": "((value: boolean, e?: Event | undefined) => void) | undefined",
"description": "Called when the dialog state changes.",
"optional": true
},
{
"name": "beforeOpenChange",
"type": "((value: boolean, e?: Event | undefined) => boolean) | undefined",
"description": "Called before opening or closing the dialog.",
"optional": true
},
{
"name": "modal",
"type": "MaybeGetter<boolean> | undefined",
"description": "Show the dialog using `showModal` instead of `show`.",
"defaultValue": "true",
"optional": true
},
{
"name": "lightDismiss",
"type": "MaybeGetter<boolean> | undefined",
"description": "Close the dialog whenever a user clicks outside.",
"defaultValue": "true",
"optional": true
},
{
"name": "forceVisible",
"type": "MaybeGetter<boolean> | undefined",
"description": "If the dialog visibility should be controlled by the user.",
"defaultValue": "false",
"optional": true
},
{
"name": "preventScroll",
"type": "MaybeGetter<boolean | HTMLElement> | undefined",
"description": "Disable scrolling on dialog's containing element or on a specific element.",
"defaultValue": "true",
"optional": true
}
],
"methods": [
{
"name": "getTrigger",
"type": "({ mode }?: { mode?: \"toggle\" | \"open\" | \"close\" | undefined }) => {\n readonly \"data-melt-dialog-trigger\": \"\"\n readonly \"aria-haspopup\": \"dialog\"\n readonly onclick: (\n e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement },\n ) => void\n readonly \"data-state\": string\n}",
"description": ""
}
],
"properties": [
{
"name": "modal",
"type": "boolean",
"description": ""
},
{
"name": "forceVisible",
"type": "boolean",
"description": ""
},
{
"name": "lightDismiss",
"type": "boolean",
"description": ""
},
{
"name": "preventScroll",
"type": "boolean | HTMLElement",
"description": ""
},
{
"name": "open",
"type": "boolean",
"description": ""
},
{
"name": "root",
"type": "{\n readonly \"data-melt-dialog-root\": \"\"\n readonly id: string\n readonly onclick: (\n e: MouseEvent & { currentTarget: EventTarget & HTMLDialogElement },\n ) => void\n readonly oncancel: (\n e: Event & { currentTarget: EventTarget & HTMLDialogElement },\n ) => void\n readonly \"data-state\": string\n}",
"description": ""
},
{
"name": "overlay",
"type": "{\n readonly \"data-melt-dialog-overlay\": \"\"\n readonly \"data-state\": string\n}",
"description": ""
},
{
"name": "content",
"type": "{\n readonly \"data-melt-dialog-content\": \"\"\n readonly id: string\n readonly \"data-state\": string\n}",
"description": ""
},
{
"name": "close",
"type": "{\n readonly \"data-melt-dialog-close\": \"\"\n readonly onclick: (\n e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement },\n ) => void\n}",
"description": ""
},
{
"name": "title",
"type": "{ readonly \"data-melt-dialog-title\": \"\" }",
"description": ""
},
{
"name": "description",
"type": "{ readonly \"data-melt-dialog-description\": \"\" }",
"description": ""
}
],
"propsAlt": "export type NativeDialogProps = {\n /**\n * If the dialog is open.\n *\n * @default false\n */\n open?: MaybeGetter<boolean>;\n /**\n * Called when the dialog state changes.\n *\n * @param value New state of the dialog.\n * @param e Triggering event.\n */\n onOpenChange?: (value: boolean, e?: Event) => void;\n /**\n * Called before opening or closing the dialog.\n *\n * @param value New attempted state of the dialog.\n * @param e Triggering event.\n *\n * @returns Truthy to proceed, else will prevent the expected state change.\n */\n beforeOpenChange?: (value: boolean, e?: Event) => boolean;\n /**\n * Show the dialog using `showModal` instead of `show`.\n *\n * @default true\n */\n modal?: MaybeGetter<boolean>;\n /**\n * Close the dialog whenever a user clicks outside.\n *\n * @default true\n */\n lightDismiss?: MaybeGetter<boolean>;\n /**\n * If the dialog visibility should be controlled by the user.\n *\n * @default false\n */\n forceVisible?: MaybeGetter<boolean>;\n /**\n * Disable scrolling on dialog's containing element or on a specific element.\n *\n * @default true\n */\n preventScroll?: MaybeGetter<boolean | HTMLElement>;\n};"
},
"Collapsible": {
"constructorProps": [
{
Expand Down
236 changes: 236 additions & 0 deletions packages/melt/src/lib/builders/NativeDialog.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { Synced } from "$lib/Synced.svelte";
import type { HTMLAttributes, HTMLButtonAttributes, HTMLDialogAttributes } from "svelte/elements";
import { createBuilderMetadata } from "../utils/identifiers";
import type { MaybeGetter } from "$lib/types";
import { extract } from "$lib/utils/extract";

const metadata = createBuilderMetadata("dialog", [
"root",
"overlay",
"content",
"trigger",
"close",
"title",
"description",
]);

export type NativeDialogProps = {
/**
* If the dialog is open.
*
* @default false
*/
open?: MaybeGetter<boolean>;
/**
* Called when the dialog state changes.
*
* @param value New state of the dialog.
* @param e Triggering event.
*/
onOpenChange?: (value: boolean, e?: Event) => void;
/**
* Called before opening or closing the dialog.
*
* @param value New attempted state of the dialog.
* @param e Triggering event.
*
* @returns Truthy to proceed, else will prevent the expected state change.
*/
beforeOpenChange?: (value: boolean, e?: Event) => boolean;
/**
* Show the dialog using `showModal` instead of `show`.
*
* @default true
*/
modal?: MaybeGetter<boolean>;
/**
* Close the dialog whenever a user clicks outside.
*
* @default true
*/
lightDismiss?: MaybeGetter<boolean>;
/**
* If the dialog visibility should be controlled by the user.
*
* @default false
*/
forceVisible?: MaybeGetter<boolean>;
/**
* Disable scrolling on dialog's containing element or on a specific element.
*
* @default true
*/
preventScroll?: MaybeGetter<boolean | HTMLElement>;
};

export class NativeDialog {
#ids = metadata.createIds();

/* Props */
#props!: NativeDialogProps;

/* State */
#open: Synced<boolean>;
readonly modal = $derived(extract(this.#props.modal, true));
readonly forceVisible = $derived(extract(this.#props.forceVisible, false));
readonly lightDismiss = $derived(extract(this.#props.lightDismiss, true));
readonly preventScroll = $derived(extract(this.#props.preventScroll, true));

constructor(props: NativeDialogProps = {}) {
this.#props = props;
this.#open = new Synced({
value: props.open,
onChange: props.onOpenChange,
defaultValue: false,
});
}

get open() {
return this.#open.current;
}

set open(value: boolean) {
this.#setOpen(value);
}

#setOpen(value: boolean, e?: Event) {
if (this.open === value) return;

const stop = this.#props.beforeOpenChange ? !this.#props.beforeOpenChange(value, e) : false;
if (stop) {
if (e && !e?.defaultPrevented) e.preventDefault();
return false;
}

// to do: save previous activeElement ref for focus restoration later.
// if (value && document.activeElement instanceof HTMLElement) {
// this.#prevFocusRef = document.activeElement;
// this.#prevFocusVisible = this.#prevFocusRef?.matches(':focus-visible') ?? false;
// }

this.#open.current = value;

if (!value && !this.forceVisible) {
const el = document.getElementById(this.#ids.root);
if (el instanceof HTMLDialogElement) {
// to do: figure out how to handle/pass returnValue
el.close()
}
}

// to do: implement scroll locking util.
// if (this.#props.preventScroll) {
// this.#scrollLock.active = open;
// }

// to do: restore focus.
// if (!this.#open.current) {
// if (this.#prevFocusRef) {
// this.#prevFocusRef.focus(
// // @ts-expect-error: experimental with poor support (https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus)
// { focusVisible: this.#prevFocusVisible },
// );
// }
// this.#prevFocusRef = undefined;
// this.#prevFocusVisible = false;
// }

this.#props.onOpenChange?.(value, e);
return true;
}

get #sharedAttrs() {
return { "data-state": this.open ? "open" : "closed" };
}

get root() {
$effect(() => {
const el = document.getElementById(this.#ids.root);
if (!(el instanceof HTMLDialogElement)) {
return;
}
if (this.open || this.forceVisible) {
if (this.modal) {
el.showModal();
} else {
el.show();
}
}
});
return {
...this.#sharedAttrs,
[metadata.dataAttrs.root]: "",
id: this.#ids.root,
onclick: (e) => {
const contentEl = document.getElementById(this.#ids.content);
if (this.lightDismiss) {
if (e.target instanceof Node && contentEl?.contains(e.target)) {
return;
}
this.#setOpen(false, e);
}
},
oncancel: (e) => {
e.preventDefault();
if (this.lightDismiss) {
this.#setOpen(false, e);
}
},
} as const satisfies HTMLDialogAttributes;
}

get overlay() {
return {
...this.#sharedAttrs,
[metadata.dataAttrs.overlay]: "",
} as const satisfies HTMLAttributes<HTMLElement>;
}

get content() {
return {
...this.#sharedAttrs,
[metadata.dataAttrs.content]: "",
id: this.#ids.content,
} as const satisfies HTMLAttributes<HTMLElement>;
}

getTrigger({
mode = "toggle",
}: {
/**
* The logic used to determine the triggered state.
*/
mode?: "toggle" | "open" | "close";
} = {}) {
return {
...this.#sharedAttrs,
[metadata.dataAttrs.trigger]: "",
"aria-haspopup": "dialog" as const,
onclick: (e) => {
const open = mode === "open" ? true : mode === "close" ? false : !this.open;
this.#setOpen(open, e);
},
} as const satisfies HTMLButtonAttributes;
}

get close() {
return {
[metadata.dataAttrs.close]: "",
onclick: (e) => {
this.#setOpen(false, e);
},
} as const satisfies HTMLButtonAttributes;
}

get title() {
return {
[metadata.dataAttrs.title]: "",
} as const satisfies HTMLAttributes<HTMLElement>;
}

get description() {
return {
[metadata.dataAttrs.description]: "",
} as const satisfies HTMLAttributes<HTMLElement>;
}
}