Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit 4aa90ba

Browse files
fix(a11y): name icon-only buttons and give clickable div/span controls a keyboard path (objectui#13461) (#13494)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DMknEB2RRDpu49p9oqa9C6D)_ Fixes #13461 Clause-②: no Metadata-protocol: none ## What changed Icon-only buttons in the console get an accessible name, and the console's mouse-only clickable `div` / `span` controls become real buttons. A new deletions-only census keeps clickable `div` / `span` sites from coming back. ### Premise re-measured (the card's counts were a script reading) The card's 25 / 24 came from a line-window script. Re-measured on the TypeScript AST at base `c85408f`: - **Icon-only buttons.** Several of the card's examples already carry `aria-label` a few lines below the opening tag (the AI chat page, the invitations page, the chat dock), so they were never unnamed. The instrument that already re-derives this family is `iconOnlyButtonCensus-12143.test.ts` (it exists; the card says nobody filed it). This PR names every entry of its `BACKLOG` inside the claim's file surface (app-shell, components, fields, plugin-chatbot: 16 sites in 12 files) and deletes those rows. The top bar's avatar button, which that census cannot see (an `Avatar` is not an icon), is named too. - **Clickable `div` / `span`.** The new census reads every `div` / `span` with an `onClick` and no `role`, `tabIndex` or key handler under `packages/*/src` and `apps/*/src`. Most sites are not controls: propagation guards around real buttons, scrims behind overlays, empty design-canvas backgrounds, and drag handles. Each is declared in `NOT_CONTROLS` with its reason. ⛔ None got `role="button"`. ### Repaired | Where | Before | After | |:--|:--|:--| | Organization picker card, "Manage" | a clickable `span` inside the card's `button` (unreachable by Tab, read as part of the card's name) | its own `button` beside the card button; `organization-manage-SLUG` test id | | Flow canvas branch label | clickable `span`; the wide hit path is pointer-only | `role="button"`, `tabIndex`, `aria-pressed`, Enter / Space when the edge is selectable | | `tree-view` node row | clickable `div` wrapped round the toggle `button`; toggle unnamed | the toggle is named (`common.expand` / `common.collapse`) with `aria-expanded`; the node is a `button` only when the host passes `onNodeClick` | | `file-upload` dropzone | clickable `div` | `role="button"`, `tabIndex={0}`, Enter / Space opens the picker | | App preview read-only nav row | an `onSelect` click handler that could never fire (the list renders only outside design mode, where the handler is undefined) | handler removed | Icon-only buttons named: AI live canvas close (`common.close`), action result dialog secret toggle (`actions.resultDialog.showValue` / `hideValue`), permission set editor remove policy / remove tab (`perm.rls.remove` / `perm.tabs.remove`, designer table en + zh), metadata history refresh (`engine.edit.refresh`), sort builder remove (`sortBuilder.removeSort`), `file-upload` and `FileField` remove (`fields.file.remove`), `MasterDetailField` open / remove (`fields.masterDetail.*`), `PasswordField` toggle (`fields.password.*`), `QRCodeField` copy / toggle (`fields.qrCode.*`), legacy `Chatbot` send (`chatbot.sendMessage`), chat scroll-to-latest (`chatbot.scrollToBottom`, before the props spread so a caller's name wins), top bar avatar (`user.accountMenu`). New pack keys are in all ten packs. ### The census — `packages/components/src/__tests__/clickableNonControlCensus-13461.test.ts` Same shape as the objectui#12143 and objectui#12145 censuses: an ordinary-suite test, ⛔ not a new CI gate. Sites are keyed by file, tag and handler (never by line); `NOT_CONTROLS` and `BACKLOG` are deletions-only and must equal what the scan finds. It carries width guards, a matcher fixture, and a stated blind-spot list (spread attributes, other tags, whether a reason is still true). `BACKLOG` freezes 16 clickable controls that still have no keyboard path. 15 are outside the claim's file surface (plugin-ai, plugin-designer, plugin-gantt, plugin-grid, plugin-view). One is in app-shell: the studio data-form preview selects a field by a click delegated from its frame, and a keyboard path for that is a design question. The 12143 census keeps its 14 remaining plugin rows (plugin-ai, plugin-detail, plugin-list, plugin-view). ## Verification (head after the merge of main, `ecd30ca`) - Ablation, done once and not kept: restored `OrganizationsPage.tsx` from `origin/main` and the new census went red with `OrganizationsPage.tsx · span · stopPropagation,navigate — declared 0, found 1`. Restored with `git checkout HEAD --`, and `git diff HEAD` was empty. - New and extended tests: `keyboardControls-13461.test.tsx` (components: tree toggle name and state, node button only with `onNodeClick`, zh pack; dropzone Enter / Space, remove-button name), `iconButtonNames-13461.test.tsx` (fields: password, QR code, master-detail names, zh pack), plus an `OrganizationsPage.test.tsx` case (Manage is a sibling button and does not switch org) and an `AppHeader.myOrganizations.test.tsx` case (avatar button name). - Every test file that imports a changed module, plus every test that reads a changed file by path: 261 files in five runs, all passed before the merge. After the merge, the three censuses, the new tests and the tree-view catalog pin (`tree-view-nodes-mirror-6939`, 8 files, 54 tests) passed. - `packages/i18n/` whole package: 87 files passed. - `turbo run type-check` for app-shell, components, fields, plugin-chatbot and i18n with their dependencies: green after a test-typing fix in fields. - Gates: `check:i18n-keys`, `check:i18n-dead-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:new-line-citations`, `check-changeset-presence`, `check-changeset-no-major`, `check-changeset-claims`, `check-pending-changeset-literals` and `check-changeset-fixed` all exit 0. ESLint on touched files: 0 errors with inline config. One `react-hooks/static-components` error appears under `--no-inline-config` in `AppPreview.tsx`, and `origin/main`'s copy of that file shows the same error. - NOT MEASURED: `check:eager-locale-catalogues` (needs a built console bundle; CI builds first). The whole-repo `pnpm test` and `pnpm lint` are left to CI. ## Acceptance notes - Literal user-facing strings next to the touched controls are untouched (the legacy `Chatbot`'s empty-state line, `MasterDetailField`'s record count, `file-upload`'s launch copy, `ResourceHistoryPage`'s "Back to item"). They are outside this card. - The optional per-route axe pass, a required axe check and repo-wide `eslint-plugin-jsx-a11y` are out of scope, per the claim. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018qafSnDdNuQgZGfnFwDajn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5698ec7 commit 4aa90ba

35 files changed

Lines changed: 1043 additions & 69 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/components': patch
4+
'@object-ui/fields': patch
5+
'@object-ui/i18n': patch
6+
'@object-ui/plugin-chatbot': patch
7+
---
8+
9+
Icon-only buttons in the console now have an accessible name, and the clickable `div` / `span` controls the console drew are real buttons a keyboard can reach (objectui#13461).
10+
11+
Clause-②: no
12+
Metadata-protocol: none
13+
14+
- Named, in every shipped language: the AI canvas's close button, the secret value's show / hide toggle in the action result dialog, the permission set editor's remove-policy and remove-tab buttons, the metadata history page's refresh button, the sort builder's remove button, the `file-upload` renderer's remove buttons, the file, master-detail, password and QR-code field widgets' icon buttons, the chat composer's send button, the chat's scroll-to-latest button, and the top bar's avatar button ("Account menu").
15+
- The organization picker's "Manage" link is its own button beside the card, not a clickable `span` inside the card's button, so it can be tabbed to and is no longer read as part of the card's name.
16+
- A flow canvas branch label selects its edge with Enter or Space when the edge is selectable.
17+
- `tree-view`: the expand toggle is named ("Expand" / "Collapse") and reports `aria-expanded`; a node is a button only when the host passes `onNodeClick`, instead of a clickable row wrapped round the toggle.
18+
- `file-upload`: the dropzone opens the file picker with Enter or Space.
19+
- The app preview's read-only navigation list no longer carries a click handler that could never fire.
20+
- New pack keys: `common.expand`, `common.collapse`, `user.accountMenu`, `actions.resultDialog.showValue` / `hideValue`, `fields.masterDetail.view` / `remove`, `fields.password.show` / `hide`, `fields.qrCode.copy` / `show` / `hide`, `chatbot.sendMessage`, `chatbot.scrollToBottom`.

‎packages/app-shell/src/console/ai/LiveCanvas.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,13 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re
137137
<span className="hidden sm:inline">{t('topbar.designInStudio', { defaultValue: 'Design in Studio' })}</span>
138138
</Button>
139139
)}
140-
<Button size="sm" variant="ghost" onClick={onClose} data-testid="live-canvas-close">
140+
<Button
141+
size="sm"
142+
variant="ghost"
143+
onClick={onClose}
144+
data-testid="live-canvas-close"
145+
aria-label={t('common.close')}
146+
>
141147
<X className="h-3.5 w-3.5" />
142148
</Button>
143149
</div>

