Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/visually-hidden-fixed-position.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { useAutocompleteContext } from '../root/context';
import { visuallyHiddenStyle } from '../../primitives/visually-hidden';

/**
* Autocomplete.Status - Visually-hidden live region that announces the number
Expand All @@ -15,9 +16,6 @@
count === 0 ? 'No results available' : `${count} result${count === 1 ? '' : 's'} available`
}: AutocompleteStatusProps = $props();

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 ctx = useAutocompleteContext();
const message = $derived(formatMessage(ctx.visibleCount));
</script>
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/lib/button/root/button-root.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -429,13 +430,14 @@
<!--
The polite live region is a sibling of the button, not a child: inside the button its text
would be concatenated into the button's computed accessible name ("Saving Saving, pending").
Visually hidden via inline styles because the library is headless.
Visually hidden with the shared fixed-position style: an absolute one inside a scroll
container made Chrome promote the sticky content around it to its own layer, which blurred
its text.
-->
<span
data-button-live-region="true"
role="status"
aria-live="polite"
aria-atomic="true"
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;"
>{pendingAnnouncement}</span
style={visuallyHiddenStyle}>{pendingAnnouncement}</span
>
10 changes: 10 additions & 0 deletions packages/ui/src/lib/button/root/button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[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<HTMLButtonElement>('[data-button-root="true"]');
Expand Down
10 changes: 3 additions & 7 deletions packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -506,18 +507,13 @@
>
<div data-wheel-spacer="top" style={`height:${spacerHeight}px`}></div>
<!--
Visually-hidden live region using inline styles so the headless library
Visually-hidden live region using the shared inline style so the headless library
does not depend on an external `sr-only` utility class (e.g. Tailwind).
Kept in addition to aria-valuetext: valuetext changes are only announced
while the spinbutton has DOM focus, whereas this also covers pointer and
touch scrolling without focus.
-->
<span
role="status"
aria-live="polite"
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0"
>{valueText}</span
>
<span role="status" aria-live="polite" style={visuallyHiddenStyle}>{valueText}</span>
{#each options as option, index (option.value)}
{#if children}
{@render children(option, selectedValue === option.value)}
Expand Down
4 changes: 1 addition & 3 deletions packages/ui/src/lib/combobox/status/combobox-status.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
Expand Down
7 changes: 2 additions & 5 deletions packages/ui/src/lib/dropzone/dropzone.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -315,10 +316,6 @@
onchange={handleInputChange}
/>

<span
role="status"
aria-live="polite"
aria-atomic="true"
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;"
<span role="status" aria-live="polite" aria-atomic="true" style={visuallyHiddenStyle}
>{announcement}</span
>
29 changes: 29 additions & 0 deletions packages/ui/src/lib/primitives/visually-hidden.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
16 changes: 16 additions & 0 deletions packages/ui/src/lib/primitives/visually-hidden.ts
Original file line number Diff line number Diff line change
@@ -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 `<input>` or `<select>`) keep
* their own absolute style: the browser anchors its validation bubble to them, so they have to
* stay where the visible control is.
*/
export 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;';
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
useTableContext
} from '../root/context.svelte';
import type { TableColumnResizerProps } from '../types.js';
import { visuallyHiddenStyle } from '../utils/visually-hidden-style';
import { visuallyHiddenStyle } from '../../primitives/visually-hidden';
import {
shouldShowFocusVisible,
trackInteractionModality
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/lib/table/root/table-root.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
trackInteractionModality
} from '../../primitives/input-modality';
import { watchFocusVisible } from '../../primitives/focus-visible.svelte';
import { visuallyHiddenStyle } from '../utils/visually-hidden-style';
import { visuallyHiddenStyle } from '../../primitives/visually-hidden';
import type { TableRootProps } from '../types.js';
import {
createTableContext,
Expand Down
2 changes: 0 additions & 2 deletions packages/ui/src/lib/table/utils/visually-hidden-style.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/ui/src/lib/toast/viewport/toast-viewport.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { getFocusableElements } from '../../primitives/focus-trap';
import { shouldShowFocusVisible } from '../../primitives/input-modality';
import { SWIPE_IGNORE_ATTRIBUTE } from '../../primitives/swipe-gesture';
import { visuallyHiddenStyle } from '../../table/utils/visually-hidden-style';
import { visuallyHiddenStyle } from '../../primitives/visually-hidden';
import { useToastProviderContext } from '../provider/context';
import type { ToastItem } from '../provider/toast-manager.svelte';
import type { ToastViewportProps } from '../types.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { useLocaleContextOptional } from '../../locale-provider/context';
import { useTransferListContext } from '../root/context';
import type { TransferListMoveDetails } from '../root/types';
import { visuallyHiddenStyle } from '../../primitives/visually-hidden';

/**
* TransferList.Status - Visually-hidden live region that announces each move.
Expand All @@ -25,9 +26,6 @@

const ctx = useTransferListContext('TransferList.Status');

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 message = $derived.by(() => {
const move = ctx.lastMove;
if (!move) return '';
Expand Down
Loading