Dialogs
A promise-based prompt queue and three headless DOM helpers that go with it.
request_choice and ConfirmDialog
request_choice(message, title, choices, dismiss_id) returns a promise that resolves to
the id of the choice the user picked, so asking a question reads like a function call
instead of a callback dance. Mount one ConfirmDialog high in the tree and any code
below it can ask.
Two details carry their weight:
- Requests queue rather than overlap. Two prompts racing in one modal would leave the second answered by a click meant for the first, so only the head of the queue is on screen and the buttons are rebuilt between questions. Press Ask both at once below and answer fast to see the second question survive a double-click.
dismiss_iddecides what Escape means. A dismissal resolves with that id, never with the accented choice, so a stray keypress is never read as consent.
Choices are arbitrary in number. ask_confirm(body, title, confirm_label = 'OK') is the two-button shorthand and resolves to a boolean. A body can be text or a trusted Svelte snippet. ask_prompt(body, title, options) adds a text field, returns string | null and keeps the dialog open when its validator returns an error message. Configure the shared prompt field with input_props on ConfirmDialog; these attributes apply to every prompt, request-specific placeholders win when set, and oninput chains so validation still clears.
ConfirmDialog, CommandMenu, and Dialog use native modal dialogs with closedby for Escape and backdrop dismissal; the browser owns top-layer stacking, modality, and light-dismiss ordering. Their backdrop dims the page by default; set backdrop_dim={false} to disable it or backdrop_blur to opt into blur. The existing --*-backdrop and --*-backdrop-filter CSS variables control intensity.
Answers (newest first):
Nothing answered yet.
svelte<script lang="ts">
import ConfirmDialog from 'svelte-widgets/ConfirmDialog.svelte'
import { ask_confirm, ask_prompt, request_choice } from 'svelte-widgets/dialogs'
let answers = $state<string[]>([])
const record = (answer: string) => (answers = [answer, ...answers].slice(0, 5))
const ask_delete = async () => {
const ok = await ask_confirm(`Delete all build artifacts?`, `Clean up`, `Delete`)
record(`ask_confirm resolved ${ok}`)
}
const ask_quit = async () => {
const answer = await request_choice(
`Your changes have not been saved.`,
`Quit editor`,
[
{ id: `cancel`, label: `Keep editing` },
{ id: `discard`, label: `Discard`, tone: `danger` },
{ id: `save`, label: `Save and quit`, tone: `accent` },
],
`cancel`,
)
record(`request_choice resolved ${answer}`)
}
const ask_name = async () => {
const name = await ask_prompt(`Choose a display name.`, `Profile`, {
input_label: `Display name`,
placeholder: `Ada`,
validate: (value) =>
value.trim().length < 2 ? `Enter at least two characters` : undefined,
})
record(`ask_prompt resolved ${name ?? `canceled`}`)
}
const ask_rich = async () => {
const ok = await ask_confirm(
{ kind: `snippet`, snippet: rich_body },
`Publish package`,
`Publish`,
)
record(`rich ask_confirm resolved ${ok}`)
}
</script>
{#snippet rich_body()}
<p>Publish <strong>svelte-widgets</strong> with the current changelog?</p>
{/snippet}
<ConfirmDialog />
<div style="display: flex; gap: 6pt; flex-wrap: wrap">
<button type="button" onclick={ask_delete}>Ask to delete</button>
<button type="button" onclick={ask_quit}>Ask to quit</button>
<button type="button" onclick={ask_name}>Ask for a name</button>
<button type="button" onclick={ask_rich}>Ask with rich body</button>
<button
type="button"
onclick={() => {
ask_delete()
ask_quit()
}}
>
Ask both at once
</button>
</div>
<p style="margin: 8pt 0 0"><strong>Answers</strong> (newest first):</p>
{#if answers.length}
<ul style="margin: 6pt 0 0; padding-left: 1.25em">
{#each answers as answer, idx (idx)}
<li><code>{answer}</code></li>
{/each}
</ul>
{:else}
<p style="margin: 6pt 0 0; opacity: 0.8">Nothing answered yet.</p>
{/if}print_page
Print the current page with an optional suggested PDF filename. The helper temporarily sets document.title and restores it after printing, cancellation, or an error. Use your own @media print CSS to control visible content and pagination.
This replaces print_element(node, options). The node argument and single_page, page_width_mm, and px_per_inch options are removed; print_page prints the current document.
svelte<script lang="ts">
import { format_print_filename, print_page } from 'svelte-widgets/print'
</script>
<button
type="button"
onclick={() => print_page({ filename: format_print_filename(`widgets-demo`) })}
>
Print this page
</button>create_clipboard_feedback
Reactive “recently copied” state keyed by string, for UIs that render their own copy
affordances and only need the state CopyButton keeps internally. Each key runs its own
timer, and re-copying a key restarts that timer so the checkmark cannot blink out early.
npm install svelte-widgets | |
npx vitest run | |
npx vp build |
svelte<script lang="ts">
import { create_clipboard_feedback } from 'svelte-widgets/clipboard'
const { copied, copy } = create_clipboard_feedback(1500, (error) =>
console.error(error),
)
const commands = [
[`install`, `npm install svelte-widgets`],
[`test`, `npx vitest run`],
[`build`, `npx vp build`],
]
</script>
<table>
<tbody>
{#each commands as [key, command] (key)}
<tr>
<td><code>{command}</code></td>
<td>
<button class="copy-feedback" type="button" onclick={() => copy(command, key)}>
<span>{copied.has(key) ? `Copied!` : `Copy`}</span>
<span class="copy-width" aria-hidden="true">Copied!</span>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
<style>
.copy-feedback {
display: inline-grid;
white-space: nowrap;
> span {
grid-area: 1 / 1;
}
> .copy-width {
visibility: hidden;
}
}
</style>files_from_data_transfer
DataTransfer.files stops at the top level: drop a directory and it reports a single
zero-byte file named after the directory. This walks the drop with webkitGetAsEntry instead, expanding directories depth-first (draining readEntries, which hands back at
most 100 children per call), and falls back to the flat file list when the entry API
yields nothing. It rejects rather than returning a partial list when a tree nests past 32
levels or expands past 20 000 directories — the entry API resolves symlinks, so a link to
an ancestor would otherwise recurse without end — which is why the handler below catches.
svelte<script lang="ts">
import { files_from_data_transfer } from 'svelte-widgets/file-drop'
let dropped = $state<File[]>([])
let is_dragging = $state(false)
let drop_error = $state(``)
const handle_drop = async (event: DragEvent) => {
event.preventDefault()
is_dragging = false
if (!event.dataTransfer) return
// rejects past its nesting/branching caps, which a symlink cycle reaches; unhandled, the
// drop would look like it silently did nothing
try {
dropped = await files_from_data_transfer(event.dataTransfer)
drop_error = ``
} catch (error) {
drop_error = String(error)
}
}
</script>
<div
role="region"
aria-label="File drop zone"
ondrop={handle_drop}
ondragover={(event) => {
event.preventDefault()
is_dragging = true
}}
ondragleave={() => (is_dragging = false)}
style="padding: 2em; text-align: center; border: 2px dashed {is_dragging
? `cornflowerblue`
: `gray`}; border-radius: 6pt"
>
Drop files or whole folders here
</div>
{#if drop_error}
<p style="margin: 8pt 0 0; color: tomato">{drop_error}</p>
{/if}
{#if dropped.length}
<p style="margin: 8pt 0 0">{dropped.length} files:</p>
<ul style="margin: 6pt 0 0; padding-left: 1.25em; max-height: 12em; overflow: auto">
{#each dropped as file (file)}
<li><code>{file.name}</code> — {file.size} bytes</li>
{/each}
</ul>
{/if}