From 65d742c52353a47bad07e43ac22ee342bc4adc32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:07:15 +0000 Subject: [PATCH] Fix focus trapping, className updates, and toast lifetimes for 0.6.0 Focus detection returned targets that cannot actually receive focus. A dialog whose form began with a hidden input, such as a CSRF token, focused that input, which left document.activeElement on so the trap never engaged. Hidden inputs and anything inside a [hidden], [inert], or aria-hidden subtree are now excluded, and summary, iframe, media, and contenteditable targets are recognised. update({ className }) split the class string without dropping empty tokens, so a padded or whitespace-only value made classList throw a SyntaxError. toast() passed its duration straight to setTimeout, which coerces 0 and Infinity to 0 and dismissed those toasts immediately instead of keeping them until dismissed. Its auto-dismiss timer also leaked when the toast was removed through an action button, and the region name was hardcoded English. Also fixes the dialog stack test reset leaving the body scroll locked, reads the test-time version from package.json so it cannot drift from the build, pins generated API reports to LF, and refreshes dev dependencies to clear all reported advisories. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MtJ3nDatGiv7dHjvZL1uZ4 --- .gitattributes | 7 +++ CHANGELOG.md | 18 ++++++ README.md | 28 +++++---- api-extractor.json | 1 + etc/core.api.md | 1 + etc/forgedialog.api.md | 2 +- etc/presentation.api.md | 1 - package-lock.json | 106 ++++++++++++--------------------- package.json | 2 +- scripts/check-subpath-apis.mjs | 3 + src/api/toast.ts | 49 +++++++++++---- src/core/Dialog.ts | 14 ++++- src/core/DialogStack.ts | 4 ++ src/core/keyboard.ts | 21 ++++++- src/i18n/defaultLabels.ts | 1 + src/types.ts | 2 + tests/api/presentation.test.ts | 66 +++++++++++++++++++- tests/core/Dialog.v2.test.ts | 22 +++++++ tests/core/keyboard.test.ts | 46 ++++++++++++++ tests/i18n/labels.test.ts | 1 + vitest.config.ts | 5 +- 21 files changed, 299 insertions(+), 101 deletions(-) create mode 100644 .gitattributes 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 = ''; + 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 = ` + +
+ + + `; + 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'],