From 626ca977453552078f11712afacbee3782d22da5 Mon Sep 17 00:00:00 2001 From: Agustin Delgado Date: Sat, 24 Oct 2026 18:52:39 -0300 Subject: [PATCH] fix(a11y): keep visually hidden live regions fixed, so they never escape a scroll container --- .changeset/visually-hidden-fixed-position.md | 5 ++++ .../status/autocomplete-status.svelte | 4 +-- .../ui/src/lib/button/root/button-root.svelte | 8 +++-- .../ui/src/lib/button/root/button.test.ts | 10 +++++++ .../wheel-column/clock-wheel-column.svelte | 10 ++----- .../combobox/status/combobox-status.svelte | 4 +-- packages/ui/src/lib/dropzone/dropzone.svelte | 7 ++--- .../lib/primitives/visually-hidden.test.ts | 29 +++++++++++++++++++ .../ui/src/lib/primitives/visually-hidden.ts | 16 ++++++++++ .../table-column-resizer.svelte | 2 +- .../ui/src/lib/table/root/table-root.svelte | 2 +- .../lib/table/utils/visually-hidden-style.ts | 2 -- .../lib/toast/viewport/toast-viewport.svelte | 2 +- .../status/transfer-list-status.svelte | 4 +-- 14 files changed, 76 insertions(+), 29 deletions(-) create mode 100644 .changeset/visually-hidden-fixed-position.md create mode 100644 packages/ui/src/lib/primitives/visually-hidden.test.ts create mode 100644 packages/ui/src/lib/primitives/visually-hidden.ts delete mode 100644 packages/ui/src/lib/table/utils/visually-hidden-style.ts diff --git a/.changeset/visually-hidden-fixed-position.md b/.changeset/visually-hidden-fixed-position.md new file mode 100644 index 00000000..4d6429dc --- /dev/null +++ b/.changeset/visually-hidden-fixed-position.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': patch +--- + +Fix: the screen reader live regions of `Button`, `Dropzone` and `TimePicker` wheel columns now use `position: fixed`, not `position: absolute`. An absolute live region inside a scroll container that is not positioned is laid out against an ancestor outside the container. Chrome then puts the sticky content near it, such as a page header, in its own compositing layer, and the text of that content loses subpixel anti-aliasing. All the live regions of the library now use one shared style. diff --git a/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte b/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte index 06678d15..b4752e55 100644 --- a/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte +++ b/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte @@ -1,5 +1,6 @@ diff --git a/packages/ui/src/lib/button/root/button-root.svelte b/packages/ui/src/lib/button/root/button-root.svelte index 43cb3a0f..f9358069 100644 --- a/packages/ui/src/lib/button/root/button-root.svelte +++ b/packages/ui/src/lib/button/root/button-root.svelte @@ -7,6 +7,7 @@ } from '../../primitives/input-modality'; import { watchFocusVisible } from '../../primitives/focus-visible.svelte'; import { cn } from '../../utils/cn'; + import { visuallyHiddenStyle } from '../../primitives/visually-hidden'; export type ButtonRenderState = { hovered: boolean; @@ -429,13 +430,14 @@ {pendingAnnouncement}{pendingAnnouncement} diff --git a/packages/ui/src/lib/button/root/button.test.ts b/packages/ui/src/lib/button/root/button.test.ts index c400776f..bd9b261e 100644 --- a/packages/ui/src/lib/button/root/button.test.ts +++ b/packages/ui/src/lib/button/root/button.test.ts @@ -170,6 +170,16 @@ describe('Button.Root', () => { expect(buttonElement.textContent).not.toContain(', pending'); }); + it('keeps the live region fixed, so it never escapes a scroll container', () => { + // An absolute live region inside an unpositioned scroller made Chrome promote the sticky + // content around it to its own layer, which dropped subpixel anti-aliasing from its text. + render(ButtonTest, { pending: true }); + const status = document.querySelector('[data-button-live-region="true"]'); + + expect(status).not.toBeNull(); + expect(getComputedStyle(status as HTMLElement).position).toBe('fixed'); + }); + it('updates to pending dynamically and suppresses later activation', async () => { render(ButtonTest, { type: 'submit' }); const button = document.querySelector('[data-button-root="true"]'); diff --git a/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte b/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte index 34e11f3d..69cf9953 100644 --- a/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte +++ b/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte @@ -2,6 +2,7 @@ import type { Snippet } from 'svelte'; import type { HTMLAttributes } from 'svelte/elements'; import { resolveLocalizedString } from '../../internal/localized-strings'; + import { visuallyHiddenStyle } from '../../primitives/visually-hidden'; import { useClockContext, type ClockEditableSegmentType } from '../root/context'; import TimePickerWheelItem from '../wheel-item/clock-wheel-item.svelte'; import { useWheelScroll, type WheelScrollBehavior } from '../hooks/use-wheel-scroll.svelte'; @@ -506,18 +507,13 @@ >
- {valueText} + {valueText} {#each options as option, index (option.value)} {#if children} {@render children(option, selectedValue === option.value)} diff --git a/packages/ui/src/lib/combobox/status/combobox-status.svelte b/packages/ui/src/lib/combobox/status/combobox-status.svelte index 79cf7ff6..dad79d4c 100644 --- a/packages/ui/src/lib/combobox/status/combobox-status.svelte +++ b/packages/ui/src/lib/combobox/status/combobox-status.svelte @@ -3,6 +3,7 @@ import { resolveLocalizedString } from '../../internal/localized-strings'; import { useLocaleContextOptional } from '../../locale-provider/context'; import { useComboBoxContext } from '../root/context'; + import { visuallyHiddenStyle } from '../../primitives/visually-hidden'; /** * ComboBox.Status - Visually-hidden live region that announces the number of @@ -25,9 +26,6 @@ const ctx = useComboBoxContext(); - const visuallyHiddenStyle = - 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;'; - const defaultMessage = $derived.by(() => { const count = ctx.visibleCount; if (count === 0) return resolveLocalizedString($localeStore, 'combobox.noResults'); diff --git a/packages/ui/src/lib/dropzone/dropzone.svelte b/packages/ui/src/lib/dropzone/dropzone.svelte index 5ff24b27..397cb625 100644 --- a/packages/ui/src/lib/dropzone/dropzone.svelte +++ b/packages/ui/src/lib/dropzone/dropzone.svelte @@ -2,6 +2,7 @@ import { untrack, type Snippet } from 'svelte'; import type { HTMLButtonAttributes } from 'svelte/elements'; import { shouldShowFocusVisible, trackInteractionModality } from '../primitives/input-modality'; + import { visuallyHiddenStyle } from '../primitives/visually-hidden'; import type { ClassValue } from '../utils/cn'; import { composeEventHandlers } from '../utils/compose-event-handlers'; @@ -315,10 +316,6 @@ onchange={handleInputChange} /> -{announcement} diff --git a/packages/ui/src/lib/primitives/visually-hidden.test.ts b/packages/ui/src/lib/primitives/visually-hidden.test.ts new file mode 100644 index 00000000..db7b9f16 --- /dev/null +++ b/packages/ui/src/lib/primitives/visually-hidden.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { visuallyHiddenStyle } from './visually-hidden'; + +function styleOf(css: string): CSSStyleDeclaration { + const element = document.createElement('span'); + element.setAttribute('style', css); + return element.style; +} + +describe('visuallyHiddenStyle', () => { + it('lays the element out against the viewport, so it never escapes a scroll container', () => { + const style = styleOf(visuallyHiddenStyle); + + expect(style.position).toBe('fixed'); + expect(style.top).toBe('0px'); + expect(style.left).toBe('0px'); + }); + + it('hides the element visually and keeps it in the accessibility tree', () => { + const style = styleOf(visuallyHiddenStyle); + + expect(style.width).toBe('1px'); + expect(style.height).toBe('1px'); + expect(style.overflow).toBe('hidden'); + expect(style.clip).toBe('rect(0px, 0px, 0px, 0px)'); + expect(style.display).toBe(''); + expect(style.visibility).toBe(''); + }); +}); diff --git a/packages/ui/src/lib/primitives/visually-hidden.ts b/packages/ui/src/lib/primitives/visually-hidden.ts new file mode 100644 index 00000000..03a0db06 --- /dev/null +++ b/packages/ui/src/lib/primitives/visually-hidden.ts @@ -0,0 +1,16 @@ +/** + * Inline style for content that only assistive technology reads, such as a live region. + * Inline, because the library is headless and must not depend on an `sr-only` class. + * + * `position: fixed`, not `absolute`: an absolutely positioned element inside a scroll container + * that is not itself positioned is laid out against an ancestor outside the scroller. While the + * container scrolls, Chrome then promotes the content around it (a sticky header, say) to its own + * compositing layer to keep the element in place, and that layer drops subpixel anti-aliasing + * from its text. A fixed element is laid out against the viewport and never escapes a scroller. + * + * Native form controls that stand in for a custom one (a hidden `` or `