diff --git a/CHANGELOG.md b/CHANGELOG.md index d29ae69..e29797d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [3.3.3] — 2026-06-26 + +A hardening pass across the non-core subsystems (`ui`, `widgets`, `components`, `plugins`, `data`, `platform`, `patterns`, `performance`, `devtools`): correctness fixes, memory-leak plugs, SSR-safety guards, and accessibility improvements. No breaking changes. + +### Added + +- **`bindField` reflects an array back to `` can't be driven by the plain `value` prop + // (assigning an array to `el.value` clears the selection), so reflect the + // field's array value onto each option's `selected` flag via a reactive + // effect bound to the element. Every other control type is handled correctly + // by the `value` prop alone, so this only engages for multi-selects. + const onElement = (el: HTMLElement): void => { + if (el instanceof HTMLSelectElement && el.multiple) { + const stop = effect(() => { + const v = field.value() as unknown; + const selected = Array.isArray(v) ? v.map(String) : []; + for (const opt of Array.from(el.options)) { + opt.selected = selected.includes(opt.value); + } + }); + registerDisposer(el, stop); + } + if (typeof extraOnElement === "function") { + (extraOnElement as (el: HTMLElement) => void)(el); + } + }; + return { value: field.value as () => unknown, on: mergedOn as BoundFieldProps["on"], + onElement, ...restExtras, }; } @@ -244,9 +273,27 @@ export function form>(config: FormConfig): return true; }); + // Dirty-check that doesn't false-positive on array/object initials (e.g. a + // multi-select `initial: []`): a fresh array is never `Object.is`-equal to the + // original, so fall back to a shallow structural comparison. + const valueEquals = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => Object.is(v, b[i])); + } + if (a && b && typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a); + const bk = Object.keys(b as object); + return ( + ak.length === bk.length && + ak.every((k) => Object.is((a as Record)[k], (b as Record)[k])) + ); + } + return false; + }; const isDirty = derived(() => { for (const [name, cfg] of fieldEntries) { - if (!Object.is(fieldMap[name].value(), cfg.initial)) return true; + if (!valueEquals(fieldMap[name].value(), cfg.initial)) return true; } return false; }); diff --git a/src/ui/hover.ts b/src/ui/hover.ts index 80e0854..f7ad08d 100644 --- a/src/ui/hover.ts +++ b/src/ui/hover.ts @@ -1,3 +1,4 @@ +import { registerDisposer } from "../core/rendering/dispose"; import { signal } from "../core/signals/signal"; /** @@ -36,5 +37,9 @@ export function hover(target: HTMLElement): { target.removeEventListener("pointerleave", onLeave); } + // Also release when the element is disposed, so the listeners don't leak if + // the caller forgets to call dispose() (mirrors a11y.focus()/createListbox). + registerDisposer(target, dispose); + return { hovered, dispose }; } diff --git a/src/ui/infiniteScroll.ts b/src/ui/infiniteScroll.ts index 090a67b..3d5fabe 100644 --- a/src/ui/infiniteScroll.ts +++ b/src/ui/infiniteScroll.ts @@ -43,6 +43,14 @@ export function infiniteScroll(options: { await onLoadMore(); } finally { setLoading(false); + // If the sentinel is still intersecting after the append (e.g. the newly + // loaded content didn't push it out of view, or the page isn't full yet), + // the observer won't fire again on its own — re-observe to force a fresh + // intersection check so loading doesn't stall. + if (!disposed && observer && _current && hasMore()) { + observer.unobserve(_current); + observer.observe(_current); + } } } diff --git a/src/ui/inputMask.ts b/src/ui/inputMask.ts index bad8ef5..e996670 100644 --- a/src/ui/inputMask.ts +++ b/src/ui/inputMask.ts @@ -93,8 +93,6 @@ export function inputMask(options: MaskOptions): { return /[^a-zA-Z0-9]/g; } const stripRegex = buildStripRegex(); - const rawCharTest = options.pattern.includes("*") ? () => true : (c: string) => /[a-zA-Z0-9]/.test(c); - function bind(input: HTMLInputElement): () => void { const onInput = () => { const cursorBefore = input.selectionStart ?? input.value.length; @@ -105,20 +103,24 @@ export function inputMask(options: MaskOptions): { setRawValue(extractRaw(masked)); input.value = masked; - let rawBefore = 0; - for (let i = 0; i < cursorBefore && i < oldValue.length; i++) { - if (rawCharTest(oldValue[i])) rawBefore++; - } + // Count raw (non-literal) chars before the caret using the SAME strip + // rule that derives `raw`. This is correct for digit/letter masks, "*" + // (keep-all) masks, and patterns with leading literals alike — the old + // per-char test counted literals for "*" masks and mis-handled a leading + // literal. + const rawBefore = oldValue.slice(0, cursorBefore).replace(stripRegex, "").length; + + // Walk the freshly-masked value to the position just after the + // rawBefore-th editable slot, stepping over auto-inserted literals. + // Stopping once `counted === rawBefore` keeps the caret correct even when + // rawBefore is 0 (caret stays before the first slot/leading literal). let newCursor = 0; let counted = 0; - for (; newCursor < masked.length; newCursor++) { + while (newCursor < masked.length && counted < rawBefore) { if (newCursor < options.pattern.length && isSlot(options.pattern[newCursor])) { counted++; - if (counted >= rawBefore) { - newCursor++; - break; - } } + newCursor++; } // setSelectionRange throws (InvalidStateError) on input types that don't // support selection (number, email, date, …). Masks are normally on text diff --git a/src/ui/pagination.ts b/src/ui/pagination.ts index 57ebad2..512bd2c 100644 --- a/src/ui/pagination.ts +++ b/src/ui/pagination.ts @@ -23,25 +23,30 @@ export function pagination(options: { totalItems: () => number; pageSize?: numbe return Math.max(1, Math.ceil(total / pageSizeValue)); }); + // The exposed page is clamped to the valid range, so when totalItems shrinks + // below the current page, page()/startIndex()/endIndex() stay in bounds + // instead of pointing past the data. + const currentPage = derived(() => Math.min(Math.max(1, page()), totalPages())); + const startIndex = derived(() => { - return (page() - 1) * pageSizeValue; + return (currentPage() - 1) * pageSizeValue; }); const endIndex = derived(() => { - const end = page() * pageSizeValue; + const end = currentPage() * pageSizeValue; const total = options.totalItems(); return Math.min(end, total); }); function next(): void { - if (page() < totalPages()) { - setPage((p) => p + 1); + if (currentPage() < totalPages()) { + setPage(currentPage() + 1); } } function prev(): void { - if (page() > 1) { - setPage((p) => p - 1); + if (currentPage() > 1) { + setPage(currentPage() - 1); } } @@ -50,5 +55,5 @@ export function pagination(options: { totalItems: () => number; pageSize?: numbe setPage(clamped); } - return { page, pageSize, totalPages, next, prev, goTo, startIndex, endIndex }; + return { page: currentPage, pageSize, totalPages, next, prev, goTo, startIndex, endIndex }; } diff --git a/src/ui/reducedMotion.ts b/src/ui/reducedMotion.ts index e7fafa0..d65a22b 100644 --- a/src/ui/reducedMotion.ts +++ b/src/ui/reducedMotion.ts @@ -1,5 +1,15 @@ import { signal } from "../core/signals/signal"; +/** + * One-shot check of the `prefers-reduced-motion: reduce` media query. Returns + * false under SSR / where `matchMedia` is unavailable. Use this for imperative + * "should I animate right now?" decisions; use {@link reducedMotion} when you + * need a value that reacts to the user changing the setting. + */ +export function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; +} + /** * reducedMotion returns a reactive boolean tracking whether the user * prefers reduced motion via the `prefers-reduced-motion` media query. diff --git a/src/ui/scopedStyle.ts b/src/ui/scopedStyle.ts index 96809d0..dc4ab3b 100644 --- a/src/ui/scopedStyle.ts +++ b/src/ui/scopedStyle.ts @@ -2,7 +2,11 @@ // SCOPED STYLE ISOLATION // ============================================================================ -let scopeCounter = 0; +import { globalSingleton } from "../utils/globalSingleton"; + +// Shared via globalSingleton so a duplicated copy of this module doesn't restart +// at 0 and emit colliding `sibu-s*` scope ids (cross-bundle style collisions). +const _scope = globalSingleton(Symbol.for("sibujs.scopedStyle.v1"), () => ({ n: 0 })); /** * Decode CSS escape sequences so the sanitizer can catch obfuscated @@ -75,7 +79,7 @@ function sanitizeCSS(css: string): string { * background images, use inline styles via the `style` prop instead. */ export function scopedStyle(css: string): { scope: string; attr: string } { - const id = `sibu-s${scopeCounter++}`; + const id = `sibu-s${_scope.n++}`; const attr = `data-${id}`; // Sanitize CSS to prevent data exfiltration attacks diff --git a/src/ui/scrollLock.ts b/src/ui/scrollLock.ts index 1602a9b..ed6da80 100644 --- a/src/ui/scrollLock.ts +++ b/src/ui/scrollLock.ts @@ -18,6 +18,8 @@ * lock.unlock(); * ``` */ +import { globalSingleton } from "../utils/globalSingleton"; + export interface ScrollLockHandle { /** Activate a lock. Idempotent per-handle if called twice. */ lock: () => void; @@ -34,9 +36,14 @@ export interface ScrollLockHandle { // the lock is active — if application code assigns `body.style.overflow` // during a lock, that value will be clobbered on unlock. Keep modal state // in scrollLock handles, not direct style writes. -let lockCount = 0; -let savedOverflow: string | null = null; -let savedPaddingRight: string | null = null; +// Shared via globalSingleton so a duplicated copy of this module doesn't keep +// its own counter/snapshot — otherwise one copy's N→0 unlock would restore +// `overflow` while another copy still holds an open lock. +const _lock = globalSingleton(Symbol.for("sibujs.scrollLock.v1"), () => ({ + count: 0, + savedOverflow: null as string | null, + savedPaddingRight: null as string | null, +})); export function scrollLock(): ScrollLockHandle { let owned = false; @@ -44,15 +51,15 @@ export function scrollLock(): ScrollLockHandle { function lock() { if (owned) return; owned = true; - lockCount++; + _lock.count++; // Only the 0 → 1 transition snapshots and mutates the body; nested locks // increment the counter and otherwise no-op. - if (lockCount !== 1 || typeof document === "undefined") return; + if (_lock.count !== 1 || typeof document === "undefined") return; const body = document.body; const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; - savedOverflow = body.style.overflow; - savedPaddingRight = body.style.paddingRight; + _lock.savedOverflow = body.style.overflow; + _lock.savedPaddingRight = body.style.paddingRight; body.style.overflow = "hidden"; if (scrollBarWidth > 0) { body.style.paddingRight = `${scrollBarWidth}px`; @@ -62,15 +69,15 @@ export function scrollLock(): ScrollLockHandle { function unlock() { if (!owned) return; owned = false; - lockCount = Math.max(0, lockCount - 1); + _lock.count = Math.max(0, _lock.count - 1); // Only the N → 0 transition restores the snapshot. - if (lockCount !== 0 || typeof document === "undefined") return; + if (_lock.count !== 0 || typeof document === "undefined") return; const body = document.body; - body.style.overflow = savedOverflow ?? ""; - body.style.paddingRight = savedPaddingRight ?? ""; - savedOverflow = null; - savedPaddingRight = null; + body.style.overflow = _lock.savedOverflow ?? ""; + body.style.paddingRight = _lock.savedPaddingRight ?? ""; + _lock.savedOverflow = null; + _lock.savedPaddingRight = null; } return { lock, unlock }; diff --git a/src/ui/socket.ts b/src/ui/socket.ts index d95b835..aeb76db 100644 --- a/src/ui/socket.ts +++ b/src/ui/socket.ts @@ -66,6 +66,12 @@ export function socket( function connect(): void { if (disposed) return; + // WebSocket is absent under SSR and some edge runtimes — degrade to a + // closed socket instead of throwing at construction. + if (typeof WebSocket === "undefined") { + setStatus("closed"); + return; + } const safeUrl = validateWsUrl(getUrl()); if (safeUrl === null) { diff --git a/src/ui/springSignal.ts b/src/ui/springSignal.ts index 867fefe..17ccc7c 100644 --- a/src/ui/springSignal.ts +++ b/src/ui/springSignal.ts @@ -1,4 +1,5 @@ import { signal } from "../core/signals/signal"; +import { prefersReducedMotion } from "./reducedMotion"; /** * Options for springSignal. @@ -12,9 +13,6 @@ export interface SpringOptions { precision?: number; } -const prefersReducedMotion = (): boolean => - typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; - /** * Creates a reactive spring-animated value. The getter returns the * current animated number (updated every frame via rAF). The setter diff --git a/src/ui/stream.ts b/src/ui/stream.ts index 42744a7..2c04365 100644 --- a/src/ui/stream.ts +++ b/src/ui/stream.ts @@ -52,6 +52,12 @@ export function stream( function connect(): void { if (disposed) return; + // EventSource is absent under SSR and some edge runtimes — degrade to a + // closed stream instead of throwing at construction. + if (typeof EventSource === "undefined") { + setStatus("closed"); + return; + } const safeUrl = validateSseUrl(url); if (safeUrl === null) { diff --git a/src/ui/transition.ts b/src/ui/transition.ts index 19f0110..f5dc049 100644 --- a/src/ui/transition.ts +++ b/src/ui/transition.ts @@ -3,6 +3,8 @@ * Provides declarative transition and spring animations for elements. */ +import { registerDisposer } from "../core/rendering/dispose"; + export interface TransitionOptions { /** CSS property to animate (e.g., "opacity", "transform") */ property?: string; @@ -137,6 +139,10 @@ export function transition( }); } + // If the element is disposed mid-transition, clear the pending timer (and + // settle any awaited promise) so the timer doesn't retain the element. + registerDisposer(element, cancelPending); + return { enter, leave }; } diff --git a/src/utils/globalSingleton.ts b/src/utils/globalSingleton.ts new file mode 100644 index 0000000..4f5f940 --- /dev/null +++ b/src/utils/globalSingleton.ts @@ -0,0 +1,24 @@ +/** + * First-copy-wins singleton holder. + * + * Returns the value stored on `globalThis` under `key`, creating it (once) from + * `create` if absent. This makes a piece of coordination state survive a + * bundler loading this module more than once on a page (Vite `optimizeDeps` / + * esbuild dependency pre-bundling can materialize the same chunk twice). Every + * duplicate copy resolves the SAME object, so they coordinate instead of + * silently splitting into independent worlds. + * + * The helper itself is pure (only reads/writes `globalThis[key]`), so it is + * safe under duplication too — whichever copy runs first wins the `??=`. + * + * Keys MUST be created with `Symbol.for(...)` so duplicate module copies share + * the same symbol. Use a versioned suffix (`"sibujs..v1"`) and only bump + * the version on an incompatible change to the held value's shape. + * + * @example + * const cache = globalSingleton(Symbol.for("sibujs.query.v1"), () => new Map()); + */ +export function globalSingleton(key: symbol, create: () => T): T { + const g = globalThis as typeof globalThis & Record; + return (g[key] ??= create()) as T; +} diff --git a/src/widgets/Combobox.ts b/src/widgets/Combobox.ts index 1b2b212..b95f6fd 100644 --- a/src/widgets/Combobox.ts +++ b/src/widgets/Combobox.ts @@ -1,10 +1,10 @@ +import { createId } from "../core/rendering/createId"; import { derived } from "../core/signals/derived"; import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { watch } from "../core/signals/watch"; import { batch } from "../reactivity/batch"; -let comboboxIdCounter = 0; const boundComboboxes = new WeakMap void>(); export interface ComboboxOptions { @@ -111,7 +111,15 @@ export function combobox(options: ComboboxOptions): { const existing = boundComboboxes.get(els.input); if (existing) return existing; - const listboxId = `sibu-combobox-listbox-${++comboboxIdCounter}`; + // Capture the attributes bind() mutates so teardown can restore them. + const prevListboxId = els.listbox.id; + const prevListboxRole = els.listbox.getAttribute("role"); + const prevListboxHidden = els.listbox.hidden; + const prevInputRole = els.input.getAttribute("role"); + const prevInputAutocomplete = els.input.getAttribute("aria-autocomplete"); + const prevInputControls = els.input.getAttribute("aria-controls"); + + const listboxId = createId("sibu-combobox-listbox"); els.listbox.id = listboxId; els.listbox.setAttribute("role", "listbox"); els.input.setAttribute("role", "combobox"); @@ -180,10 +188,19 @@ export function combobox(options: ComboboxOptions): { }, 100); }; + // Keep focus on the input when a pointer goes down inside the listbox, so + // selecting an option doesn't blur the input and race the blur-close timer. + // preventDefault on mousedown stops the focus shift without blocking the + // option's subsequent click handler. + const onListboxPointerDown = (e: Event) => { + e.preventDefault(); + }; + els.input.addEventListener("input", onInput); els.input.addEventListener("keydown", onKey); els.input.addEventListener("focus", onFocus); els.input.addEventListener("blur", onBlur); + els.listbox.addEventListener("mousedown", onListboxPointerDown); const teardown = () => { boundComboboxes.delete(els.input); @@ -192,10 +209,25 @@ export function combobox(options: ComboboxOptions): { els.input.removeEventListener("keydown", onKey); els.input.removeEventListener("focus", onFocus); els.input.removeEventListener("blur", onBlur); + els.listbox.removeEventListener("mousedown", onListboxPointerDown); if (blurTimer !== null) { clearTimeout(blurTimer); blurTimer = null; } + // Restore the attributes bind() mutated (mirrors Accordion/Tabs/Popover). + if (prevListboxId === "") els.listbox.removeAttribute("id"); + else els.listbox.id = prevListboxId; + if (prevListboxRole === null) els.listbox.removeAttribute("role"); + else els.listbox.setAttribute("role", prevListboxRole); + els.listbox.hidden = prevListboxHidden; + if (prevInputRole === null) els.input.removeAttribute("role"); + else els.input.setAttribute("role", prevInputRole); + if (prevInputAutocomplete === null) els.input.removeAttribute("aria-autocomplete"); + else els.input.setAttribute("aria-autocomplete", prevInputAutocomplete); + if (prevInputControls === null) els.input.removeAttribute("aria-controls"); + else els.input.setAttribute("aria-controls", prevInputControls); + els.input.removeAttribute("aria-expanded"); + els.input.removeAttribute("aria-activedescendant"); }; boundComboboxes.set(els.input, teardown); return teardown; diff --git a/src/widgets/FileUpload.ts b/src/widgets/FileUpload.ts index bdb7861..5588e3a 100644 --- a/src/widgets/FileUpload.ts +++ b/src/widgets/FileUpload.ts @@ -1,8 +1,8 @@ +import { createId } from "../core/rendering/createId"; import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { batch } from "../reactivity/batch"; -let fileUploadIdCounter = 0; const boundFileUploads = new WeakMap void>(); export interface FileUploadOptions { @@ -122,7 +122,7 @@ export function fileUpload(options?: FileUploadOptions): { const existing = boundFileUploads.get(els.input); if (existing) return existing; - const id = `sibu-fileupload-${++fileUploadIdCounter}`; + const id = createId("sibu-fileupload"); const restore: Array<() => void> = []; if (accept) els.input.accept = accept; els.input.multiple = multiple; diff --git a/src/widgets/Popover.ts b/src/widgets/Popover.ts index 9fce02f..c0a3346 100644 --- a/src/widgets/Popover.ts +++ b/src/widgets/Popover.ts @@ -1,7 +1,7 @@ +import { createId } from "../core/rendering/createId"; import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; -let popoverIdCounter = 0; const boundPopovers = new WeakMap void>(); /** @@ -35,7 +35,7 @@ export function popover(): { const existing = boundPopovers.get(els.trigger); if (existing) return existing; - const id = `sibu-popover-${++popoverIdCounter}`; + const id = createId("sibu-popover"); // Capture prior attribute state so teardown can restore (or remove) // every attribute we touch — bind() should be reversible. const prevPopoverRole = els.popover.getAttribute("role"); diff --git a/src/widgets/Select.ts b/src/widgets/Select.ts index cc94bdc..3eed482 100644 --- a/src/widgets/Select.ts +++ b/src/widgets/Select.ts @@ -1,9 +1,9 @@ +import { createId } from "../core/rendering/createId"; import { derived } from "../core/signals/derived"; import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; import { batch } from "../reactivity/batch"; -let selectIdCounter = 0; const boundSelects = new WeakMap void>(); export interface SelectOptions { @@ -136,7 +136,13 @@ export function select(options: SelectOptions): { const existing = boundSelects.get(els.listbox); if (existing) return existing; - const listboxId = `sibu-select-${++selectIdCounter}`; + // Capture the attributes bind() mutates so teardown can restore them. + const prevId = els.listbox.id; + const prevRole = els.listbox.getAttribute("role"); + const prevMultiselectable = els.listbox.getAttribute("aria-multiselectable"); + const prevTabIndex = els.listbox.getAttribute("tabindex"); + + const listboxId = createId("sibu-select"); els.listbox.id = listboxId; els.listbox.setAttribute("role", "listbox"); els.listbox.setAttribute("aria-multiselectable", multiple ? "true" : "false"); @@ -201,6 +207,16 @@ export function select(options: SelectOptions): { fxTeardown(); els.listbox.removeEventListener("keydown", onKey); if (typeTimer !== null) clearTimeout(typeTimer); + // Restore the attributes bind() mutated (mirrors Accordion/Tabs/Popover). + if (prevId === "") els.listbox.removeAttribute("id"); + else els.listbox.id = prevId; + if (prevRole === null) els.listbox.removeAttribute("role"); + else els.listbox.setAttribute("role", prevRole); + if (prevMultiselectable === null) els.listbox.removeAttribute("aria-multiselectable"); + else els.listbox.setAttribute("aria-multiselectable", prevMultiselectable); + if (prevTabIndex === null) els.listbox.removeAttribute("tabindex"); + else els.listbox.setAttribute("tabindex", prevTabIndex); + els.listbox.removeAttribute("aria-activedescendant"); }; boundSelects.set(els.listbox, teardown); return teardown; diff --git a/src/widgets/Tooltip.ts b/src/widgets/Tooltip.ts index 8d9da56..11c463f 100644 --- a/src/widgets/Tooltip.ts +++ b/src/widgets/Tooltip.ts @@ -1,8 +1,7 @@ +import { createId } from "../core/rendering/createId"; import { effect } from "../core/signals/effect"; import { signal } from "../core/signals/signal"; -let tooltipIdCounter = 0; - // Track which trigger elements already have a bind() active so a second // call short-circuits rather than corrupting aria-describedby restore. const boundTriggers = new WeakMap void>(); @@ -71,7 +70,7 @@ export function tooltip(options?: { delay?: number; hideDelay?: number }): { // aria-describedby restore on double-bind. const existing = boundTriggers.get(els.trigger); if (existing) return existing; - const id = `sibu-tooltip-${++tooltipIdCounter}`; + const id = createId("sibu-tooltip"); els.tooltip.setAttribute("role", "tooltip"); els.tooltip.id = id; const prevDescribedBy = els.trigger.getAttribute("aria-describedby"); diff --git a/src/widgets/contentEditable.ts b/src/widgets/contentEditable.ts index ce96ad1..159b204 100644 --- a/src/widgets/contentEditable.ts +++ b/src/widgets/contentEditable.ts @@ -29,8 +29,13 @@ export interface SetContentOptions { * Uses the modern Selection/Range API instead of the deprecated * document.execCommand. Formatting is applied by wrapping the current * selection in the appropriate inline element. + * + * @param element Optional editor element to scope formatting to. When provided, + * `bold()`/`italic()`/`underline()` ignore any selection that lives outside it + * — otherwise a selection elsewhere on the page could be mutated, since the + * Selection API is global. */ -export function contentEditable(): { +export function contentEditable(element?: HTMLElement): { content: () => string; /** * Update the reactive content value. @@ -82,6 +87,11 @@ export function contentEditable(): { const range = selection.getRangeAt(0); + // Scope formatting to the bound editor: ignore a selection that lives + // outside it (e.g. text selected elsewhere on the page) so the formatting + // commands can't mutate unrelated DOM. + if (element && !element.contains(range.commonAncestorContainer)) return; + // Check if we're already inside the same tag — if so, unwrap const ancestor = range.commonAncestorContainer; const existingWrap = findAncestorByTag( diff --git a/src/widgets/datePicker.ts b/src/widgets/datePicker.ts index 2ef7064..b2822de 100644 --- a/src/widgets/datePicker.ts +++ b/src/widgets/datePicker.ts @@ -175,13 +175,25 @@ export function datePicker(options?: DatePickerOptions): { const existing = boundDatePickers.get(els.grid); if (existing) return existing; + // Capture the attributes bind() mutates so teardown can restore them. + const prevGridRole = els.grid.getAttribute("role"); + const prevGridTabIndex = els.grid.getAttribute("tabindex"); + const prevGridLabel = els.grid.getAttribute("aria-label"); + els.grid.setAttribute("role", "grid"); if (els.grid.tabIndex < 0) els.grid.tabIndex = 0; + // Set when a keyboard navigation moves the view date, so the effect can move + // real focus to the newly-current cell (not just the roving tabindex). + let pendingFocus = false; + const fxTeardown = effect(() => { const sel = selectedDate(); const view = viewDate(); const days = daysInMonth(); + // Give the grid an accessible name reflecting the month on display. + els.grid.setAttribute("aria-label", view.toLocaleDateString(undefined, { month: "long", year: "numeric" })); + let viewCell: HTMLElement | null = null; for (const d of days) { const cell = els.cell(d.date); if (!cell) continue; @@ -190,7 +202,15 @@ export function datePicker(options?: DatePickerOptions): { if (d.isDisabled) cell.setAttribute("aria-disabled", "true"); else cell.removeAttribute("aria-disabled"); // Roving tabindex on focused-day (view date) cell. - cell.tabIndex = isSameCalendarDay(view, d.date) ? 0 : -1; + const isView = isSameCalendarDay(view, d.date); + cell.tabIndex = isView ? 0 : -1; + if (isView) viewCell = cell; + } + // After a keyboard move, follow the roving tabindex with real focus so + // screen-reader / keyboard users land on the day they navigated to. + if (pendingFocus && viewCell && typeof viewCell.focus === "function") { + pendingFocus = false; + viewCell.focus(); } }); @@ -203,6 +223,18 @@ export function datePicker(options?: DatePickerOptions): { } const onKey = (e: KeyboardEvent) => { + if ( + e.key === "ArrowLeft" || + e.key === "ArrowRight" || + e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "Home" || + e.key === "End" || + e.key === "PageUp" || + e.key === "PageDown" + ) { + pendingFocus = true; + } switch (e.key) { case "ArrowLeft": e.preventDefault(); @@ -252,6 +284,14 @@ export function datePicker(options?: DatePickerOptions): { boundDatePickers.delete(els.grid); fxTeardown(); els.grid.removeEventListener("keydown", onKey); + // Restore the grid attributes bind() mutated so the element can be + // re-bound or reused cleanly (mirrors Accordion/Tabs/Popover). + if (prevGridRole === null) els.grid.removeAttribute("role"); + else els.grid.setAttribute("role", prevGridRole); + if (prevGridTabIndex === null) els.grid.removeAttribute("tabindex"); + else els.grid.setAttribute("tabindex", prevGridTabIndex); + if (prevGridLabel === null) els.grid.removeAttribute("aria-label"); + else els.grid.setAttribute("aria-label", prevGridLabel); }; boundDatePickers.set(els.grid, teardown); return teardown; diff --git a/tests/audit-coverage.test.ts b/tests/audit-coverage.test.ts new file mode 100644 index 0000000..caa97ee --- /dev/null +++ b/tests/audit-coverage.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Loading } from "../src/components/Loading"; +import { dispose } from "../src/core/rendering/dispose"; +import { persisted } from "../src/patterns/persist"; +import { Head } from "../src/platform/head"; +import { hover } from "../src/ui/hover"; +import { stream } from "../src/ui/stream"; +import { combobox } from "../src/widgets/Combobox"; +import { datePicker } from "../src/widgets/datePicker"; +import { select as selectWidget } from "../src/widgets/Select"; + +// Regression coverage for the audit's accessibility (E), SSR-guard (C), and +// leak/disposer (B) themes — the mechanical fixes that lacked dedicated tests. + +// ── E3 — Loading is announced to assistive tech ───────────────────────────── +describe("Loading — screen-reader semantics (E3)", () => { + it("exposes role=status / aria-live and a default label, deferring to text when present", () => { + const bare = Loading(); + expect(bare.getAttribute("role")).toBe("status"); + expect(bare.getAttribute("aria-live")).toBe("polite"); + expect(bare.getAttribute("aria-label")).toBe("Loading"); // no visible text → labeled + + const labelled = Loading({ text: "Saving…" }); + expect(labelled.getAttribute("role")).toBe("status"); + expect(labelled.getAttribute("aria-label")).toBeNull(); // visible text is the name + }); +}); + +// ── E5 — widget bind() is reversible ──────────────────────────────────────── +describe("Select.bind() — reversible ARIA wiring (E5)", () => { + it("restores the listbox's attributes on teardown", () => { + const listbox = document.createElement("ul"); + const sel = selectWidget({ items: ["a", "b"] }); + + const teardown = sel.bind({ listbox, option: () => null }); + expect(listbox.getAttribute("role")).toBe("listbox"); + expect(listbox.id).not.toBe(""); + expect(listbox.getAttribute("aria-multiselectable")).toBe("false"); + + teardown(); + expect(listbox.getAttribute("role")).toBeNull(); + expect(listbox.id).toBe(""); + expect(listbox.getAttribute("aria-multiselectable")).toBeNull(); + expect(listbox.getAttribute("tabindex")).toBeNull(); + }); +}); + +// ── E6 — combobox option click doesn't race the blur-close ────────────────── +describe("combobox.bind() — pointer-down inside the listbox keeps input focus (E6)", () => { + it("prevents default on listbox mousedown so the option click lands", () => { + const input = document.createElement("input"); + const listbox = document.createElement("ul"); + const cb = combobox({ items: ["apple", "banana"] }); + + const teardown = cb.bind({ input, listbox, option: () => null }); + const ev = new MouseEvent("mousedown", { cancelable: true, bubbles: true }); + listbox.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + teardown(); + }); +}); + +// ── E1 — datePicker grid has an accessible name (and E5 restore) ──────────── +describe("datePicker.bind() — grid role + accessible name (E1)", () => { + it("labels the grid with the displayed month and restores attributes on teardown", () => { + const grid = document.createElement("div"); + const dp = datePicker({ initialDate: new Date(2026, 5, 15) }); + + const teardown = dp.bind({ grid, cell: () => null }); + expect(grid.getAttribute("role")).toBe("grid"); + expect(grid.getAttribute("aria-label")).toMatch(/2026/); // month/year name + + teardown(); + expect(grid.getAttribute("role")).toBeNull(); + expect(grid.getAttribute("aria-label")).toBeNull(); + }); +}); + +// ── C4 — stream degrades instead of throwing where EventSource is absent ──── +describe("stream — SSR / unsupported-runtime guard (C4)", () => { + it("stays 'closed' instead of throwing when EventSource is unavailable", () => { + // jsdom provides no EventSource — connect() must not throw at construction. + expect(typeof EventSource).toBe("undefined"); + const s = stream("https://example.com/sse"); + expect(s.status()).toBe("closed"); + s.dispose(); + }); +}); + +// ── C1 — persist degrades to a plain signal when storage is unavailable ───── +describe("persisted — degrades gracefully when storage throws/absent (C1)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("still works as a signal when localStorage is unavailable", () => { + vi.stubGlobal("localStorage", undefined); + const [value, setValue] = persisted("audit-c1", "initial"); + expect(value()).toBe("initial"); + setValue("next"); // must not throw despite no storage + expect(value()).toBe("next"); + }); +}); + +// ── B2 — Head() ties its injected elements to disposal ─────────────── +describe("Head() — injected elements are released on dispose (B2)", () => { + it("removes its managed meta element when the anchor is disposed", () => { + const sel = 'meta[name="sibu-audit-b2"]'; + expect(document.head.querySelector(sel)).toBeNull(); + + const anchor = Head({ title: "Audit B2", meta: [{ name: "sibu-audit-b2", content: "x" }] }); + expect(document.head.querySelector(sel)?.getAttribute("content")).toBe("x"); + + dispose(anchor); + // Without the disposer this element would leak in forever. + expect(document.head.querySelector(sel)).toBeNull(); + }); +}); + +// ── B7 — hover() releases its listeners on element disposal ───────────────── +describe("hover() — listeners released with the element (B7)", () => { + it("stops responding to pointer events once the element is disposed", () => { + const el = document.createElement("div"); + const h = hover(el); + + el.dispatchEvent(new Event("pointerenter")); + expect(h.hovered()).toBe(true); + + dispose(el); // registerDisposer(el, dispose) removes the listeners + el.dispatchEvent(new Event("pointerleave")); + // The leave listener is gone, so the state is no longer mutated. + expect(h.hovered()).toBe(true); + }); +}); diff --git a/tests/audit-followups.test.ts b/tests/audit-followups.test.ts new file mode 100644 index 0000000..91e8e5f --- /dev/null +++ b/tests/audit-followups.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { option, select } from "../src/core/rendering/html"; +import { signal } from "../src/core/signals/signal"; +import { initDevTools } from "../src/devtools/devtools"; +import { captureSignalGraph } from "../src/devtools/signalGraph"; +import { type NormalizedSchema, normalize } from "../src/performance/normalize"; +import { createAction } from "../src/platform/routeActions"; +import { lazyModule } from "../src/plugins/modular"; +import { createRouter, destroyRouter, navigate, route } from "../src/plugins/router"; +import { bindField, form } from "../src/ui/form"; +import { inputMask } from "../src/ui/inputMask"; +import { pagination } from "../src/ui/pagination"; + +// Regression tests for the non-core audit follow-ups (TODO.md theme D). + +describe("pagination — reactive clamp when totalItems shrinks (D9)", () => { + it("clamps the current page and indices when the dataset shrinks", () => { + const [total, setTotal] = signal(100); + const p = pagination({ totalItems: () => total(), pageSize: 10 }); + + p.goTo(8); + expect(p.page()).toBe(8); + expect(p.startIndex()).toBe(70); + + // Dataset shrinks to 2 pages while we're on page 8. + setTotal(20); + expect(p.totalPages()).toBe(2); + expect(p.page()).toBe(2); // clamped, not stuck at 8 + expect(p.startIndex()).toBe(10); // points at valid data, not past the end + }); +}); + +describe("form — isDirty does not false-positive on array/object initials (D3)", () => { + it("treats an unchanged multi-select array as not dirty", () => { + const f = form({ tags: { initial: ["a", "b"] as string[] } }); + expect(f.isDirty()).toBe(false); // fresh array !== initial under Object.is + + f.fields.tags.set(["a", "c"]); + expect(f.isDirty()).toBe(true); + + f.fields.tags.set(["a", "b"]); // structurally equal to the initial again + expect(f.isDirty()).toBe(false); + }); +}); + +describe("inputMask — caret restoration for '*' masks with literals (D8)", () => { + it("keeps the caret before the edited slot instead of jumping past the literal", () => { + const mask = inputMask({ pattern: "**-**" }); + const input = document.createElement("input"); + document.body.appendChild(input); + const dispose = mask.bind(input); + + input.value = "ab-cd"; + input.setSelectionRange(4, 4); // caret before "d" — 3 raw chars ("abc") precede it + input.dispatchEvent(new Event("input")); + + expect(input.value).toBe("ab-cd"); + // The old algorithm counted the literal "-" as a raw char (rawCharTest was + // `() => true` for "*" masks) and pushed the caret to 5 (end); it must stay at 4. + expect(input.selectionStart).toBe(4); + + dispose(); + document.body.removeChild(input); + }); +}); + +describe("bindField —