‎packages/app-shell/src/console/organizations/OrganizationsPage.tsx‎

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -239,35 +239,47 @@ export function OrganizationsPage() {
239239
const isActive = org.id === activeOrganization?.id;
240240
const isSwitching = switchingId === org.id;
241241
return (
242-
<button type="button"
242+
// objectui#13461 — the card holds two controls side by side: the
243+
// card itself switches to the organization, "Manage" opens its
244+
// members. "Manage" used to be a clickable `span` inside the card's
245+
// `button`, which a keyboard could not reach and a screen reader
246+
// read as part of the card's name; a button cannot hold another.
247+
<div
243248
key={org.id}
244-
onClick={() => handleSelect(org)}
245-
disabled={isSwitching}
246-
className="group flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
247-
data-testid={`organization-card-${org.slug}`}
249+
className={`group flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent${isSwitching ? ' opacity-60' : ''}`}
248250
>
249-
<Avatar className="h-10 w-10 rounded-md shrink-0">
250-
{org.logo && <AvatarImage src={org.logo} alt={org.name} />}
251-
<AvatarFallback className="rounded-md bg-primary/10 text-primary text-sm font-medium">
252-
{getOrgInitials(org.name)}
253-
</AvatarFallback>
254-
</Avatar>
255-
<div className="min-w-0 flex-1">
256-
<div className="truncate font-medium">{org.name}</div>
257-
<div className="truncate text-xs text-muted-foreground">
258-
{isActive
259-
? t('organizations.current', { defaultValue: 'Current organization' })
260-
: org.slug}
251+
<button
252+
type="button"
253+
onClick={() => handleSelect(org)}
254+
disabled={isSwitching}
255+
className="flex min-w-0 flex-1 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
256+
data-testid={`organization-card-${org.slug}`}
257+
>
258+
<Avatar className="h-10 w-10 rounded-md shrink-0">
259+
{org.logo && <AvatarImage src={org.logo} alt={org.name} />}
260+
<AvatarFallback className="rounded-md bg-primary/10 text-primary text-sm font-medium">
261+
{getOrgInitials(org.name)}
262+
</AvatarFallback>
263+
</Avatar>
264+
<div className="min-w-0 flex-1">
265+
<div className="truncate font-medium">{org.name}</div>
266+
<div className="truncate text-xs text-muted-foreground">
267+
{isActive
268+
? t('organizations.current', { defaultValue: 'Current organization' })
269+
: org.slug}
270+
</div>
261271
</div>
262-
</div>
263-
<span
264-
className="text-xs text-primary underline-offset-4 hover:underline shrink-0"
265-
onClick={(e) => { e.stopPropagation(); navigate(`/organizations/${org.slug}/members`); }}
272+
</button>
273+
<button
274+
type="button"
275+
className="shrink-0 rounded-sm text-xs text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
276+
onClick={() => navigate(`/organizations/${org.slug}/members`)}
277+
data-testid={`organization-manage-${org.slug}`}
266278
>
267279
{t('organizations.manage', { defaultValue: 'Manage' })}
268-
</span>
280+
</button>
269281
{isSwitching && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
270-
</button>
282+
</div>
271283
);
272284
})}
273285
</div>

