From 8293642f6e5adfdf9f413fb7ac109b01cd0f1927 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 13 May 2026 11:37:30 +0530 Subject: [PATCH 1/8] fix: accessibility baseline pass across components (#673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the cross-component a11y gaps tracked in #673 with one PR. Each change is targeted at the specific WCAG / ARIA issue called out in the child issues — no broader refactors. Component-level fixes - Tooltip: aria-label / aria-labelledby flow to Popup for ReactNode messages - TextArea: aria-invalid / aria-required propagated from Field context - Table: Table.Head defaults scope="col"; SectionHeader uses scope="colgroup"; new Table.Caption sub-component - Spinner: drop conflicting aria-hidden + status combo; add ariaLabel (default "Loading"); aria-hidden override cleanly demotes to decorative - Skeleton: aria-hidden="true" on decorative placeholder container - Sidebar: remove orphan role="listitem" (items rely on native element semantics) - SidePanel: title renders as

with generated id, new titleId prop for aria-labelledby wiring - Drawer: aria-label / aria-labelledby customisable; new closeLabel prop (default "Close") - Separator: new `decorative` prop -> role="presentation" + aria-hidden - Select: remove conflicting aria-multiselectable on Combobox list (data-multiselectable retained for styling) - ScrollArea: aria-label / aria-labelledby apply role="region" to viewport - List: keep explicit role="list" (Safari drops implicit role when list-style:none); drop redundant role="listitem"; remove generic default aria-label="List"; new `level` prop on List.Header (default 3, was hardcoded) - Link: drop redundant role="link"; use children for aria-label only when string (no more "[object Object]") - Label: new requiredText + showRequiredIndicator props to balance the existing (optional) indicator - Input: aria-invalid / aria-required from Field context; leading / trailing icon wrappers marked aria-hidden - Image: drop redundant role="img" and aria-label={alt} (native alt is the accessible name) - IconButton: drop redundant aria-disabled; strengthen aria-label guidance in JSDoc - Container: no default role="region"; role applied only when an aria-label / aria-labelledby is supplied - Button: aria-busy when loading; internal spinner marked aria-hidden so the button speaks for itself - AnnouncementBar: action rendered as a real ) : null} ); diff --git a/packages/raystack/components/announcement-bar/index.tsx b/packages/raystack/components/announcement-bar/index.tsx index 95cbf88c9..8d1cbf37f 100644 --- a/packages/raystack/components/announcement-bar/index.tsx +++ b/packages/raystack/components/announcement-bar/index.tsx @@ -1 +1 @@ -export { AnnouncementBar } from "./announcement-bar"; +export { AnnouncementBar } from './announcement-bar'; diff --git a/packages/raystack/components/button/__tests__/button.test.tsx b/packages/raystack/components/button/__tests__/button.test.tsx index 375d80beb..dbafbd9ad 100644 --- a/packages/raystack/components/button/__tests__/button.test.tsx +++ b/packages/raystack/components/button/__tests__/button.test.tsx @@ -99,20 +99,26 @@ describe('Button', () => { }); it('handles loading state', () => { - render(); + const { container } = render(); const button = screen.getByRole('button'); expect(button).toHaveClass(styles['button-loading']); - expect(screen.getByRole('status', { hidden: true })).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-busy', 'true'); + // Spinner is rendered as a decorative aria-hidden element inside the button + expect( + container.querySelector('[aria-hidden="true"]') + ).toBeInTheDocument(); }); it('shows loader text when loading', () => { - render( + const { container } = render( ); expect(screen.getByText('Please wait...')).toBeInTheDocument(); - expect(screen.getByRole('status', { hidden: true })).toBeInTheDocument(); + expect( + container.querySelector('[aria-hidden="true"]') + ).toBeInTheDocument(); }); it('does not show children when loading', () => { diff --git a/packages/raystack/components/button/button.module.css b/packages/raystack/components/button/button.module.css index ea8b7a14e..784c99610 100644 --- a/packages/raystack/components/button/button.module.css +++ b/packages/raystack/components/button/button.module.css @@ -558,4 +558,4 @@ .button-text-success:disabled:hover, .button-text-success.button-loading:hover { color: var(--rs-color-foreground-success-primary); -} \ No newline at end of file +} diff --git a/packages/raystack/components/button/button.tsx b/packages/raystack/components/button/button.tsx index d523842ca..b4571146e 100644 --- a/packages/raystack/components/button/button.tsx +++ b/packages/raystack/components/button/button.tsx @@ -167,11 +167,12 @@ export const Button = ({ render={render} nativeButton={!render} focusableWhenDisabled={loading} + aria-busy={loading || undefined} {...props} > {loading ? ( <> - + , ...props }: LinkProps) { + const userAriaLabel = props['aria-label']; + const textLabel = typeof children === 'string' ? children : undefined; + const externalProps = external ? { target: '_blank', rel: 'noopener noreferrer', - 'aria-label': `${children} (opens in new tab)` + 'aria-label': + userAriaLabel ?? + (textLabel ? `${textLabel} (opens in new tab)` : undefined) } : {}; const downloadProps = download ? { download: typeof download === 'string' ? download : true, - 'aria-label': `${children} (download)` + 'aria-label': + userAriaLabel ?? (textLabel ? `${textLabel} (download)` : undefined) } : {}; @@ -40,7 +46,6 @@ export function Link({ className={cx(styles.link, className)} variant={variant} size={size} - role='link' {...externalProps} {...downloadProps} {...props} diff --git a/packages/raystack/components/list/__tests__/list.test.tsx b/packages/raystack/components/list/__tests__/list.test.tsx index e7efd6e84..900e92bc4 100644 --- a/packages/raystack/components/list/__tests__/list.test.tsx +++ b/packages/raystack/components/list/__tests__/list.test.tsx @@ -45,10 +45,22 @@ describe('List', () => { expect(list).toHaveStyle({ maxWidth: '400px' }); }); - it('has default aria-label', () => { + it('does not set a generic default aria-label', () => { render(Content); const list = screen.getByRole('list'); - expect(list).toHaveAttribute('aria-label', 'List'); + expect(list).not.toHaveAttribute('aria-label'); + }); + + it('forwards user-provided aria-label', () => { + render(Content); + const list = screen.getByRole('list'); + expect(list).toHaveAttribute('aria-label', 'Recent activity'); + }); + + it('keeps explicit role="list" for Safari/VoiceOver', () => { + render(Content); + const list = screen.getByRole('list'); + expect(list).toHaveAttribute('role', 'list'); }); }); diff --git a/packages/raystack/components/list/index.tsx b/packages/raystack/components/list/index.tsx index fc80cd36e..53821c4bd 100644 --- a/packages/raystack/components/list/index.tsx +++ b/packages/raystack/components/list/index.tsx @@ -1 +1 @@ -export { List } from "./list"; \ No newline at end of file +export { List } from './list'; diff --git a/packages/raystack/components/list/list.tsx b/packages/raystack/components/list/list.tsx index d4fcd6961..86bab9661 100644 --- a/packages/raystack/components/list/list.tsx +++ b/packages/raystack/components/list/list.tsx @@ -32,6 +32,7 @@ interface ListValueProps extends ComponentProps<'span'> { interface ListHeaderProps extends ComponentProps<'div'> { children: ReactNode; + level?: 1 | 2 | 3 | 4 | 5 | 6; } const ListRoot = ({ @@ -45,8 +46,9 @@ const ListRoot = ({