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'],