‎packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import '@testing-library/jest-dom/vitest';
1313
import { describe, it, expect, vi, beforeEach } from 'vitest';
14-
import { render, screen, waitFor } from '@testing-library/react';
14+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
1515

1616
vi.mock('@object-ui/i18n', async (importOriginal) => ({
1717
...(await importOriginal<Record<string, unknown>>()),
@@ -103,3 +103,25 @@ describe('OrganizationsPage — zero-org first run', () => {
103103
);
104104
});
105105
});
106+
107+
describe('OrganizationsPage — the card\'s "Manage" control (objectui#13461)', () => {
108+
it('is its own button beside the card button, and opens the members page without switching', async () => {
109+
authState.organizations = [
110+
{ id: 'o1', name: 'Alpha', slug: 'alpha' },
111+
{ id: 'o2', name: 'Beta', slug: 'beta' },
112+
];
113+
render(<OrganizationsPage />);
114+
const manage = await screen.findByTestId('organization-manage-alpha');
115+
const card = screen.getByTestId('organization-card-alpha');
116+
// A real button, reachable by Tab, and not nested in the card's button.
117+
expect(manage.tagName).toBe('BUTTON');
118+
expect(manage).toHaveAttribute('type', 'button');
119+
expect(card.tagName).toBe('BUTTON');
120+
expect(card.contains(manage)).toBe(false);
121+
expect(manage.closest('button')).toBe(manage);
122+
123+
fireEvent.click(manage);
124+
expect(navigate).toHaveBeenCalledWith('/organizations/alpha/members');
125+
expect(authState.switchOrganization).not.toHaveBeenCalled();
126+
});
127+
});

