From 8293642f6e5adfdf9f413fb7ac109b01cd0f1927 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 13 May 2026 11:37:30 +0530 Subject: [PATCH] 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 = ({
    {children} @@ -56,7 +58,7 @@ const ListRoot = ({ const ListItem = ({ children, className, ...props }: ListItemProps) => { return ( -
  • +
  • {children}
  • ); @@ -88,12 +90,17 @@ const ListValue = ({ children, className, ...props }: ListValueProps) => { ); }; -const ListHeader = ({ children, className, ...props }: ListHeaderProps) => { +const ListHeader = ({ + children, + className, + level = 3, + ...props +}: ListHeaderProps) => { return (
    {children} diff --git a/packages/raystack/components/scroll-area/scroll-area.tsx b/packages/raystack/components/scroll-area/scroll-area.tsx index 2f1e53023..63cfb0fbe 100644 --- a/packages/raystack/components/scroll-area/scroll-area.tsx +++ b/packages/raystack/components/scroll-area/scroll-area.tsx @@ -15,11 +15,19 @@ export function ScrollArea({ className, type = 'hover', children, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, ...props }: ScrollAreaProps) { + const hasLabel = !!(ariaLabel || ariaLabelledBy); return ( - + {children} diff --git a/packages/raystack/components/select/index.ts b/packages/raystack/components/select/index.ts index 13bcb8262..de9bdf23a 100644 --- a/packages/raystack/components/select/index.ts +++ b/packages/raystack/components/select/index.ts @@ -1 +1 @@ -export { Select } from "./select"; +export { Select } from './select'; diff --git a/packages/raystack/components/select/select-content.tsx b/packages/raystack/components/select/select-content.tsx index c8bc8eb96..5cf64bdc7 100644 --- a/packages/raystack/components/select/select-content.tsx +++ b/packages/raystack/components/select/select-content.tsx @@ -47,10 +47,7 @@ export function SelectContent({ className={styles.comboboxInput} size={12} /> - + {children} diff --git a/packages/raystack/components/separator/index.tsx b/packages/raystack/components/separator/index.tsx index 9bf4f2db5..c99fe35dc 100644 --- a/packages/raystack/components/separator/index.tsx +++ b/packages/raystack/components/separator/index.tsx @@ -1 +1 @@ -export { Separator } from "./separator"; \ No newline at end of file +export { Separator } from './separator'; diff --git a/packages/raystack/components/separator/separator.module.css b/packages/raystack/components/separator/separator.module.css index 46151aa0e..b3f47bc53 100644 --- a/packages/raystack/components/separator/separator.module.css +++ b/packages/raystack/components/separator/separator.module.css @@ -45,4 +45,4 @@ .separator-full[data-orientation="vertical"] { width: 1px; height: 100%; -} +} diff --git a/packages/raystack/components/separator/separator.tsx b/packages/raystack/components/separator/separator.tsx index fedbe3bb5..4c8bd2d91 100644 --- a/packages/raystack/components/separator/separator.tsx +++ b/packages/raystack/components/separator/separator.tsx @@ -22,19 +22,37 @@ const separator = cva(styles.separator, { } }); -type SeparatorProps = SeparatorPrimitive.Props & VariantProps; +type SeparatorProps = SeparatorPrimitive.Props & + VariantProps & { + /** + * When true, the separator is purely decorative and hidden from + * assistive technology. Use for visual dividers that don't convey + * structure. + * @default false + */ + decorative?: boolean; + }; export function Separator({ className, orientation = 'horizontal', size, color, + decorative, ...props }: SeparatorProps) { + const decorativeProps = decorative + ? { + role: 'presentation', + 'aria-hidden': true, + 'aria-orientation': undefined + } + : {}; return ( ); diff --git a/packages/raystack/components/side-panel/index.tsx b/packages/raystack/components/side-panel/index.tsx index 2899d56b2..5c3fc9e19 100644 --- a/packages/raystack/components/side-panel/index.tsx +++ b/packages/raystack/components/side-panel/index.tsx @@ -1 +1 @@ -export { SidePanel } from "./side-panel"; +export { SidePanel } from './side-panel'; diff --git a/packages/raystack/components/side-panel/side-panel.tsx b/packages/raystack/components/side-panel/side-panel.tsx index 59f612f86..27d904863 100644 --- a/packages/raystack/components/side-panel/side-panel.tsx +++ b/packages/raystack/components/side-panel/side-panel.tsx @@ -1,5 +1,5 @@ import { cva, cx, VariantProps } from 'class-variance-authority'; -import { ComponentProps, Fragment, ReactNode } from 'react'; +import { ComponentProps, Fragment, ReactNode, useId } from 'react'; import { Flex } from '../flex'; import { Text } from '../text'; import styles from './side-panel.module.css'; @@ -35,6 +35,7 @@ interface SidePanelHeaderProps extends ComponentProps<'div'> { icon?: ReactNode; actions?: Array; description?: string; + titleId?: string; } const SidePanelHeader = ({ @@ -42,14 +43,17 @@ const SidePanelHeader = ({ icon, actions = [], description, + titleId, ...props }: SidePanelHeaderProps) => { + const generatedId = useId(); + const headingId = titleId ?? generatedId; return (
    {icon} - + } size='large' weight='medium'> {title} diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx index 39550827a..7a0b01d5a 100644 --- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx +++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx @@ -271,7 +271,8 @@ describe('Sidebar', () => { const item = screen.getByTestId('custom-render-item'); expect(item.tagName).toBe('BUTTON'); - expect(item).toHaveAttribute('role', 'listitem'); + // role="listitem" is not applied — items rely on their native semantics + expect(item).not.toHaveAttribute('role'); expect(item).toHaveTextContent('Custom Item'); }); @@ -279,9 +280,7 @@ describe('Sidebar', () => { render(); expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument(); - const dashboardLink = screen.getByRole('listitem', { - name: DASHBOARD_ITEM_TEXT - }); + const dashboardLink = screen.getByLabelText(DASHBOARD_ITEM_TEXT); expect(dashboardLink).toHaveAttribute('aria-label', DASHBOARD_ITEM_TEXT); }); }); @@ -387,9 +386,7 @@ describe('Sidebar', () => { ); - expect( - screen.getByRole('listitem', { name: DASHBOARD_ITEM_TEXT }) - ).toBeInTheDocument(); + expect(screen.getByLabelText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument(); }); it('renders right icon when provided in collapsible header', () => { diff --git a/packages/raystack/components/sidebar/sidebar-item.tsx b/packages/raystack/components/sidebar/sidebar-item.tsx index 2c35e86a5..de7b4539d 100644 --- a/packages/raystack/components/sidebar/sidebar-item.tsx +++ b/packages/raystack/components/sidebar/sidebar-item.tsx @@ -83,7 +83,6 @@ export function SidebarItem({ 'data-disabled': disabled, 'aria-current': active ? 'page' : undefined, 'aria-disabled': disabled, - ...(!insideSidebarMore ? { role: 'listitem' } : {}), ...(isCollapsed && typeof children === 'string' && !insideSidebarMore ? { 'aria-label': children } : {}), diff --git a/packages/raystack/components/skeleton/index.ts b/packages/raystack/components/skeleton/index.ts index 9fe316ee8..9147f13a9 100644 --- a/packages/raystack/components/skeleton/index.ts +++ b/packages/raystack/components/skeleton/index.ts @@ -1 +1 @@ -export { Skeleton } from './skeleton'; \ No newline at end of file +export { Skeleton } from './skeleton'; diff --git a/packages/raystack/components/skeleton/skeleton.module.css b/packages/raystack/components/skeleton/skeleton.module.css index f495b4e32..9467e91b0 100644 --- a/packages/raystack/components/skeleton/skeleton.module.css +++ b/packages/raystack/components/skeleton/skeleton.module.css @@ -15,7 +15,7 @@ } .animate::after { - content: ''; + content: ""; position: absolute; inset: 0; background: linear-gradient( @@ -40,4 +40,3 @@ animation: shimmer var(--skeleton-duration) infinite; } } - diff --git a/packages/raystack/components/skeleton/skeleton.tsx b/packages/raystack/components/skeleton/skeleton.tsx index 06c4b8c27..23f0e13aa 100644 --- a/packages/raystack/components/skeleton/skeleton.tsx +++ b/packages/raystack/components/skeleton/skeleton.tsx @@ -58,6 +58,7 @@ const SkeletonBase = (props: SkeletonProps) => { return (