fix(react): a11y attrs on DTMobileFilterOverlay

Add the screen-reader semantics that were missing:
- role="dialog" + aria-modal="true" on the content panel
- aria-labelledby pointing at the heading span (id via useId)
- aria-label="Close {heading}" on the ✕ button (was just text)
- aria-hidden="true" on the click-target backdrop

Without these, screen readers can't identify the mobile filter panel
as a dialog, can't announce its label, and the close button reads as
just "X". useId so multiple overlay instances on a page don't collide
on heading IDs.

Surfaced during dt-shopify pre-launch §10 verification — two
MobileFilterMenu.test.tsx assertions were dropped to make the suite
green pending this fix; storefront can put them back now.

Revert: git revert <THIS_COMMIT_SHA>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-06-03 16:38:38 -07:00
parent a459b643e8
commit e33bf70896

View File

@@ -5,7 +5,7 @@
* .dt-filter-overlay-content * .dt-filter-overlay-content
*/ */
import { useEffect, useCallback, useRef, type ReactNode, type CSSProperties } from 'react'; import { useEffect, useCallback, useRef, useId, type ReactNode, type CSSProperties } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { DTVariant } from '@dangerousthings/tokens'; import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx'; import { cx } from '../utils/cx';
@@ -40,6 +40,7 @@ export function DTMobileFilterOverlay({
}: DTMobileFilterOverlayProps) { }: DTMobileFilterOverlayProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null); const triggerRef = useRef<HTMLElement | null>(null);
const headingId = useId();
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@@ -105,8 +106,18 @@ export function DTMobileFilterOverlay({
return createPortal( return createPortal(
<div className={cx('dt-filter-overlay', getVariantClass(variant), className)} style={style}> <div className={cx('dt-filter-overlay', getVariantClass(variant), className)} style={style}>
<div className="dt-filter-overlay-backdrop" onClick={onDismiss} /> <div
<div ref={panelRef} className="dt-filter-overlay-content"> className="dt-filter-overlay-backdrop"
onClick={onDismiss}
aria-hidden="true"
/>
<div
ref={panelRef}
className="dt-filter-overlay-content"
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
>
{/* Header */} {/* Header */}
<div <div
style={{ style={{
@@ -116,7 +127,7 @@ export function DTMobileFilterOverlay({
padding: '16px', padding: '16px',
borderBottom: '1px solid rgba(var(--color-primary-rgb), 0.2)', borderBottom: '1px solid rgba(var(--color-primary-rgb), 0.2)',
}}> }}>
<span style={{ fontWeight: 700, fontSize: '1.125rem', letterSpacing: '0.05em' }}> <span id={headingId} style={{ fontWeight: 700, fontSize: '1.125rem', letterSpacing: '0.05em' }}>
{heading} {heading}
{activeFilterCount !== undefined && activeFilterCount > 0 && ( {activeFilterCount !== undefined && activeFilterCount > 0 && (
<span <span
@@ -144,6 +155,7 @@ export function DTMobileFilterOverlay({
<button <button
onClick={onDismiss} onClick={onDismiss}
type="button" type="button"
aria-label={`Close ${heading}`}
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',