Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/13461-keyboard-and-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/app-shell': patch
'@object-ui/components': patch
'@object-ui/fields': patch
'@object-ui/i18n': patch
'@object-ui/plugin-chatbot': patch
---

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).

Clause-②: no
Metadata-protocol: none

- 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").
- 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.
- A flow canvas branch label selects its edge with Enter or Space when the edge is selectable.
- `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.
- `file-upload`: the dropzone opens the file picker with Enter or Space.
- The app preview's read-only navigation list no longer carries a click handler that could never fire.
- 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`.
8 changes: 7 additions & 1 deletion packages/app-shell/src/console/ai/LiveCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,13 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re
<span className="hidden sm:inline">{t('topbar.designInStudio', { defaultValue: 'Design in Studio' })}</span>
</Button>
)}
<Button size="sm" variant="ghost" onClick={onClose} data-testid="live-canvas-close">
<Button
size="sm"
variant="ghost"
onClick={onClose}
data-testid="live-canvas-close"
aria-label={t('common.close')}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
Expand Down
58 changes: 35 additions & 23 deletions packages/app-shell/src/console/organizations/OrganizationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,35 +239,47 @@ export function OrganizationsPage() {
const isActive = org.id === activeOrganization?.id;
const isSwitching = switchingId === org.id;
return (
<button type="button"
// objectui#13461 — the card holds two controls side by side: the
// card itself switches to the organization, "Manage" opens its
// members. "Manage" used to be a clickable `span` inside the card's
// `button`, which a keyboard could not reach and a screen reader
// read as part of the card's name; a button cannot hold another.
<div
key={org.id}
onClick={() => handleSelect(org)}
disabled={isSwitching}
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"
data-testid={`organization-card-${org.slug}`}
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' : ''}`}
>
<Avatar className="h-10 w-10 rounded-md shrink-0">
{org.logo && <AvatarImage src={org.logo} alt={org.name} />}
<AvatarFallback className="rounded-md bg-primary/10 text-primary text-sm font-medium">
{getOrgInitials(org.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{org.name}</div>
<div className="truncate text-xs text-muted-foreground">
{isActive
? t('organizations.current', { defaultValue: 'Current organization' })
: org.slug}
<button
type="button"
onClick={() => handleSelect(org)}
disabled={isSwitching}
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"
data-testid={`organization-card-${org.slug}`}
>
<Avatar className="h-10 w-10 rounded-md shrink-0">
{org.logo && <AvatarImage src={org.logo} alt={org.name} />}
<AvatarFallback className="rounded-md bg-primary/10 text-primary text-sm font-medium">
{getOrgInitials(org.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{org.name}</div>
<div className="truncate text-xs text-muted-foreground">
{isActive
? t('organizations.current', { defaultValue: 'Current organization' })
: org.slug}
</div>
</div>
</div>
<span
className="text-xs text-primary underline-offset-4 hover:underline shrink-0"
onClick={(e) => { e.stopPropagation(); navigate(`/organizations/${org.slug}/members`); }}
</button>
<button
type="button"
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"
onClick={() => navigate(`/organizations/${org.slug}/members`)}
data-testid={`organization-manage-${org.slug}`}
>
{t('organizations.manage', { defaultValue: 'Manage' })}
</span>
</button>
{isSwitching && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</button>
</div>
);
})}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

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

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
Expand Down Expand Up @@ -103,3 +103,25 @@ describe('OrganizationsPage — zero-org first run', () => {
);
});
});