‎packages/app-shell/src/layout/AppHeader.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1192,7 +1192,12 @@ export function AppHeader({
11921192
<div data-topbar-group className="flex items-center gap-0.5 shrink-0"> {/* User Profile + Organization switcher */}
11931193
<DropdownMenu>
11941194
<DropdownMenuTrigger asChild>
1195-
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full">
1195+
<Button
1196+
variant="ghost"
1197+
size="icon"
1198+
className="h-8 w-8 shrink-0 rounded-full"
1199+
aria-label={t('user.accountMenu', { defaultValue: 'Account menu' })}
1200+
>
11961201
<Avatar className="h-7 w-7 rounded-full">
11971202
<AvatarImage src={user?.image} alt={user?.name ?? 'User'} />
11981203
<AvatarFallback className="rounded-full bg-primary text-primary-foreground text-xs">

‎packages/app-shell/src/layout/__tests__/AppHeader.myOrganizations.test.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,12 @@ describe('AppHeader avatar menu — the single-org door to workspace management
244244
expect(screen.getByTestId('header-my-organizations')).toBeInTheDocument();
245245
});
246246
});
247+
248+
describe('AppHeader avatar menu trigger — an accessible name (objectui#13461)', () => {
249+
it('names the avatar button, which holds only the avatar', () => {
250+
render(<AppHeader variant="home" />);
251+
// The mocked `t` returns the call's `defaultValue`: the key is `user.accountMenu`.
252+
const trigger = screen.getByRole('button', { name: 'Account menu' });
253+
expect(trigger).toHaveTextContent('ZS');
254+
});
255+
});

‎packages/app-shell/src/views/ActionResultDialog.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,13 +273,20 @@ function CodeListBlock({ value }: { value: string[] }) {
273273
}
274274

