diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..0a2482c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+# Normalize line endings so generated reports and sources stay byte-identical
+# across platforms.
+* text=auto eol=lf
+
+# Generated snapshots: keep diffs readable but do not treat them as authored.
+etc/*.api.md linguist-generated=true
+package-lock.json linguist-generated=true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23c2e77..4fecef3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
# Changelog
+## 0.6.0
+
+- Fixed focus trapping skipping over targets that cannot receive focus: hidden inputs and anything
+ inside a `[hidden]`, `[inert]`, or `aria-hidden="true"` subtree. A dialog whose form started with a
+ hidden field, such as a CSRF token, previously left focus on `
` so the trap never engaged.
+- Added `summary`, `iframe`, `audio[controls]`, `video[controls]`, and `contenteditable` elements to
+ focus detection.
+- Fixed `update({ className })` throwing `SyntaxError` when the class string was padded, empty, or
+ contained repeated whitespace.
+- Fixed `toast()` dismissing immediately for a duration of `0` or `Infinity`; both now keep the toast
+ until it is dismissed explicitly.
+- Fixed the toast auto-dismiss timer leaking when a toast was dismissed through its action button.
+- Added a `notifications` label so the toast region name can be localized with `setLabels()`.
+- Fixed `resetForTests()` on the dialog stack leaving the body scroll locked.
+- Pinned generated API reports to LF endings and normalized line endings repository-wide so the
+ checked-in snapshots stop churning on Windows checkouts.
+- Updated development dependencies to clear all reported advisories.
+
## 0.5.0
- Added scoped surface/backdrop opacity, backdrop blur, border, and shadow appearance controls.
diff --git a/README.md b/README.md
index 9e03177..a199edd 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# ForgeDialog 0.5
+# ForgeDialog 0.6
_A modern, lightweight, and highly customizable JavaScript dialog library for alerts, confirmations, modals, drawers, and interactive workflows._
@@ -72,17 +72,17 @@ const result = await instance.whenClosed();
## API
-| Function | Returns | Description |
-| -------------------------------------- | ----------------------- | ------------------------------------------------------------ |
-| `alert(message, options?)` | `Promise` | Single-button informational dialog. |
-| `confirm(message, options?)` | `Promise` | OK/Cancel dialog; resolves `false` on Escape/overlay close. |
-| `prompt(message, options?)` | `Promise` | Text-input dialog with optional `validate()`. |
-| `open(options)` | `DialogInstance` | Low-level API for fully custom dialogs. |
-| `setTheme('light'\|'dark'\|'system')` | `void` | Overrides the OS color-scheme preference. |
-| `getTheme()` | `ThemeMode` | Reads the current theme override. |
-| `setLabels(overrides)` / `getLabels()` | `void` / `DialogLabels` | Overrides default button labels (`ok`, `cancel`, `close`). |
-| `registerPlugin(plugin)` | `void` | Registers a plugin (`{ name, install?, hooks? }`). |
-| `on(hookName, fn)` / `off(...)` | `void` | Shorthand for a single lifecycle hook without a full plugin. |
+| Function | Returns | Description |
+| -------------------------------------- | ----------------------- | -------------------------------------------------------------------- |
+| `alert(message, options?)` | `Promise` | Single-button informational dialog. |
+| `confirm(message, options?)` | `Promise` | OK/Cancel dialog; resolves `false` on Escape/overlay close. |
+| `prompt(message, options?)` | `Promise` | Text-input dialog with optional `validate()`. |
+| `open(options)` | `DialogInstance` | Low-level API for fully custom dialogs. |
+| `setTheme('light'\|'dark'\|'system')` | `void` | Overrides the OS color-scheme preference. |
+| `getTheme()` | `ThemeMode` | Reads the current theme override. |
+| `setLabels(overrides)` / `getLabels()` | `void` / `DialogLabels` | Overrides default labels (`ok`, `cancel`, `close`, `notifications`). |
+| `registerPlugin(plugin)` | `void` | Registers a plugin (`{ name, install?, hooks? }`). |
+| `on(hookName, fn)` / `off(...)` | `void` | Shorthand for a single lifecycle hook without a full plugin. |
`DialogInstance` exposes `open()`, `close(result?)`, `whenClosed()`, `update(partialOptions)`,
`isOpen()`, and the position methods `getPosition()`, `setPosition()`, and `resetPosition()`.
@@ -142,6 +142,10 @@ palettes, and persisted branching wizards.
import { drawer, toast, wizard } from 'forgedialog';
toast('Saved', { tone: 'success' });
+// A duration of 0 or Infinity keeps the toast until dismiss() is called.
+const upload = toast('Uploading…', { duration: 0 });
+upload.dismiss();
+
drawer({ title: 'Settings', side: 'right', content: renderSettings });
const flow = wizard({
diff --git a/api-extractor.json b/api-extractor.json
index 86e8f7c..7d43b55 100644
--- a/api-extractor.json
+++ b/api-extractor.json
@@ -7,6 +7,7 @@
"reportFolder": "/etc",
"reportTempFolder": "/temp"
},
+ "newlineKind": "lf",
"docModel": { "enabled": false },
"dtsRollup": { "enabled": false },
"tsdocMetadata": { "enabled": false },
diff --git a/etc/core.api.md b/etc/core.api.md
index a538420..a718cff 100644
--- a/etc/core.api.md
+++ b/etc/core.api.md
@@ -103,6 +103,7 @@ export interface DialogLabels {
close: string;
// (undocumented)
fieldRequired?: string;
+ notifications?: string;
// (undocumented)
ok: string;
// (undocumented)
diff --git a/etc/forgedialog.api.md b/etc/forgedialog.api.md
index 0f09b62..5d147b9 100644
--- a/etc/forgedialog.api.md
+++ b/etc/forgedialog.api.md
@@ -126,6 +126,7 @@ export interface DialogLabels {
close: string;
// (undocumented)
fieldRequired?: string;
+ notifications?: string;
// (undocumented)
ok: string;
// (undocumented)
@@ -515,7 +516,6 @@ export interface ToastOptions {
text: string;
onClick: () => void | Promise;
};
- // (undocumented)
duration?: number;
// (undocumented)
tone?: ToastTone;
diff --git a/etc/presentation.api.md b/etc/presentation.api.md
index 11dff8a..969fb6b 100644
--- a/etc/presentation.api.md
+++ b/etc/presentation.api.md
@@ -85,7 +85,6 @@ export interface ToastOptions {
text: string;
onClick: () => void | Promise;
};
- // (undocumented)
duration?: number;
// (undocumented)
tone?: ToastTone;
diff --git a/package-lock.json b/package-lock.json
index bfc9ead..b166057 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1209,9 +1209,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
- "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1259,9 +1259,9 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
- "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1599,16 +1599,16 @@
}
},
"node_modules/@microsoft/api-extractor/node_modules/brace-expansion": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/@microsoft/api-extractor/node_modules/minimatch": {
@@ -1896,9 +1896,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1916,9 +1913,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1936,9 +1930,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1956,9 +1947,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1976,9 +1964,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1996,9 +1981,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3758,9 +3740,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
- "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4803,9 +4785,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
- "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5017,9 +4999,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
- "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@@ -5309,9 +5291,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
- "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5921,9 +5903,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -6192,9 +6174,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6216,9 +6195,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6240,9 +6216,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6264,9 +6237,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6706,9 +6676,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.16",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
- "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -7231,9 +7201,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.19",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
- "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -7251,7 +7221,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -7504,9 +7474,9 @@
}
},
"node_modules/read-yaml-file/node_modules/js-yaml": {
- "version": "3.15.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
- "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
+ "version": "3.15.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
+ "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7980,9 +7950,9 @@
}
},
"node_modules/serve-handler/node_modules/brace-expansion": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
- "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index ca273ba..d454a74 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "forgedialog",
- "version": "0.5.0",
+ "version": "0.6.0",
"description": "A modern, lightweight, and highly customizable JavaScript dialog library for alerts, confirmations, modals, drawers, and interactive workflows.",
"type": "module",
"license": "MIT",
diff --git a/scripts/check-subpath-apis.mjs b/scripts/check-subpath-apis.mjs
index 7846b92..2a1ac87 100644
--- a/scripts/check-subpath-apis.mjs
+++ b/scripts/check-subpath-apis.mjs
@@ -26,6 +26,9 @@ for (const entry of entries) {
docModel: { enabled: false },
dtsRollup: { enabled: false },
tsdocMetadata: { enabled: false },
+ // Keep reports byte-identical across platforms so checked-in API
+ // snapshots do not churn on Windows checkouts.
+ newlineKind: 'lf',
messages: {
extractorMessageReporting: {
default: { logLevel: 'warning' },
diff --git a/src/api/toast.ts b/src/api/toast.ts
index d7a1c92..2eec9cd 100644
--- a/src/api/toast.ts
+++ b/src/api/toast.ts
@@ -1,10 +1,15 @@
import { generateId } from '../utils/id';
+import { getLabels } from '../i18n/defaultLabels';
import { open } from './open';
import type { DialogInstance } from '../types';
export type ToastTone = 'info' | 'success' | 'warning' | 'danger';
export interface ToastOptions {
tone?: ToastTone;
+ /**
+ * Auto-dismiss delay in milliseconds. Defaults to 4000. Pass `0` or
+ * `Infinity` to keep the toast until it is dismissed explicitly.
+ */
duration?: number;
action?: { text: string; onClick: () => void | Promise };
}
@@ -16,11 +21,17 @@ export interface ToastHandle {
let region: HTMLElement | undefined;
const history: Array<{ id: string; message: string; tone: ToastTone; createdAt: number }> = [];
+const DEFAULT_TOAST_DURATION = 4000;
+
function getRegion(): HTMLElement {
- if (region?.isConnected) return region;
+ const label = getLabels().notifications ?? 'Notifications';
+ if (region?.isConnected) {
+ region.setAttribute('aria-label', label);
+ return region;
+ }
region = document.createElement('section');
region.className = 'fd-toast-region';
- region.setAttribute('aria-label', 'Notifications');
+ region.setAttribute('aria-label', label);
document.body.appendChild(region);
return region;
}
@@ -35,28 +46,42 @@ export function toast(message: string, options: ToastOptions = {}): ToastHandle
const text = document.createElement('span');
text.textContent = message;
element.appendChild(text);
+
+ let timer: number | undefined;
+ const dismiss = (): void => {
+ if (timer !== undefined) {
+ window.clearTimeout(timer);
+ timer = undefined;
+ }
+ element.remove();
+ };
+
if (options.action) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'fd-toast__action';
button.textContent = options.action.text;
button.addEventListener('click', async () => {
- await options.action?.onClick();
- element.remove();
+ try {
+ await options.action?.onClick();
+ } finally {
+ dismiss();
+ }
});
element.appendChild(button);
}
getRegion().appendChild(element);
history.unshift({ id, message, tone, createdAt: Date.now() });
if (history.length > 100) history.length = 100;
- const timer = window.setTimeout(() => element.remove(), options.duration ?? 4000);
- return {
- id,
- dismiss: () => {
- window.clearTimeout(timer);
- element.remove();
- },
- };
+
+ const duration = options.duration ?? DEFAULT_TOAST_DURATION;
+ // A non-positive or non-finite delay would be coerced to 0 by setTimeout and
+ // dismiss the toast immediately; treat it as "keep until dismissed" instead.
+ if (Number.isFinite(duration) && duration > 0) {
+ timer = window.setTimeout(dismiss, duration);
+ }
+
+ return { id, dismiss };
}
export function getNotificationHistory() {
diff --git a/src/core/Dialog.ts b/src/core/Dialog.ts
index ffe6f8a..3f2a874 100644
--- a/src/core/Dialog.ts
+++ b/src/core/Dialog.ts
@@ -24,6 +24,14 @@ import {
import { FocusTrap } from './FocusTrap';
import { createDraggable, type DraggableHandle } from './interactionRegistry';
+/**
+ * Splits a `className` option into safe tokens. `classList` throws on empty
+ * tokens, so padded or whitespace-only values must not reach it.
+ */
+function toClassTokens(className: string | undefined): string[] {
+ return className ? className.split(/\s+/).filter(Boolean) : [];
+}
+
export class Dialog implements DialogInstance {
readonly id: string;
readonly element: HTMLElement;
@@ -234,8 +242,10 @@ export class Dialog implements DialogInstance {
this.dialogEl.classList.add(`fd-dialog--${partial.presentation ?? 'modal'}`);
}
if ('className' in partial) {
- if (previous.className) this.dialogEl.classList.remove(...previous.className.split(/\s+/));
- if (partial.className) this.dialogEl.classList.add(...partial.className.split(/\s+/));
+ const previousTokens = toClassTokens(previous.className);
+ const nextTokens = toClassTokens(partial.className);
+ if (previousTokens.length > 0) this.dialogEl.classList.remove(...previousTokens);
+ if (nextTokens.length > 0) this.dialogEl.classList.add(...nextTokens);
}
}
diff --git a/src/core/DialogStack.ts b/src/core/DialogStack.ts
index ebd7dfc..38da3d4 100644
--- a/src/core/DialogStack.ts
+++ b/src/core/DialogStack.ts
@@ -53,7 +53,11 @@ export class DialogStackManager {
}
resetForTests(): void {
+ // A single scroll lock is taken when the stack goes from empty to non-empty,
+ // so exactly one release is owed when the stack is discarded.
+ const wasLocked = this.stack.length > 0;
this.stack = [];
+ if (wasLocked) unlockScroll();
this.detachEscapeListener();
}
diff --git a/src/core/keyboard.ts b/src/core/keyboard.ts
index b56d0c3..8f10e85 100644
--- a/src/core/keyboard.ts
+++ b/src/core/keyboard.ts
@@ -2,11 +2,28 @@ export const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
- 'input:not([disabled])',
+ 'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
+ 'details > summary:first-of-type',
+ 'iframe',
+ 'audio[controls]',
+ 'video[controls]',
+ '[contenteditable]:not([contenteditable="false"])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
+/** Ancestors that remove their whole subtree from the tab order. */
+const HIDDEN_SUBTREE_SELECTOR = '[hidden],[inert],[aria-hidden="true"]';
+
+/**
+ * Attribute-based visibility check. Layout-based checks (`offsetParent`,
+ * `getClientRects`) are deliberately avoided so the trap behaves identically in
+ * jsdom, which has no layout engine.
+ */
+function isTabbable(element: HTMLElement): boolean {
+ return element.closest(HIDDEN_SUBTREE_SELECTOR) === null;
+}
+
export function isEscapeKey(event: KeyboardEvent): boolean {
return event.key === 'Escape' || event.key === 'Esc';
}
@@ -16,5 +33,5 @@ export function isTabKey(event: KeyboardEvent): boolean {
}
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
- return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(isTabbable);
}
diff --git a/src/i18n/defaultLabels.ts b/src/i18n/defaultLabels.ts
index f1eabd7..0828f50 100644
--- a/src/i18n/defaultLabels.ts
+++ b/src/i18n/defaultLabels.ts
@@ -7,6 +7,7 @@ const defaultLabels: DialogLabels = {
promptPlaceholder: '',
submit: 'Submit',
fieldRequired: 'This field is required.',
+ notifications: 'Notifications',
};
let currentLabels: DialogLabels = { ...defaultLabels };
diff --git a/src/types.ts b/src/types.ts
index 7d66a5a..ca07805 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -63,6 +63,8 @@ export interface DialogLabels {
promptPlaceholder?: string;
submit?: string;
fieldRequired?: string;
+ /** Accessible name for the toast notification region. */
+ notifications?: string;
}
export interface ButtonConfig {
diff --git a/tests/api/presentation.test.ts b/tests/api/presentation.test.ts
index 5ac7d94..c06ad37 100644
--- a/tests/api/presentation.test.ts
+++ b/tests/api/presentation.test.ts
@@ -1,4 +1,5 @@
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { resetLabelsForTests, setLabels } from '../../src/i18n/defaultLabels';
import { bottomSheet, drawer, lightbox, loading } from '../../src/api/presentation';
import { commandPalette } from '../../src/api/commandPalette';
import {
@@ -35,6 +36,69 @@ describe('presentation APIs', () => {
expect(document.getElementById(handle.id)).toBeNull();
});
+ it('keeps toasts until dismissed when the duration is zero or infinite', () => {
+ vi.useFakeTimers();
+ try {
+ const sticky = toast('Upload in progress', { duration: 0 });
+ const endless = toast('Connection lost', { tone: 'danger', duration: Infinity });
+
+ // Regression: setTimeout coerces these delays to 0, so the toasts used to
+ // disappear on the next tick instead of waiting for an explicit dismiss.
+ vi.advanceTimersByTime(60_000);
+ expect(document.getElementById(sticky.id)).not.toBeNull();
+ expect(document.getElementById(endless.id)).not.toBeNull();
+
+ sticky.dismiss();
+ endless.dismiss();
+ expect(document.getElementById(sticky.id)).toBeNull();
+ expect(document.getElementById(endless.id)).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('auto-dismisses after the configured duration and clears the timer on action', async () => {
+ vi.useFakeTimers();
+ try {
+ const expiring = toast('Saved', { duration: 5_000 });
+ vi.advanceTimersByTime(4_999);
+ expect(document.getElementById(expiring.id)).not.toBeNull();
+ vi.advanceTimersByTime(1);
+ expect(document.getElementById(expiring.id)).toBeNull();
+
+ let undone = false;
+ const withAction = toast('Item deleted', {
+ duration: 5_000,
+ action: {
+ text: 'Undo',
+ onClick: () => {
+ undone = true;
+ },
+ },
+ });
+ const element = document.getElementById(withAction.id)!;
+ element.querySelector('.fd-toast__action')!.click();
+ await vi.runAllTimersAsync();
+ expect(undone).toBe(true);
+ expect(document.getElementById(withAction.id)).toBeNull();
+ expect(vi.getTimerCount()).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('labels the toast region using the configured locale', () => {
+ setLabels({ notifications: 'Thông báo' });
+ try {
+ toast('Xin chào', { duration: 10_000 });
+ expect(document.querySelector('.fd-toast-region')?.getAttribute('aria-label')).toBe(
+ 'Thông báo',
+ );
+ } finally {
+ resetLabelsForTests();
+ }
+ });
+
it('opens bottom sheets and accessible lightboxes', async () => {
const sheet = bottomSheet({ title: 'Actions' });
expect(sheet.element.querySelector('.fd-dialog--bottom-sheet')).not.toBeNull();
diff --git a/tests/core/Dialog.v2.test.ts b/tests/core/Dialog.v2.test.ts
index f27de52..52b48e5 100644
--- a/tests/core/Dialog.v2.test.ts
+++ b/tests/core/Dialog.v2.test.ts
@@ -14,6 +14,28 @@ describe('Dialog v2 lifecycle', () => {
await expect(dialog.whenSettled()).resolves.toEqual({ result: true, reason: 'button' });
});
+ it('updates className safely when it contains padding or repeated whitespace', async () => {
+ const dialog = new Dialog(
+ { className: ' legacy theme ' },
+ labels,
+ new DialogStackManager(),
+ new PluginManager(),
+ );
+ await dialog.open();
+ const surface = dialog.element.querySelector('.fd-dialog')!;
+ expect(surface.classList.contains('legacy')).toBe(true);
+
+ // Regression: empty tokens from split() made classList throw a SyntaxError.
+ expect(() => dialog.update({ className: ' fresh skin ' })).not.toThrow();
+ expect(surface.classList.contains('legacy')).toBe(false);
+ expect(surface.classList.contains('fresh')).toBe(true);
+ expect(surface.classList.contains('skin')).toBe(true);
+
+ expect(() => dialog.update({ className: undefined })).not.toThrow();
+ expect(surface.classList.contains('fresh')).toBe(false);
+ await dialog.close();
+ });
+
it('supports cancellable beforeClose hooks', async () => {
const plugins = new PluginManager();
plugins.on('beforeClose', (context) => context.preventClose?.());
diff --git a/tests/core/keyboard.test.ts b/tests/core/keyboard.test.ts
index 4a0c9db..a7876dc 100644
--- a/tests/core/keyboard.test.ts
+++ b/tests/core/keyboard.test.ts
@@ -24,4 +24,50 @@ describe('keyboard utils', () => {
const focusable = getFocusableElements(container);
expect(focusable).toHaveLength(3);
});
+
+ it('skips hidden inputs, which cannot receive focus', () => {
+ const container = document.createElement('div');
+ container.innerHTML = 'Send ';
+ document.body.appendChild(container);
+
+ const focusable = getFocusableElements(container);
+ expect(focusable.map((element) => element.tagName)).toEqual(['BUTTON']);
+
+ // Regression: focusing a hidden input silently leaves focus on ,
+ // which let focus escape the dialog entirely.
+ focusable[0].focus();
+ expect(document.activeElement).toBe(focusable[0]);
+
+ container.remove();
+ });
+
+ it('skips elements inside hidden, inert, or aria-hidden subtrees', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+ Hidden
+ Inert
+ Aria hidden
+ Visible
+ `;
+ const focusable = getFocusableElements(container);
+ expect(focusable.map((element) => element.textContent)).toEqual(['Visible']);
+ });
+
+ it('includes summary, iframe, media, and contenteditable targets', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+ More body
+
+
+ edit
+ static
+ `;
+ const focusable = getFocusableElements(container);
+ expect(focusable.map((element) => element.tagName)).toEqual([
+ 'SUMMARY',
+ 'IFRAME',
+ 'VIDEO',
+ 'DIV',
+ ]);
+ });
});
diff --git a/tests/i18n/labels.test.ts b/tests/i18n/labels.test.ts
index c055090..4bd2f8b 100644
--- a/tests/i18n/labels.test.ts
+++ b/tests/i18n/labels.test.ts
@@ -22,6 +22,7 @@ describe('i18n labels', () => {
promptPlaceholder: '',
submit: 'Submit',
fieldRequired: 'This field is required.',
+ notifications: 'Notifications',
});
});
diff --git a/vitest.config.ts b/vitest.config.ts
index b8de76f..a7df853 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,7 +1,10 @@
import { defineConfig } from 'vitest/config';
+import packageJson from './package.json';
export default defineConfig({
- define: { __FORGEDIALOG_VERSION__: JSON.stringify('0.5.0') },
+ // Read from package.json like tsup does, so the version cannot drift between
+ // the build and the tests after a release bump.
+ define: { __FORGEDIALOG_VERSION__: JSON.stringify(packageJson.version) },
test: {
environment: 'jsdom',
setupFiles: ['tests/setup.ts'],