describe('OrganizationsPage — the card\'s "Manage" control (objectui#13461)', () => {
it('is its own button beside the card button, and opens the members page without switching', async () => {
authState.organizations = [
{ id: 'o1', name: 'Alpha', slug: 'alpha' },
{ id: 'o2', name: 'Beta', slug: 'beta' },
];
render(<OrganizationsPage />);
const manage = await screen.findByTestId('organization-manage-alpha');
const card = screen.getByTestId('organization-card-alpha');
// A real button, reachable by Tab, and not nested in the card's button.
expect(manage.tagName).toBe('BUTTON');
expect(manage).toHaveAttribute('type', 'button');
expect(card.tagName).toBe('BUTTON');
expect(card.contains(manage)).toBe(false);
expect(manage.closest('button')).toBe(manage);

fireEvent.click(manage);
expect(navigate).toHaveBeenCalledWith('/organizations/alpha/members');
expect(authState.switchOrganization).not.toHaveBeenCalled();
});
});
7 changes: 6 additions & 1 deletion packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1192,7 +1192,12 @@ export function AppHeader({
<div data-topbar-group className="flex items-center gap-0.5 shrink-0"> {/* User Profile + Organization switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
aria-label={t('user.accountMenu', { defaultValue: 'Account menu' })}
>
<Avatar className="h-7 w-7 rounded-full">
<AvatarImage src={user?.image} alt={user?.name ?? 'User'} />
<AvatarFallback className="rounded-full bg-primary text-primary-foreground text-xs">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,12 @@ describe('AppHeader avatar menu — the single-org door to workspace management
expect(screen.getByTestId('header-my-organizations')).toBeInTheDocument();
});
});

describe('AppHeader avatar menu trigger — an accessible name (objectui#13461)', () => {
it('names the avatar button, which holds only the avatar', () => {
render(<AppHeader variant="home" />);
// The mocked `t` returns the call's `defaultValue`: the key is `user.accountMenu`.
const trigger = screen.getByRole('button', { name: 'Account menu' });
expect(trigger).toHaveTextContent('ZS');
});
});
9 changes: 8 additions & 1 deletion packages/app-shell/src/views/ActionResultDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,20 @@ function CodeListBlock({ value }: { value: string[] }) {
}

function SecretBlock({ value }: { value: string }) {
const { t } = useObjectTranslation();
const [revealed, setRevealed] = useState(false);
return (
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-muted px-2 py-1 text-sm font-mono break-all">
{revealed ? value : value.replace(/./g, '•')}
</code>
<Button type="button" variant="outline" size="sm" onClick={() => setRevealed((v) => !v)}>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRevealed((v) => !v)}
aria-label={revealed ? t('actions.resultDialog.hideValue') : t('actions.resultDialog.showValue')}
>
{revealed ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<CopyButton value={value} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ export function PermissionAdvancedFacets({
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => setPolicies(policies.filter((_, j) => j !== i))}
aria-label={t('perm.rls.remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
Expand Down Expand Up @@ -648,6 +649,7 @@ export function PermissionAdvancedFacets({
delete next[tab];
setTabs(next);
}}
aria-label={t('perm.tabs.remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function MetadataResourceHistoryPage({
variant="ghost"
size="sm"
onClick={() => setRefreshKey((k) => k + 1)}
aria-label={t('engine.edit.refresh', locale)}
>
<RefreshCw className="h-4 w-4" />
</Button>
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2547,6 +2547,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.rls.checkPlaceholder': 'optional — defaults to USING',
'perm.rls.empty': 'No row-level policies.',
'perm.rls.add': 'Add policy',
'perm.rls.remove': 'Remove policy',
'perm.rls.nameNeeded': 'Give the policy a name to save it.',
'perm.rls.nameRefused': 'Not saved. Policy name, on row {row}: {problem}',
// CEL authoring safety for RLS predicates (objectui#2413).
Expand Down Expand Up @@ -2585,6 +2586,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.tabs.help': 'Per-tab visibility for holders of this set.',
'perm.tabs.empty': 'No tab overrides.',
'perm.tabs.add': 'Add tab',
'perm.tabs.remove': 'Remove tab',
'perm.tabs.vis.visible': 'Visible',
'perm.tabs.vis.hidden': 'Hidden',
'perm.tabs.vis.default_on': 'Default on',
Expand Down Expand Up @@ -6095,6 +6097,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.rls.checkPlaceholder': '可选 — 默认同 USING',
'perm.rls.empty': '暂无行级策略。',
'perm.rls.add': '新增策略',
'perm.rls.remove': '移除策略',
'perm.rls.nameNeeded': '为策略填写名称后即可保存。',
'perm.rls.nameRefused': '未保存。第 {row} 行的策略名称:{problem}',
// 行级安全 CEL 编写安全 (objectui#2413)。
Expand Down Expand Up @@ -6131,6 +6134,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.tabs.help': '为此权限集持有者设置各标签页的可见性。',
'perm.tabs.empty': '暂无标签页覆盖。',
'perm.tabs.add': '新增标签页',
'perm.tabs.remove': '移除标签页',
'perm.tabs.vis.visible': '可见',
'perm.tabs.vis.hidden': '隐藏',
'perm.tabs.vis.default_on': '默认开启',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,6 @@ export function AppPreview({ name, draft, editing, selection, onSelectionChange,
const designMode = !!(editing && onSelectionChange);
const canEdit = designMode && !!onPatch;
const selectedId = selection && selection.kind === 'nav' ? selection.id : null;
const onSelect = designMode
? (path: string, item: NavItem) => onSelectionChange!({ kind: 'nav', id: path, label: item.label })
: undefined;

const baseRuntimeUrl = appName ? `/apps/${encodeURIComponent(appName)}/` : null;

Expand Down Expand Up @@ -400,7 +397,6 @@ export function AppPreview({ name, draft, editing, selection, onSelectionChange,
item={item}
depth={0}
path={`${rootKey}[${i}]`}
onSelect={onSelect}
selectedId={selectedId}
locale={locale}
/>
Expand All @@ -417,14 +413,12 @@ function NavRow({
item,
depth,
path,
onSelect,
selectedId,
locale,
}: {
item: NavItem;
depth: number;
path: string;
onSelect?: (path: string, item: NavItem) => void;
selectedId: string | null;
locale?: string;
}) {
Expand All @@ -433,10 +427,13 @@ function NavRow({
const selected = selectedId === path;
return (
<>
{/* objectui#13461 — a read-only row. It used to take an `onSelect` that
made the row a mouse-only click target, but this list renders only
outside design mode and the handler existed only inside it, so the
row never selected anything; design mode selects in `AppNavCanvas`. */}
<div
className={`flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent/40 ${onSelect ? 'cursor-pointer' : ''} ${selected ? 'bg-primary/5 ring-1 ring-primary' : ''}`}
className={`flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent/40 ${selected ? 'bg-primary/5 ring-1 ring-primary' : ''}`}
style={{ paddingLeft: `${12 + depth * 16}px` }}
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(path, item); } : undefined}
>
{/* eslint-disable-next-line react-hooks/static-components -- kindIcon returns a stable icon component from a static registry, not one created during render */}
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
Expand Down Expand Up @@ -472,7 +469,6 @@ function NavRow({
item={c}
depth={depth + 1}
path={`${path}.children[${i}]`}
onSelect={onSelect}
selectedId={selectedId}
locale={locale}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,22 @@ export function FlowCanvas({
title={branch.title}
onPointerDown={selectable ? (e) => e.stopPropagation() : undefined}
onClick={selectable ? (e) => { e.stopPropagation(); onSelectEdge!(edge, eid); } : undefined}
// objectui#13461 — the label is the edge's one keyboard
// stop: the wide hit path above is pointer-only.
role={selectable ? 'button' : undefined}
tabIndex={selectable ? 0 : undefined}
aria-pressed={selectable ? selected : undefined}
onKeyDown={
selectable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSelectEdge!(edge, eid);
}
}
: undefined
}
className={cn(
'max-w-full truncate rounded-full border bg-background/95 px-2 py-0.5 text-[10px] font-medium shadow-sm backdrop-blur-sm transition-colors',
selectable && 'cursor-pointer hover:border-primary/60',
Expand Down
Loading
Loading