275275
function SecretBlock({ value }: { value: string }) {
276+
const { t } = useObjectTranslation();
276277
const [revealed, setRevealed] = useState(false);
277278
return (
278279
<div className="flex items-center gap-2">
279280
<code className="flex-1 rounded bg-muted px-2 py-1 text-sm font-mono break-all">
280281
{revealed ? value : value.replace(/./g, '•')}
281282
</code>
282-
<Button type="button" variant="outline" size="sm" onClick={() => setRevealed((v) => !v)}>
283+
<Button
284+
type="button"
285+
variant="outline"
286+
size="sm"
287+
onClick={() => setRevealed((v) => !v)}
288+
aria-label={revealed ? t('actions.resultDialog.hideValue') : t('actions.resultDialog.showValue')}
289+
>
283290
{revealed ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
284291
</Button>
285292
<CopyButton value={value} />

‎packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ export function PermissionAdvancedFacets({
530530
size="icon"
531531
className="h-7 w-7 text-muted-foreground hover:text-destructive"
532532
onClick={() => setPolicies(policies.filter((_, j) => j !== i))}
533+
aria-label={t('perm.rls.remove')}
533534
>
534535
<Trash2 className="h-4 w-4" />
535536
</Button>
@@ -648,6 +649,7 @@ export function PermissionAdvancedFacets({
648649
delete next[tab];
649650
setTabs(next);
650651
}}
652+
aria-label={t('perm.tabs.remove')}
651653
>
652654
<Trash2 className="h-4 w-4" />
653655
</Button>

‎packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export function MetadataResourceHistoryPage({
9898
variant="ghost"
9999
size="sm"
100100
onClick={() => setRefreshKey((k) => k + 1)}
101+
aria-label={t('engine.edit.refresh', locale)}
101102
>
102103
<RefreshCw className="h-4 w-4" />
103104
</Button>

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2605,6 +2605,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
26052605
'perm.rls.checkPlaceholder': 'optional — defaults to USING',
26062606
'perm.rls.empty': 'No row-level policies.',
26072607
'perm.rls.add': 'Add policy',
2608+
'perm.rls.remove': 'Remove policy',
26082609
'perm.rls.nameNeeded': 'Give the policy a name to save it.',
26092610
'perm.rls.nameRefused': 'Not saved. Policy name, on row {row}: {problem}',
26102611
// CEL authoring safety for RLS predicates (objectui#2413).
@@ -2643,6 +2644,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
26432644
'perm.tabs.help': 'Per-tab visibility for holders of this set.',
26442645
'perm.tabs.empty': 'No tab overrides.',
26452646
'perm.tabs.add': 'Add tab',
2647+
'perm.tabs.remove': 'Remove tab',
26462648
'perm.tabs.vis.visible': 'Visible',
26472649
'perm.tabs.vis.hidden': 'Hidden',
26482650
'perm.tabs.vis.default_on': 'Default on',
@@ -6208,6 +6210,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
62086210
'perm.rls.checkPlaceholder': '可选 — 默认同 USING',
62096211
'perm.rls.empty': '暂无行级策略。',
62106212
'perm.rls.add': '新增策略',
6213+
'perm.rls.remove': '移除策略',
62116214
'perm.rls.nameNeeded': '为策略填写名称后即可保存。',
62126215
'perm.rls.nameRefused': '未保存。第 {row} 行的策略名称:{problem}',
62136216
// 行级安全 CEL 编写安全 (objectui#2413)。
@@ -6244,6 +6247,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
62446247
'perm.tabs.help': '为此权限集持有者设置各标签页的可见性。',
62456248
'perm.tabs.empty': '暂无标签页覆盖。',
62466249
'perm.tabs.add': '新增标签页',
6250+
'perm.tabs.remove': '移除标签页',
62476251
'perm.tabs.vis.visible': '可见',
62486252
'perm.tabs.vis.hidden': '隐藏',
62496253
'perm.tabs.vis.default_on': '默认开启',

0 commit comments

Comments
 (0)