From 568be2dae8850038965cf2178e892bfd41603c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:06:52 +0000 Subject: [PATCH 1/2] fix(layout,app-shell): a hidden icon-mode group label is no focus stop and is not announced (objectui#12795) In the collapsed icon sidebar the SidebarGroupLabel primitive hides a label with opacity-0 only, so a collapsible group's label button stayed in the tab order (Tab from the row above landed on it and Enter toggled a group the user could not see), and every label stayed in the accessibility tree. Every SidebarGroupLabel call site (NavigationRenderer, AppSchemaRenderer, SidebarNav, UnifiedSidebar) now adds a variant-scoped `invisible`, with `visibility` joining the transition list under the same variant so the fade-out is kept. The expanded sidebar and the mobile sheet are unchanged. An enumeration pin asserts every call site carries the classes. Claude-Session: https://claude.ai/code/session_016gcD8krj7QLd3njFMYsi1g Co-authored-by: Claude --- .../12795-icon-label-focus-app-shell.md | 7 + .changeset/12795-icon-label-focus-layout.md | 9 + .../app-shell/src/layout/UnifiedSidebar.tsx | 22 +- ...dSidebar.iconModeLabelFocus-12795.test.tsx | 308 +++++++++++++++++ packages/layout/src/AppSchemaRenderer.tsx | 20 +- packages/layout/src/NavigationRenderer.tsx | 28 +- packages/layout/src/SidebarNav.tsx | 20 +- .../sidebarGroupLabel.callSites-12795.test.ts | 130 ++++++++ ...barGroupLabel.iconModeFocus-12795.test.tsx | 314 ++++++++++++++++++ 9 files changed, 851 insertions(+), 7 deletions(-) create mode 100644 .changeset/12795-icon-label-focus-app-shell.md create mode 100644 .changeset/12795-icon-label-focus-layout.md create mode 100644 packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx create mode 100644 packages/layout/src/__tests__/sidebarGroupLabel.callSites-12795.test.ts create mode 100644 packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx diff --git a/.changeset/12795-icon-label-focus-app-shell.md b/.changeset/12795-icon-label-focus-app-shell.md new file mode 100644 index 0000000000..bfcc8e9af2 --- /dev/null +++ b/.changeset/12795-icon-label-focus-app-shell.md @@ -0,0 +1,7 @@ +--- +'@object-ui/app-shell': patch +--- + +In the collapsed (icon-mode) console sidebar, the hidden Area, Recent and Starred group labels are no longer announced by screen readers (objectui#12795). The `SidebarGroupLabel` primitive hides a label there by fading it to `opacity-0`, which left it in the accessibility tree; the labels now also take `visibility: hidden` under the primitive's own icon-mode condition, and still fade out before they are hidden. Together with the `@object-ui/layout` change, Tab in the icon rail now moves from a group's last row straight to the next group's first row instead of stopping on an invisible label. + +In the expanded sidebar and the mobile sheet nothing changes: the Recent label still opens and closes Recent. No export, prop or type member is added. diff --git a/.changeset/12795-icon-label-focus-layout.md b/.changeset/12795-icon-label-focus-layout.md new file mode 100644 index 0000000000..9e71180891 --- /dev/null +++ b/.changeset/12795-icon-label-focus-layout.md @@ -0,0 +1,9 @@ +--- +'@object-ui/layout': patch +--- + +In the collapsed (icon-mode) sidebar, a hidden group label is no longer a keyboard focus stop, and screen readers no longer announce it (objectui#12795). Before, Tab from the row above a collapsible group landed on the group's invisible label button, and Enter toggled a group the user could not see. + +The `SidebarGroupLabel` primitive hides a label there by fading it to `opacity-0`, which left it in the tab order and the accessibility tree. `NavigationRenderer`'s collapsible group and Favorites labels, `AppSchemaRenderer`'s Area label and `SidebarNav`'s group labels now also take `visibility: hidden` under the primitive's own icon-mode condition. The label still fades out before it is hidden. Tab now moves from a group's last row straight to the next group's first row. + +In the expanded sidebar and the mobile sheet nothing changes: every group label that took focus still does, and Enter still opens and closes its group. The primitive itself is a Shadcn-synced file and is not edited, and no export, prop or type member is added. diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 6ce907421c..ea6876d78c 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -85,6 +85,22 @@ import { SidebarItemMenu, sidebarItemDesignPath, favoriteDesignPath } from './Si const HIDDEN_ICON_LABEL_NO_POINTER = 'group-data-[state=collapsed]:group-data-[collapsible=icon]:pointer-events-none'; +/** + * The classes every group label here carries so that, once the sidebar + * collapses to icons, a hidden label is no keyboard focus stop and nothing a + * screen reader announces (objectui#12795). The primitive draws the label at + * `opacity-0` there, which leaves it in the tab order and in the accessibility + * tree; `invisible` takes it out of both, under the primitive's own hiding + * condition, so the expanded sidebar is unchanged. `visibility` joins the + * primitive's transition list under the same condition, so the label still + * fades out before it is hidden, and on expand it is visible at once and fades + * in. `NavigationRenderer` (in `@object-ui/layout`) gives its group labels the + * same pair, and a pin enumerates every `SidebarGroupLabel` call site. + */ +const HIDDEN_ICON_LABEL_NOT_FOCUSABLE = + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:invisible ' + + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:transition-[margin,opacity,visibility]'; + /** * The classes the sidebar content carries so that, once the sidebar collapses * to icons, the rail scrolls vertically when its rows overflow (objectui#12794). @@ -521,7 +537,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { area visibility is derived from the items inside (#3319) */} {visibleAreas.length > 1 && ( - + {t('sidebar.area', { defaultValue: 'Area' })} @@ -580,7 +596,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { {recentItems.length > 0 && ( setRecentExpanded(prev => !prev)} > @@ -667,7 +683,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { {/* Starred Apps */} {favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').length > 0 && ( - + {t('sidebar.starred', { defaultValue: 'Starred' })} diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx new file mode 100644 index 0000000000..cd2c929f4d --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment jsdom +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * UnifiedSidebar — in the collapsed (icon-mode) sidebar a hidden group label + * is no keyboard focus stop and is not in the accessibility tree + * (objectui#12795). This file covers the app-shell call sites: the Area, + * Recent and Starred labels, and Tab through the navigation the sidebar + * renders with `NavigationRenderer`. + * + * The `SidebarGroupLabel` primitive hides a label there with `opacity-0` and + * `-mt-8` only, so a collapsible group's label (a button) stayed a Tab stop, + * and every label stayed in the accessibility tree. Each call site now adds a + * variant-scoped `invisible`. + * + * Instrument: the REAL Tailwind compiler. The labels' class tokens are + * compiled with `tailwindcss` and injected, so the DOM computes `visibility` + * from the classes the labels carry; user-event's Tab and Testing Library's + * role queries then read behaviour. jsdom, because happy-dom does not match + * the `:is(:where(.group)[data-state="collapsed"] *)` selectors Tailwind emits + * for group variants. The layout package's + * `sidebarGroupLabel.iconModeFocus-12795.test.tsx` documents the instrument. + * + * Harness: the provider / chrome mocks of + * `UnifiedSidebar.iconModeRecentLabel-12744.test.tsx`; `@object-ui/layout` and + * `@object-ui/components` (the real `SidebarProvider`, `Sidebar` and + * `SidebarGroupLabel`) stay REAL. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import { isInaccessible } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { compile } from 'tailwindcss'; + +/** What the recent-items and favorites stores hold, and which arm renders. */ +const state = vi.hoisted(() => ({ + recent: [] as unknown[], + favorites: [] as unknown[], + context: 'app' as 'app' | 'home', +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback, + dashboardLabel: ({ label }: { label?: string }) => label, + appLabel: ({ label }: { label?: string }) => label, + }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: null, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +vi.mock('@object-ui/permissions', async (importOriginal) => ({ + ...(await importOriginal()), + usePermissions: () => ({ can: () => true, hasCapabilities: () => true }), +})); + +let metadataState: { apps: unknown[]; objects: unknown[] }; +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => metadataState, +})); + +vi.mock('../../providers/ExpressionProvider', () => ({ + useExpressionContext: () => ({ evaluator: null }), + evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false', + evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false', +})); + +vi.mock('../../utils', () => ({ + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => + apps.find((a) => a?.name === segment), + appRouteSegment: (app: { name?: string }) => app?.name, +})); + +vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null })); +vi.mock('../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: state.recent }) })); +vi.mock('../../hooks/useFavorites', () => ({ + useFavorites: () => ({ favorites: state.favorites, removeFavorite: vi.fn() }), +})); +vi.mock('../../hooks/useNavPins', () => ({ + useNavPins: () => ({ togglePin: vi.fn(), applyPins: (items: unknown) => items }), +})); +vi.mock('../../hooks/useNavActionDispatch', () => ({ + useNavActionDispatch: () => vi.fn(), +})); +vi.mock('../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ context: state.context, currentAppName: 'crm' }), +})); +vi.mock('../ContextSelectors', () => ({ + useAppContextSelectors: () => ({ contextValues: {}, element: null }), + contextSelectorQueryKey: (id: string) => (id === 'active_package' ? 'package' : id), + STUDIO_PACKAGE_SELECTOR_ID: 'active_package', +})); +vi.mock('../LocalizedSidebarTrigger', () => ({ + LocalizedSidebarTrigger: () => null, +})); + +import { SidebarProvider } from '@object-ui/components'; +import { UnifiedSidebar } from '../UnifiedSidebar'; + +const tailwind = await compile('@tailwind utilities;'); + +beforeAll(() => { + // jsdom has no `matchMedia`; the sidebar's `useIsMobile` reads it. A desktop + // width keeps the sidebar out of its mobile sheet. + window.matchMedia ??= ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; +}); + +let sheets: HTMLStyleElement[] = []; +afterEach(() => { + cleanup(); + for (const s of sheets) s.remove(); + sheets = []; +}); + +const RECENT = { + id: 'metadata:object:account', + type: 'metadata', + label: 'account', + href: '/apps/crm/metadata/object/account', + visitedAt: '2026-10-16T09:00:00.000Z', +}; + +const STARRED = { id: 'fav_account', type: 'object', label: 'Starred accounts', href: '/apps/crm/account' }; + +const obj = (id: string, label: string) => ({ id, type: 'object', objectName: id, label }); + +/** + * Two areas (so the Area label draws), each area's navigation a row then an + * open group, and the Recent group below the navigation. + */ +const APP = { + name: 'crm', + label: 'CRM', + active: true, + areas: [ + { + id: 'area_work', + label: 'Work', + navigation: [ + obj('page_authoring', 'Page Authoring'), + { id: 'grp_workspace', type: 'group', label: 'Workspace', expanded: true, children: [obj('my_work', 'My Work'), obj('tasks', 'Tasks')] }, + ], + }, + { id: 'area_money', label: 'Money', navigation: [obj('invoices', 'Invoices')] }, + ], +}; + +const labelsIn = (root: ParentNode) => + Array.from(root.querySelectorAll('[data-sidebar="group-label"]')); + +function sidebarUi(open: boolean) { + metadataState = { apps: [APP], objects: [] }; + const view = render( + + + + + , + ); + const candidates = new Set(); + for (const label of labelsIn(view.container)) { + for (const t of label.className.split(/\s+/)) if (t) candidates.add(t); + } + const sheet = document.createElement('style'); + sheet.textContent = tailwind.build([...candidates]); + document.head.appendChild(sheet); + sheets.push(sheet); + return view; +} + +const tokens = (el: Element) => el.className.split(/\s+/).filter(Boolean); + +/** The variant prefix the primitive hides this label under, read off its `…:opacity-0` token. */ +function hidingPrefix(label: Element): string { + const hide = tokens(label).find((t) => t.endsWith(':opacity-0')); + expect(hide, 'the primitive hides the label with a variant-scoped opacity-0').toBeDefined(); + return hide!.slice(0, -'opacity-0'.length); +} + +const labelByText = (text: string) => + screen.getByText(text).closest('[data-sidebar="group-label"]') as HTMLElement; + +function expectHidden(label: HTMLElement) { + const prefix = hidingPrefix(label); + expect(tokens(label)).toContain(`${prefix}invisible`); + expect(tokens(label)).toContain(`${prefix}transition-[margin,opacity,visibility]`); + expect(getComputedStyle(label).visibility).toBe('hidden'); + expect(isInaccessible(label)).toBe(true); +} + +function expectShown(label: HTMLElement) { + expect(getComputedStyle(label).visibility).not.toBe('hidden'); + expect(isInaccessible(label)).toBe(false); +} + +const sidebarOf = (container: HTMLElement) => container.querySelector('[data-sidebar="sidebar"]') as HTMLElement; + +/** + * Tabs from `start` until focus leaves the sidebar, and returns the rows and + * labels that took focus, in order. Each row's own menu button is left out: + * the browser hides it in icon mode through classes this file does not + * compile, so in this DOM it would read as a stop it is not. + */ +async function tabWalk(start: HTMLElement, root: HTMLElement) { + const user = userEvent.setup(); + start.focus(); + const seen: HTMLElement[] = [start]; + for (let i = 0; i < 50; i++) { + await user.tab(); + const el = document.activeElement as HTMLElement | null; + if (!el || !root.contains(el) || el === start) break; + seen.push(el); + } + return seen.filter((el) => el.tagName === 'A' || el.closest('[data-sidebar="group-label"]')); +} + +beforeEach(() => { + localStorage.clear(); + document.cookie = 'sidebar_state=; path=/; max-age=0'; + state.recent = [RECENT]; + state.favorites = []; + state.context = 'app'; +}); + +describe('UnifiedSidebar — a hidden group label is no focus stop in icon mode (objectui#12795)', () => { + it('collapsed to icons: the Area, Workspace and Recent labels are hidden and not announced, and Tab skips them', async () => { + const { container } = sidebarUi(false); + const peer = container.querySelector('.group.peer[data-state]'); + expect(peer).toHaveAttribute('data-state', 'collapsed'); + expect(peer).toHaveAttribute('data-collapsible', 'icon'); + + const labels = labelsIn(container); + expect(labels).toHaveLength(3); + for (const label of labels) expectHidden(label); + expect(labelByText('Area')).toBe(labels[0]); + expect(labelByText('Recent')).toBe(labels[2]); + expect(screen.queryByRole('button', { name: 'Workspace' })).not.toBeInTheDocument(); + + const walk = await tabWalk(screen.getByRole('link', { name: 'Page Authoring' }), sidebarOf(container)); + expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'My Work', 'Tasks']); + expect(walk.filter((el) => el.closest('[data-sidebar="group-label"]'))).toEqual([]); + }); + + it('collapsed to icons (home arm): the Starred label is hidden and not announced', () => { + state.context = 'home'; + state.favorites = [STARRED]; + const { container } = sidebarUi(false); + const labels = labelsIn(container); + expect(labels).toHaveLength(1); + expect(labelByText('Starred')).toBe(labels[0]); + expectHidden(labels[0]); + }); + + it('expanded (CONTROL): the Workspace label is still a focus stop after Page Authoring, Enter still toggles it, and Recent still opens on click', async () => { + const { container } = sidebarUi(true); + expect(container.querySelector('.group.peer[data-state]')).toHaveAttribute('data-state', 'expanded'); + for (const label of labelsIn(container)) expectShown(label); + + const walk = await tabWalk(screen.getByRole('link', { name: 'Page Authoring' }), sidebarOf(container)); + expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'Workspace', 'My Work', 'Tasks']); + + const user = userEvent.setup(); + const workspace = screen.getByRole('button', { name: 'Workspace' }); + workspace.focus(); + expect(workspace).toHaveAttribute('aria-expanded', 'true'); + await user.keyboard('{Enter}'); + expect(workspace).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('link', { name: 'My Work' })).not.toBeInTheDocument(); + await user.keyboard('{Enter}'); + expect(workspace).toHaveAttribute('aria-expanded', 'true'); + + expect(screen.queryByRole('link', { name: /\baccount$/ })).not.toBeInTheDocument(); + await user.click(labelByText('Recent')); + expect(screen.getByRole('link', { name: /\baccount$/ })).toHaveAttribute('href', RECENT.href); + }); + + it('expanded (CONTROL, home arm): the Starred label is shown and announced', () => { + state.context = 'home'; + state.favorites = [STARRED]; + const { container } = sidebarUi(true); + expectShown(labelByText('Starred')); + expect(labelsIn(container)).toHaveLength(1); + }); +}); diff --git a/packages/layout/src/AppSchemaRenderer.tsx b/packages/layout/src/AppSchemaRenderer.tsx index 890204b30a..39cbc777b3 100644 --- a/packages/layout/src/AppSchemaRenderer.tsx +++ b/packages/layout/src/AppSchemaRenderer.tsx @@ -59,6 +59,24 @@ import { // Internal module, not re-exported by `index.ts` (objectui#11395). import { byNavOrder } from './navOrder'; +/** + * The classes this file's sidebar group label carries so that, once the + * sidebar collapses to icons, the hidden label is nothing a screen reader + * announces and no keyboard focus stop (objectui#12795). The + * `SidebarGroupLabel` primitive draws it at `opacity-0` there, which leaves it + * in the accessibility tree; `invisible` takes it out, under the primitive's + * own hiding condition, so the expanded sidebar is unchanged. `visibility` + * joins the primitive's transition list under the same condition, so the label + * still fades out before it is hidden. `NavigationRenderer` documents the pair + * in full, and a pin enumerates every `SidebarGroupLabel` call site. + * The primitive is Shadcn-synced and never edited (AGENTS.md #7), so the pair + * rides on the call site's `className`. Module-local on purpose: `index.ts` + * re-exports this file whole. + */ +const HIDDEN_ICON_LABEL_NOT_FOCUSABLE = + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:invisible ' + + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:transition-[margin,opacity,visibility]'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -235,7 +253,7 @@ function AreaSwitcher({ return ( - + Area diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index af715ecb53..c268da2c38 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -105,6 +105,30 @@ import { byNavOrder } from './navOrder'; const HIDDEN_ICON_LABEL_NO_POINTER = 'group-data-[state=collapsed]:group-data-[collapsible=icon]:pointer-events-none'; +/** + * The classes this file's sidebar group labels carry so that, once the sidebar + * collapses to icons, a hidden label is no keyboard focus stop and nothing a + * screen reader announces (objectui#12795). + * + * The primitive draws the label at `opacity-0` there, which leaves it in the + * tab order and in the accessibility tree. A collapsible group's label is its + * disclosure button, so Tab from the row above it landed on an invisible + * button and Enter toggled a group the user could not see. `invisible` + * (`visibility: hidden`) takes the label out of both, under the primitive's + * own hiding condition, so in the expanded sidebar every label is focusable + * and toggles as before. Visibility would switch at once and cut the label's + * fade-out short, so under the same condition `visibility` joins the + * primitive's transition list: the label stays visible while it fades and is + * hidden when the fade ends. On expand the variant no longer applies, so the + * label is visible at once and fades in as before. + * + * Module-local for the same reason as the constant above. Every + * `SidebarGroupLabel` call site carries this pair; a pin enumerates them. + */ +const HIDDEN_ICON_LABEL_NOT_FOCUSABLE = + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:invisible ' + + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:transition-[margin,opacity,visibility]'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -1560,7 +1584,7 @@ function NavigationItemRenderer({ const group = ( - + {groupLabel} 0; const favoritesSection = showPinned || hasExtraFavorites ? ( - + {tProp ? tProp('console.nav.favorites', { defaultValue: 'Favorites' }) : 'Favorites'} diff --git a/packages/layout/src/SidebarNav.tsx b/packages/layout/src/SidebarNav.tsx index 5e11b973d0..051b4cfa4e 100644 --- a/packages/layout/src/SidebarNav.tsx +++ b/packages/layout/src/SidebarNav.tsx @@ -20,6 +20,24 @@ import { } from '@object-ui/components'; import { ChevronRight, Search } from 'lucide-react'; +/** + * The classes this file's sidebar group label carries so that, once the + * sidebar collapses to icons, the hidden label is nothing a screen reader + * announces and no keyboard focus stop (objectui#12795). The + * `SidebarGroupLabel` primitive draws it at `opacity-0` there, which leaves it + * in the accessibility tree; `invisible` takes it out, under the primitive's + * own hiding condition, so the expanded sidebar is unchanged. `visibility` + * joins the primitive's transition list under the same condition, so the label + * still fades out before it is hidden. `NavigationRenderer` documents the pair + * in full, and a pin enumerates every `SidebarGroupLabel` call site. + * The primitive is Shadcn-synced and never edited (AGENTS.md #7), so the pair + * rides on the call site's `className`. Module-local on purpose: `index.ts` + * re-exports this file whole. + */ +const HIDDEN_ICON_LABEL_NOT_FOCUSABLE = + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:invisible ' + + 'group-data-[state=collapsed]:group-data-[collapsible=icon]:transition-[margin,opacity,visibility]'; + export interface NavItem { title: string; href: string; @@ -165,7 +183,7 @@ export function SidebarNav({ items, title = "Application", className, collapsibl {gIdx > 0 && (
)} - + {group.groupLabel || title} diff --git a/packages/layout/src/__tests__/sidebarGroupLabel.callSites-12795.test.ts b/packages/layout/src/__tests__/sidebarGroupLabel.callSites-12795.test.ts new file mode 100644 index 0000000000..300f19d7d1 --- /dev/null +++ b/packages/layout/src/__tests__/sidebarGroupLabel.callSites-12795.test.ts @@ -0,0 +1,130 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12795 — every `SidebarGroupLabel` call site hides its label from + * the tab order and the accessibility tree in icon mode. + * + * The primitive hides a label in the collapsed icon sidebar with `opacity-0` + * and `-mt-8` only, which leaves it focusable (when it is a button) and + * announced. The fix rides on each call site's `className`, because the + * primitive is Shadcn-synced and never edited (AGENTS.md #7). So a NEW call + * site would ship the defect again unless it carries the same classes: this + * pin enumerates every `` opening tag, braces balanced so an `=>` inside a prop does not end it. */ +function openingTags(source: string): string[] { + const tags: string[] = []; + const re = /' && depth === 0) break; + } + tags.push(source.slice(m.index, i + 1)); + } + return tags; +} + +/** The value of `const NAME = 'a' + 'b' …;` in `source`, string literals joined, or undefined. */ +function constantValue(source: string, name: string): string | undefined { + const decl = new RegExp(`const ${name}\\s*=\\s*((?:\\s*\\+?\\s*'[^']*')+)\\s*;`).exec(source); + if (!decl) return undefined; + return [...decl[1].matchAll(/'([^']*)'/g)].map((m) => m[1]).join(''); +} + +/** The icon-mode prefix the primitive hides a group label under, read off its `…:opacity-0` token. */ +function primitiveHidingPrefix(): string { + const source = readFileSync(path.join(repoRoot, PRIMITIVE), 'utf8'); + const start = source.indexOf('const SidebarGroupLabel'); + const end = source.indexOf('SidebarGroupLabel.displayName', start); + expect(start, 'the primitive still declares SidebarGroupLabel').toBeGreaterThan(-1); + const hide = source.slice(start, end).match(/[\w\-[\]=:]+:opacity-0(?![\w-])/); + expect(hide, 'the primitive hides the label with a variant-scoped opacity-0').not.toBeNull(); + return hide![0].slice(0, -'opacity-0'.length); +} + +const sources = [...sourceFiles('packages'), ...sourceFiles('apps')].map((file) => ({ + file, + source: readFileSync(path.join(repoRoot, file), 'utf8'), +})); +const callSites = sources.filter(({ source }) => / { + it('the enumeration finds the call sites it is known to cover (control)', () => { + const files = callSites.map((c) => c.file); + for (const known of [ + 'packages/app-shell/src/layout/UnifiedSidebar.tsx', + 'packages/layout/src/NavigationRenderer.tsx', + 'packages/layout/src/AppSchemaRenderer.tsx', + 'packages/layout/src/SidebarNav.tsx', + ]) { + expect(files).toContain(known); + } + }); + + it.each(callSites.map((c) => [c.file, c.source] as const))('%s: every opening tag passes the constant, declared with the primitive\'s own variant', (file, source) => { + const prefix = primitiveHidingPrefix(); + expect(constantValue(source, CONSTANT)?.split(/\s+/), `${file} declares ${CONSTANT}`).toEqual([ + `${prefix}invisible`, + `${prefix}transition-[margin,opacity,visibility]`, + ]); + + const tags = openingTags(source); + expect(tags.length).toBeGreaterThan(0); + for (const tag of tags) { + expect(tag, `${file}: a SidebarGroupLabel without ${CONSTANT}`).toMatch(/\bclassName=\{[^]*\bHIDDEN_ICON_LABEL_NOT_FOCUSABLE\b[^]*\}/); + } + }); + + it('no file renames the primitive on import, which would hide a call site from the enumeration', () => { + for (const { file, source } of sources) { + expect(source, file).not.toMatch(/\bSidebarGroupLabel\s+as\s+\w/); + } + }); +}); diff --git a/packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx b/packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx new file mode 100644 index 0000000000..62c92c04be --- /dev/null +++ b/packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx @@ -0,0 +1,314 @@ +// @vitest-environment jsdom +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12795 — in the collapsed (icon-mode) sidebar a hidden group label + * is no keyboard focus stop and is not in the accessibility tree. This file + * covers the `@object-ui/layout` call sites: `NavigationRenderer`'s + * collapsible group label and Favorites label, `AppSchemaRenderer`'s Area + * label and `SidebarNav`'s group labels. + * + * The `SidebarGroupLabel` primitive hides a label there with `opacity-0` and + * `-mt-8` only. A collapsible group's label is its disclosure button, so Tab + * from the row above it landed on an invisible button and Enter toggled a + * group the user could not see (measured in a browser; the readings are on the + * pull request). Each call site now adds a variant-scoped `invisible`. + * + * Instrument: the real `SidebarProvider` / `Sidebar` / `SidebarGroupLabel` + * and the real renderers, plus the REAL Tailwind compiler. The labels' + * class tokens are compiled with `tailwindcss` and the stylesheet is injected, + * so the DOM computes the label's `visibility` from the classes it actually + * carries. user-event's Tab skips an element whose computed `visibility` is + * `hidden`, and Testing Library's role queries drop it, so both pins read + * behaviour rather than a class list. Only the labels' tokens are compiled: + * compiling the whole tree would apply the primitive's `hidden md:block` with + * no media query to lift it. + * + * jsdom rather than the default happy-dom: Tailwind emits its group variants + * as `:is(:where(.group)[data-state="collapsed"] *)`, and happy-dom does not + * match that selector, so it would compute every label as visible. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import React from 'react'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import { isInaccessible } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { compile } from 'tailwindcss'; +import type { AppComponentSchema, NavigationItem } from '@object-ui/types'; +import { Sidebar, SidebarContent, SidebarProvider } from '@object-ui/components'; +import { NavigationRenderer } from '../NavigationRenderer'; +import { AppSchemaRenderer } from '../AppSchemaRenderer'; +import { SidebarNav } from '../SidebarNav'; + +const tailwind = await compile('@tailwind utilities;'); + +beforeAll(() => { + // jsdom has no `matchMedia`; the sidebar's `useIsMobile` reads it. A desktop + // width keeps the sidebar out of its mobile sheet. + window.matchMedia ??= ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; +}); + +let sheets: HTMLStyleElement[] = []; +afterEach(() => { + cleanup(); + for (const s of sheets) s.remove(); + sheets = []; +}); + +const labelsIn = (root: ParentNode) => + Array.from(root.querySelectorAll('[data-sidebar="group-label"]')); + +/** Compiles every class token the rendered labels carry and injects the result. */ +function applyLabelStyles(root: ParentNode) { + const candidates = new Set(); + for (const label of labelsIn(root)) { + for (const t of label.className.split(/\s+/)) if (t) candidates.add(t); + } + const sheet = document.createElement('style'); + sheet.textContent = tailwind.build([...candidates]); + document.head.appendChild(sheet); + sheets.push(sheet); +} + +const tokens = (el: Element) => el.className.split(/\s+/).filter(Boolean); + +/** The variant prefix the primitive hides this label under, read off its `…:opacity-0` token. */ +function hidingPrefix(label: Element): string { + const hide = tokens(label).find((t) => t.endsWith(':opacity-0')); + expect(hide, 'the primitive hides the label with a variant-scoped opacity-0').toBeDefined(); + return hide!.slice(0, -'opacity-0'.length); +} + +/** Every label: in icon mode it computes hidden, under the primitive's own condition, and keeps the fade. */ +function expectHiddenLabels(root: ParentNode, count: number) { + const labels = labelsIn(root); + expect(labels).toHaveLength(count); + for (const label of labels) { + const prefix = hidingPrefix(label); + expect(tokens(label)).toContain(`${prefix}invisible`); + expect(tokens(label)).toContain(`${prefix}transition-[margin,opacity,visibility]`); + expect(getComputedStyle(label).visibility).toBe('hidden'); + expect(parseFloat(getComputedStyle(label).opacity)).toBe(0); + expect(isInaccessible(label)).toBe(true); + } +} + +function expectShownLabels(root: ParentNode, count: number) { + const labels = labelsIn(root); + expect(labels).toHaveLength(count); + for (const label of labels) { + expect(getComputedStyle(label).visibility).not.toBe('hidden'); + expect(isInaccessible(label)).toBe(false); + } +} + +/** Tabs from `start` until focus leaves the sidebar; returns every element that took focus. */ +async function tabWalk(user: ReturnType, start: HTMLElement, root: HTMLElement) { + start.focus(); + const seen: HTMLElement[] = [start]; + for (let i = 0; i < 50; i++) { + await user.tab(); + const el = document.activeElement as HTMLElement | null; + if (!el || !root.contains(el) || el === start) break; + seen.push(el); + } + return seen; +} + +const name = (el: Element) => el.textContent?.trim(); + +// --------------------------------------------------------------------------- +// NavigationRenderer +// --------------------------------------------------------------------------- + +const BASE = '/apps/crm'; +const entry = (id: string, extra: Partial = {}): NavigationItem => + ({ id, type: 'object', objectName: id, label: id.toUpperCase(), ...extra }) as NavigationItem; + +/** A top-level row, then two open collapsible groups. */ +const ITEMS: NavigationItem[] = [ + entry('page_authoring'), + { id: 'grp_sales', type: 'group', label: 'Sales', expanded: true, children: [entry('accounts'), entry('contacts')] } as NavigationItem, + { id: 'grp_support', type: 'group', label: 'Support', expanded: true, children: [entry('cases')] } as NavigationItem, +]; + +function renderNavigation(open: boolean, items: NavigationItem[] = ITEMS, pinning = false) { + const view = render( + + + + + {}} /> + + + + , + ); + applyLabelStyles(view.container); + return view; +} + +const sidebarOf = (container: HTMLElement) => container.querySelector('[data-sidebar="sidebar"]') as HTMLElement; + +describe('NavigationRenderer — a hidden group label is no focus stop in icon mode (objectui#12795)', () => { + it('collapsed to icons: Tab goes from a row straight to the next group\'s first row, and no label is focusable or announced', async () => { + const user = userEvent.setup(); + const { container } = renderNavigation(false); + const peer = container.querySelector('.group.peer[data-state]'); + expect(peer).toHaveAttribute('data-state', 'collapsed'); + expect(peer).toHaveAttribute('data-collapsible', 'icon'); + + expectHiddenLabels(container, 2); + expect(screen.queryByRole('button', { name: /Sales|Support/ })).not.toBeInTheDocument(); + + const walk = await tabWalk(user, screen.getByRole('link', { name: 'PAGE_AUTHORING' }), sidebarOf(container)); + expect(walk.map(name)).toEqual(['PAGE_AUTHORING', 'ACCOUNTS', 'CONTACTS', 'CASES']); + expect(walk.filter((el) => el.closest('[data-sidebar="group-label"]'))).toEqual([]); + }); + + it('collapsed to icons: the Favorites label is hidden and not announced', () => { + const { container } = renderNavigation( + false, + [entry('page_authoring', { pinned: true } as Partial), ...ITEMS.slice(1)], + true, + ); + const favorites = screen.getByText('Favorites').closest('[data-sidebar="group-label"]') as HTMLElement; + expect(favorites).toBeInTheDocument(); + expectHiddenLabels(container, 3); + }); + + it('expanded (CONTROL): each group label is still a focus stop after the row above it, and Enter still toggles its group', async () => { + const user = userEvent.setup(); + const { container } = renderNavigation(true); + expect(container.querySelector('.group.peer[data-state]')).toHaveAttribute('data-state', 'expanded'); + expectShownLabels(container, 2); + + const walk = await tabWalk(user, screen.getByRole('link', { name: 'PAGE_AUTHORING' }), sidebarOf(container)); + expect(walk.map(name)).toEqual(['PAGE_AUTHORING', 'Sales', 'ACCOUNTS', 'CONTACTS', 'Support', 'CASES']); + + const sales = screen.getByRole('button', { name: 'Sales' }); + sales.focus(); + expect(sales).toHaveAttribute('aria-expanded', 'true'); + await user.keyboard('{Enter}'); + expect(sales).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('link', { name: 'ACCOUNTS' })).not.toBeInTheDocument(); + await user.keyboard('{Enter}'); + expect(sales).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('link', { name: 'ACCOUNTS' })).toBeInTheDocument(); + }); + + it('expanded (CONTROL): the Favorites label is shown and announced', () => { + const { container } = renderNavigation( + true, + [entry('page_authoring', { pinned: true } as Partial), ...ITEMS.slice(1)], + true, + ); + expectShownLabels(container, 3); + }); +}); + +// --------------------------------------------------------------------------- +// AppSchemaRenderer — its Area label sits in the same icon sidebar +// --------------------------------------------------------------------------- + +const APP: AppComponentSchema = { + type: 'app', + name: 'crm', + title: 'Sales CRM', + areas: [ + { id: 'area_sales', label: 'Sales area', icon: 'Briefcase', navigation: [entry('opportunities')] }, + { id: 'area_service', label: 'Service area', icon: 'Headphones', navigation: [entry('tickets')] }, + ], +}; + +function renderApp(open: boolean) { + const view = render( + + +
Page
+
+
, + ); + applyLabelStyles(view.container); + return view; +} + +const areaLabel = (container: HTMLElement) => + within(container).getByText('Area').closest('[data-sidebar="group-label"]') as HTMLElement; + +describe('AppSchemaRenderer — the hidden Area label is not announced in icon mode (objectui#12795)', () => { + it('collapsed to icons: the Area label is hidden and not announced; Tab goes from one area row to the next', async () => { + const user = userEvent.setup(); + const { container } = renderApp(false); + expect(container.querySelector('.group.peer[data-state]')).toHaveAttribute('data-state', 'collapsed'); + expect(areaLabel(container)).toBeInTheDocument(); + expectHiddenLabels(container, 1); + + const first = screen.getByRole('button', { name: 'Sales area' }); + first.focus(); + await user.tab(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Service area' })); + }); + + it('expanded (CONTROL): the Area label is shown and announced', () => { + const { container } = renderApp(true); + expect(areaLabel(container)).toBeInTheDocument(); + expectShownLabels(container, 1); + }); +}); + +// --------------------------------------------------------------------------- +// SidebarNav — collapsible="icon" by default +// --------------------------------------------------------------------------- + +const NAV = [ + { label: 'Main', items: [{ title: 'Home', href: '/home' }, { title: 'Inbox', href: '/inbox' }] }, + { label: 'Admin', items: [{ title: 'Users', href: '/users' }] }, +]; + +function renderSidebarNav(open: boolean) { + const view = render( + + + + + , + ); + applyLabelStyles(view.container); + return view; +} + +describe('SidebarNav — a hidden group label is not announced in icon mode (objectui#12795)', () => { + it('collapsed to icons: both group labels are hidden and not announced; Tab goes from a group\'s last row to the next group\'s first', async () => { + const user = userEvent.setup(); + const { container } = renderSidebarNav(false); + expect(container.querySelector('.group.peer[data-state]')).toHaveAttribute('data-collapsible', 'icon'); + expectHiddenLabels(container, 2); + + const walk = await tabWalk(user, screen.getByRole('link', { name: 'Inbox' }), sidebarOf(container)); + expect(walk.map(name)).toEqual(['Inbox', 'Users']); + }); + + it('expanded (CONTROL): both group labels are shown and announced', () => { + const { container } = renderSidebarNav(true); + expectShownLabels(container, 2); + }); +}); From d6787206201463aefca5b26e29b3f0017ef1dce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:16:28 +0000 Subject: [PATCH 2/2] test(layout,app-shell): read the Tab walk before the class list in the icon-mode label pins (objectui#12795) Claude-Session: https://claude.ai/code/session_016gcD8krj7QLd3njFMYsi1g Co-authored-by: Claude --- .../UnifiedSidebar.iconModeLabelFocus-12795.test.tsx | 12 ++++++------ .../sidebarGroupLabel.iconModeFocus-12795.test.tsx | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx index cd2c929f4d..64df3be017 100644 --- a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx @@ -253,16 +253,16 @@ describe('UnifiedSidebar — a hidden group label is no focus stop in icon mode expect(peer).toHaveAttribute('data-state', 'collapsed'); expect(peer).toHaveAttribute('data-collapsible', 'icon'); + const walk = await tabWalk(screen.getByRole('link', { name: 'Page Authoring' }), sidebarOf(container)); + expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'My Work', 'Tasks']); + expect(walk.filter((el) => el.closest('[data-sidebar="group-label"]'))).toEqual([]); + expect(screen.queryByRole('button', { name: 'Workspace' })).not.toBeInTheDocument(); + const labels = labelsIn(container); expect(labels).toHaveLength(3); - for (const label of labels) expectHidden(label); expect(labelByText('Area')).toBe(labels[0]); expect(labelByText('Recent')).toBe(labels[2]); - expect(screen.queryByRole('button', { name: 'Workspace' })).not.toBeInTheDocument(); - - const walk = await tabWalk(screen.getByRole('link', { name: 'Page Authoring' }), sidebarOf(container)); - expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'My Work', 'Tasks']); - expect(walk.filter((el) => el.closest('[data-sidebar="group-label"]'))).toEqual([]); + for (const label of labels) expectHidden(label); }); it('collapsed to icons (home arm): the Starred label is hidden and not announced', () => { diff --git a/packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx b/packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx index 62c92c04be..8547160b3b 100644 --- a/packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx +++ b/packages/layout/src/__tests__/sidebarGroupLabel.iconModeFocus-12795.test.tsx @@ -176,12 +176,12 @@ describe('NavigationRenderer — a hidden group label is no focus stop in icon m expect(peer).toHaveAttribute('data-state', 'collapsed'); expect(peer).toHaveAttribute('data-collapsible', 'icon'); - expectHiddenLabels(container, 2); - expect(screen.queryByRole('button', { name: /Sales|Support/ })).not.toBeInTheDocument(); - const walk = await tabWalk(user, screen.getByRole('link', { name: 'PAGE_AUTHORING' }), sidebarOf(container)); expect(walk.map(name)).toEqual(['PAGE_AUTHORING', 'ACCOUNTS', 'CONTACTS', 'CASES']); expect(walk.filter((el) => el.closest('[data-sidebar="group-label"]'))).toEqual([]); + expect(screen.queryByRole('button', { name: /Sales|Support/ })).not.toBeInTheDocument(); + + expectHiddenLabels(container, 2); }); it('collapsed to icons: the Favorites label is hidden and not announced', () => {