diff --git a/.changeset/13461-keyboard-and-names.md b/.changeset/13461-keyboard-and-names.md new file mode 100644 index 0000000000..31e8a44451 --- /dev/null +++ b/.changeset/13461-keyboard-and-names.md @@ -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`. diff --git a/packages/app-shell/src/console/ai/LiveCanvas.tsx b/packages/app-shell/src/console/ai/LiveCanvas.tsx index 7bbdc7f3bf..a74bbf6255 100644 --- a/packages/app-shell/src/console/ai/LiveCanvas.tsx +++ b/packages/app-shell/src/console/ai/LiveCanvas.tsx @@ -137,7 +137,13 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re {t('topbar.designInStudio', { defaultValue: 'Design in Studio' })} )} - diff --git a/packages/app-shell/src/console/organizations/OrganizationsPage.tsx b/packages/app-shell/src/console/organizations/OrganizationsPage.tsx index c5860155dd..e1d108aa49 100644 --- a/packages/app-shell/src/console/organizations/OrganizationsPage.tsx +++ b/packages/app-shell/src/console/organizations/OrganizationsPage.tsx @@ -239,35 +239,47 @@ export function OrganizationsPage() { const isActive = org.id === activeOrganization?.id; const isSwitching = switchingId === org.id; return ( - + {isSwitching && } - + ); })} diff --git a/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx b/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx index 6b7ad1790b..1cd4d91059 100644 --- a/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx +++ b/packages/app-shell/src/console/organizations/__tests__/OrganizationsPage.test.tsx @@ -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>()), @@ -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(); + 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(); + }); +}); diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index ba344912a1..6b6e451bf1 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -1192,7 +1192,12 @@ export function AppHeader({
{/* User Profile + Organization switcher */} - diff --git a/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx b/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx index 1a04f154c7..c8d25d4d38 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx @@ -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')} > @@ -648,6 +649,7 @@ export function PermissionAdvancedFacets({ delete next[tab]; setTabs(next); }} + aria-label={t('perm.tabs.remove')} > diff --git a/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx index 0a98cb6689..d10bff245f 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx @@ -98,6 +98,7 @@ export function MetadataResourceHistoryPage({ variant="ghost" size="sm" onClick={() => setRefreshKey((k) => k + 1)} + aria-label={t('engine.edit.refresh', locale)} > diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 3eb4498491..61eaa4341b 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2547,6 +2547,7 @@ const ENGINE_STRINGS_EN: Record = { '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). @@ -2585,6 +2586,7 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -6095,6 +6097,7 @@ const ENGINE_STRINGS_ZH: Record = { 'perm.rls.checkPlaceholder': '可选 — 默认同 USING', 'perm.rls.empty': '暂无行级策略。', 'perm.rls.add': '新增策略', + 'perm.rls.remove': '移除策略', 'perm.rls.nameNeeded': '为策略填写名称后即可保存。', 'perm.rls.nameRefused': '未保存。第 {row} 行的策略名称:{problem}', // 行级安全 CEL 编写安全 (objectui#2413)。 @@ -6131,6 +6134,7 @@ const ENGINE_STRINGS_ZH: Record = { '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': '默认开启', diff --git a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx index 9b3d7c11c2..ade7868a2e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx @@ -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; @@ -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} /> @@ -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; }) { @@ -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`. */}
{ 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 */} @@ -472,7 +469,6 @@ function NavRow({ item={c} depth={depth + 1} path={`${path}.children[${i}]`} - onSelect={onSelect} selectedId={selectedId} locale={locale} /> diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx index aab45291a9..2b2c92bfdd 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -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', diff --git a/packages/components/src/__tests__/clickableNonControlCensus-13461.test.ts b/packages/components/src/__tests__/clickableNonControlCensus-13461.test.ts new file mode 100644 index 0000000000..c40b995063 --- /dev/null +++ b/packages/components/src/__tests__/clickableNonControlCensus-13461.test.ts @@ -0,0 +1,381 @@ +/** + * 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. + */ + +/** + * No shipped `div` or `span` may take a click without a keyboard path, unless + * the site is declared below (objectui#13461). + * + * ## The defect, and why it is a census + * + * A `div` with an `onClick` is a mouse-only control: it is not in the tab + * order, Enter and Space do nothing on it, and a screen reader announces no + * role, so a keyboard or screen-reader user can neither find nor use it. The + * remedy is a real `', + '
x
', + '
x
', + ' {/*
x
*/}', + '
', + ');', + ].join('\n'); + const scan = scanSource('fixture.tsx', fixture); + expect(scan.sites.map((s) => s.key)).toEqual([ + 'div · handleBgClick', + 'span · stopPropagation,navigate', + 'div · onSelect', + 'div · collapse', + ]); + // Every div / span with a live handler is read, keyboard path or not; the spread one too. + expect(scan.clickable).toBe(8); + }); +}); diff --git a/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts b/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts index c6afc75dc8..f0b9efe99d 100644 --- a/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts +++ b/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts @@ -228,20 +228,8 @@ const unnamed: Judged[] = iconOnly.filter((j) => !j.named); * that card's fix. */ const BACKLOG: Array<[file: string, icons: string[]]> = [ - ['packages/app-shell/src/console/ai/LiveCanvas.tsx', ['X']], - ['packages/app-shell/src/views/ActionResultDialog.tsx', ['EyeOff | Eye']], - ['packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx', ['Trash2', 'Trash2']], - ['packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx', ['RefreshCw']], - ['packages/components/src/custom/sort-builder.tsx', ['X']], - ['packages/components/src/renderers/form/file-upload.tsx', ['X']], - ['packages/fields/src/widgets/FileField.tsx', ['X']], - ['packages/fields/src/widgets/MasterDetailField.tsx', ['ExternalLink', 'ExternalLink', 'X']], - ['packages/fields/src/widgets/PasswordField.tsx', ['EyeOff | Eye']], - ['packages/fields/src/widgets/QRCodeField.tsx', ['Copy', 'QrCode']], ['packages/plugin-ai/src/AIFormAssist.tsx', ['RefreshCw', 'X', 'Check', 'X']], ['packages/plugin-ai/src/AIRecommendations.tsx', ['ExternalLink', 'X', 'ThumbsUp', 'ThumbsDown']], - ['packages/plugin-chatbot/src/elements/conversation.tsx', ['ArrowDownIcon']], - ['packages/plugin-chatbot/src/index.tsx', ['Send']], ['packages/plugin-detail/src/DetailView.tsx', ['ArrowLeft', 'ChevronLeft', 'ChevronRight']], ['packages/plugin-detail/src/RecordComments.tsx', ['Send']], ['packages/plugin-list/src/ListView.tsx', ['X']], diff --git a/packages/components/src/__tests__/keyboardControls-13461.test.tsx b/packages/components/src/__tests__/keyboardControls-13461.test.tsx new file mode 100644 index 0000000000..dc424812da --- /dev/null +++ b/packages/components/src/__tests__/keyboardControls-13461.test.tsx @@ -0,0 +1,123 @@ +/** + * 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#13461 — two `@object-ui/components` renderers whose click target was + * a mouse-only `div`. + * + * - `tree-view`: the node row was a clickable `div` around the expand toggle's + * `button`. The toggle is now named (`common.expand` / `common.collapse`) + * and carries `aria-expanded`; the node itself is a `button` only when the + * host passes `onNodeClick`, and plain text otherwise. + * - `file-upload`: the dropzone opens the file picker from the keyboard + * (Enter / Space), and each file's remove button is named after its file + * (`fields.file.remove`). + * + * Names are read against the pack, never a literal, and the zh case first + * proves the packs disagree. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { PredicateScopeProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; +// Registers `tree-view` and `file-upload`. +import '../renderers/data-display/tree-view'; +import '../renderers/form/file-upload'; + +afterEach(cleanup); + +const NODES = [ + { id: 'docs', label: 'Docs', children: [{ id: 'readme', label: 'Readme' }] }, + { id: 'notes', label: 'Notes' }, +]; + +function tree(schema: Record) { + const TreeView = ComponentRegistry.get('tree-view') as React.ComponentType<{ schema: unknown }>; + return ( + + + + ); +} + +describe('tree-view: the toggle and the node are separate, keyboard-reachable buttons (objectui#13461)', () => { + it('names the toggle from the pack and reports its state', () => { + render(tree({})); + const toggle = screen.getByRole('button', { name: en.common.expand }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(toggle).toHaveAccessibleName(en.common.collapse); + expect(screen.getByText('Readme')).toBeInTheDocument(); + }); + + it('draws a node as a button only when the host passes onNodeClick', () => { + const { unmount } = render(tree({})); + // Without a handler the only buttons are the toggles: the labels are text. + expect(screen.getAllByRole('button')).toHaveLength(1); + expect(screen.queryByRole('button', { name: 'Notes' })).toBeNull(); + unmount(); + + const onNodeClick = vi.fn(); + render(tree({ onNodeClick })); + const notes = screen.getByRole('button', { name: 'Notes' }); + expect(notes).toHaveAttribute('type', 'button'); + fireEvent.click(notes); + expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'notes' })); + // The toggle is not inside the node's button, and clicking it does not select. + const toggle = screen.getByRole('button', { name: en.common.expand }); + expect(screen.getByRole('button', { name: 'Docs' }).contains(toggle)).toBe(false); + onNodeClick.mockClear(); + fireEvent.click(toggle); + expect(onNodeClick).not.toHaveBeenCalled(); + }); + + it('reads the toggle name from the zh pack under zh', () => { + expect(zh.common.expand).not.toBe(en.common.expand); + render( + + {tree({})} + , + ); + expect(screen.getByRole('button', { name: zh.common.expand })).toBeInTheDocument(); + }); +}); + +describe('file-upload: the dropzone is a keyboard control and remove buttons are named (objectui#13461)', () => { + function upload() { + const FileUpload = ComponentRegistry.get('file-upload') as React.ComponentType<{ schema: unknown; value?: File[] }>; + const file = new File(['x'], 'report.pdf', { type: 'application/pdf' }); + return render( + + + , + ); + } + + it('opens the file picker on Enter and Space, and not on other keys', () => { + const { container } = upload(); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const pick = vi.spyOn(input, 'click').mockImplementation(() => {}); + const zone = container.querySelector('[role="button"]') as HTMLElement; + expect(zone).toHaveAttribute('tabindex', '0'); + fireEvent.keyDown(zone, { key: 'Enter' }); + fireEvent.keyDown(zone, { key: ' ' }); + fireEvent.keyDown(zone, { key: 'a' }); + expect(pick).toHaveBeenCalledTimes(2); + }); + + it("names each file's remove button after its file, from the pack", () => { + upload(); + const name = en.fields.file.remove.replace('{{name}}', 'report.pdf'); + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/custom/sort-builder.tsx b/packages/components/src/custom/sort-builder.tsx index ec3a3c7bf4..4d602d5beb 100644 --- a/packages/components/src/custom/sort-builder.tsx +++ b/packages/components/src/custom/sort-builder.tsx @@ -160,6 +160,7 @@ export function SortBuilder({ size="icon" className="h-9 w-9 shrink-0" onClick={() => removeItem(item.id)} + aria-label={t('sortBuilder.removeSort')} > diff --git a/packages/components/src/renderers/data-display/tree-view.tsx b/packages/components/src/renderers/data-display/tree-view.tsx index 575cb184cd..6f8bc7a879 100644 --- a/packages/components/src/renderers/data-display/tree-view.tsx +++ b/packages/components/src/renderers/data-display/tree-view.tsx @@ -12,6 +12,15 @@ import { ChevronRight, ChevronDown, Folder, File, FolderOpen } from 'lucide-reac import { useState } from 'react'; import { cn } from '../../lib/utils'; import { useDataScope } from '@object-ui/react'; +import { createSafeTranslation } from '@object-ui/i18n'; + +const useSafeTreeTranslation = createSafeTranslation( + { + 'common.expand': 'Expand', + 'common.collapse': 'Collapse', + }, + 'common.expand', +); const TreeNodeComponent = ({ node, @@ -20,6 +29,7 @@ const TreeNodeComponent = ({ node: TreeNode; onNodeClick?: (node: TreeNode) => void; }) => { + const { t } = useSafeTreeTranslation(); const [isOpen, setIsOpen] = useState(false); const hasChildren = node.children && node.children.length > 0; @@ -28,25 +38,43 @@ const TreeNodeComponent = ({ setIsOpen(!isOpen); }; - const handleClick = () => { - if (onNodeClick) { - onNodeClick(node); - } - }; + const nodeBody = ( + <> + {node.icon === 'folder' || hasChildren ? ( + isOpen ? + : + + ) : ( + + )} + + + {node.label} + + + ); return (
+ {/* objectui#13461 — the row is a container, not a click target: the + expand toggle and (when the host passes `onNodeClick`) the node itself + are each a real button, so both are reachable by keyboard. The node + used to be a clickable `div` around the toggle's `button`. */}
{hasChildren ? ( ) : ( - + nodeBody )} - - - {node.label} -
{hasChildren && isOpen && ( @@ -122,7 +147,8 @@ ComponentRegistry.register('tree-view', ))}
diff --git a/packages/components/src/renderers/form/file-upload.tsx b/packages/components/src/renderers/form/file-upload.tsx index fa5887bbdf..8476e35299 100644 --- a/packages/components/src/renderers/form/file-upload.tsx +++ b/packages/components/src/renderers/form/file-upload.tsx @@ -7,6 +7,7 @@ */ import { ComponentRegistry } from '@object-ui/core'; +import { createSafeTranslation } from '@object-ui/i18n'; import type { FileUploadSchema } from '@object-ui/types'; import { Label, Button } from '../../ui'; import { Upload, X, Rocket, CheckCircle2 } from 'lucide-react'; @@ -14,8 +15,16 @@ import { useState, useRef, useEffect } from 'react'; import { cn } from '../../lib/utils'; import { toFormControlDomProps } from '../../lib/form-control-dom-props'; +const useSafeFileUploadTranslation = createSafeTranslation( + { + 'fields.file.remove': 'Remove {{name}}', + }, + 'fields.file.remove', +); + ComponentRegistry.register('file-upload', ({ schema, className, value, onChange, ...props }: { schema: FileUploadSchema; className?: string; value?: File[]; onChange?: (files: File[]) => void; [key: string]: any }) => { + const { t } = useSafeFileUploadTranslation(); const [files, setFiles] = useState(value || []); const [isUploading, setIsUploading] = useState(false); const [progress, setProgress] = useState(0); @@ -93,8 +102,16 @@ ComponentRegistry.register('file-upload', {...toFormControlDomProps(inputProps)} /> -
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} className={cn( "relative overflow-hidden cursor-pointer rounded-lg border-2 border-dashed transition-all duration-300 min-h-[120px] flex flex-col items-center justify-center p-4 sm:p-6 gap-3", isUploading @@ -151,6 +168,7 @@ ComponentRegistry.register('file-upload', size="sm" className="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive" onClick={() => handleRemoveFile(index)} + aria-label={t('fields.file.remove', { name: file.name })} > diff --git a/packages/fields/src/__tests__/iconButtonNames-13461.test.tsx b/packages/fields/src/__tests__/iconButtonNames-13461.test.tsx new file mode 100644 index 0000000000..6e39f959ed --- /dev/null +++ b/packages/fields/src/__tests__/iconButtonNames-13461.test.tsx @@ -0,0 +1,67 @@ +/** + * 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#13461 — the field widgets' icon-only buttons carry a name, read + * from the pack: the password reveal toggle (`fields.password.*`), the QR-code + * widget's copy and show / hide buttons (`fields.qrCode.*`) and the + * master-detail rows' open / remove buttons (`fields.masterDetail.*`, named + * after the row). The icon-only-button census (objectui#12143) proves a name + * attribute is present; this file proves which name a screen reader hears. + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; +import { PasswordField } from '../widgets/PasswordField'; +import { QRCodeField } from '../widgets/QRCodeField'; +import { MasterDetailField } from '../widgets/MasterDetailField'; + +afterEach(cleanup); + +const inLocale = (language: 'en' | 'zh', node: React.ReactNode) => ( + + {node} + +); + +const noop = () => {}; +// The field metadata the three widgets read; the rest of the contract is irrelevant here. +const field = { name: 'f', type: 'text' } as never; + +describe('field widget icon-only buttons are named from the pack (objectui#13461)', () => { + it('PasswordField: the reveal toggle says what it will do', () => { + render(inLocale('en', )); + const toggle = screen.getByRole('button', { name: en.fields.password.show }); + fireEvent.click(toggle); + expect(toggle).toHaveAccessibleName(en.fields.password.hide); + }); + + it('QRCodeField: copy, and show / hide the code', () => { + render(inLocale('en', )); + expect(screen.getByRole('button', { name: en.fields.qrCode.copy })).toBeInTheDocument(); + const toggle = screen.getByRole('button', { name: en.fields.qrCode.show }); + fireEvent.click(toggle); + expect(toggle).toHaveAccessibleName(en.fields.qrCode.hide); + }); + + it('MasterDetailField: each row names its open and remove buttons after the row', () => { + render(inLocale('en', )); + const name = (pattern: string) => pattern.replace('{{name}}', 'Invoice 7'); + expect(screen.getByRole('button', { name: name(en.fields.masterDetail.view) })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: name(en.fields.masterDetail.remove) })).toBeInTheDocument(); + }); + + it('reads the zh pack under zh', () => { + expect(zh.fields.password.show).not.toBe(en.fields.password.show); + render(inLocale('zh', )); + expect(screen.getByRole('button', { name: zh.fields.password.show })).toBeInTheDocument(); + }); +}); diff --git a/packages/fields/src/widgets/FileField.tsx b/packages/fields/src/widgets/FileField.tsx index 5bea8782d7..dc07ce4e7e 100644 --- a/packages/fields/src/widgets/FileField.tsx +++ b/packages/fields/src/widgets/FileField.tsx @@ -431,6 +431,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange, handleRemove(idx); }} className="h-6 w-6 p-0" + aria-label={t('fields.file.remove', { name: file.name })} > diff --git a/packages/fields/src/widgets/MasterDetailField.tsx b/packages/fields/src/widgets/MasterDetailField.tsx index fa6cca432f..9e5e691397 100644 --- a/packages/fields/src/widgets/MasterDetailField.tsx +++ b/packages/fields/src/widgets/MasterDetailField.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Button, Badge, cn } from '@object-ui/components'; import { Plus, X, ExternalLink } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { FieldWidgetComponentProps } from './types.js'; // TOMBSTONE (objectui#4811): this widget is NOT in `fieldWidgetMap` @@ -39,6 +40,7 @@ export function MasterDetailField({ className, ...props }: FieldWidgetComponentProps) { + const { t } = useObjectTranslation(); const items = value || []; const config = field; @@ -77,6 +79,7 @@ export function MasterDetailField({ variant="ghost" size="sm" onClick={() => handleView(item)} + aria-label={t('fields.masterDetail.view', { name: item.label })} > @@ -108,6 +111,7 @@ export function MasterDetailField({ variant="ghost" size="sm" onClick={() => handleView(item)} + aria-label={t('fields.masterDetail.view', { name: item.label })} > @@ -117,6 +121,7 @@ export function MasterDetailField({ size="sm" onClick={() => handleRemove(item.id)} disabled={props.disabled} + aria-label={t('fields.masterDetail.remove', { name: item.label })} > diff --git a/packages/fields/src/widgets/PasswordField.tsx b/packages/fields/src/widgets/PasswordField.tsx index ccc024acdf..95d8d47c02 100644 --- a/packages/fields/src/widgets/PasswordField.tsx +++ b/packages/fields/src/widgets/PasswordField.tsx @@ -3,6 +3,7 @@ import { Input, EmptyValue } from '@object-ui/components'; import { Button } from '@object-ui/components'; import { isEmptyValue } from '@object-ui/core'; import { Eye, EyeOff } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { coerceToSafeValue } from '../coerceToSafeValue.js'; import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; @@ -64,6 +65,7 @@ function passwordAutoComplete(name: unknown): 'current-password' | 'new-password * See {@link passwordAutoComplete} for how the token is picked. */ export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps) { + const { t } = useObjectTranslation(); const [showPassword, setShowPassword] = useState(false); const config = field; @@ -93,6 +95,7 @@ export function PasswordField({ value, onChange, field, readonly, className, ... className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" onClick={() => setShowPassword(!showPassword)} tabIndex={-1} + aria-label={showPassword ? t('fields.password.hide') : t('fields.password.show')} > {showPassword ? ( diff --git a/packages/fields/src/widgets/QRCodeField.tsx b/packages/fields/src/widgets/QRCodeField.tsx index 95d8130274..14169be3a6 100644 --- a/packages/fields/src/widgets/QRCodeField.tsx +++ b/packages/fields/src/widgets/QRCodeField.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Input, Button, EmptyValue } from '@object-ui/components'; import { QrCode, Copy } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; @@ -9,6 +10,7 @@ import { toDomProps } from './toDomProps.js'; * Uses a simple SVG-based QR code generator */ export function QRCodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) { + const { t } = useObjectTranslation(); const [showQR, setShowQR] = React.useState(false); const config = field; @@ -68,6 +70,7 @@ export function QRCodeField({ value, onChange, field, readonly, error, ...props variant="outline" size="sm" onClick={copyToClipboard} + aria-label={t('fields.qrCode.copy')} > @@ -76,6 +79,7 @@ export function QRCodeField({ value, onChange, field, readonly, error, ...props variant="outline" size="sm" onClick={() => setShowQR(!showQR)} + aria-label={showQR ? t('fields.qrCode.hide') : t('fields.qrCode.show')} > diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index defffab793..4c5ad99fd3 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -69,6 +69,9 @@ const ar = { noRecords: 'لا توجد سجلات', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: 'توسيع', + collapse: 'طي', addToFavorites: "إضافة إلى المفضلة", removeFromFavorites: "إزالة من المفضلة", loading: "جارٍ التحميل…", @@ -141,6 +144,9 @@ const ar = { helpMultiValue: 'يُمرَّر إلى التدفق كمخرج للقرار. افصل بين القيم المتعددة بفواصل.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: 'إظهار القيمة', + hideValue: 'إخفاء القيمة', defaultTitle: 'احفظ هذه القيمة الآن', acknowledge: 'لقد حفظتها', copyAll: 'نسخ الكل', @@ -246,6 +252,20 @@ const ar = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: 'فتح {{name}}', + remove: 'إزالة {{name}}', + }, + password: { + show: 'إظهار كلمة المرور', + hide: 'إخفاء كلمة المرور', + }, + qrCode: { + copy: 'نسخ القيمة', + show: 'إظهار رمز QR', + hide: 'إخفاء رمز QR', + }, image: { upload: "رفع صورة", addMore: "إضافة المزيد من الصور", @@ -3197,6 +3217,8 @@ const ar = { forecastSoon: "عرض التوقعات قادم قريباً", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'قائمة الحساب', profile: "الملف الشخصي", settings: "الإعدادات", logout: "تسجيل الخروج", @@ -3657,6 +3679,9 @@ const ar = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'إرسال الرسالة', + scrollToBottom: 'الانتقال إلى أحدث رسالة', answer: { showFull: 'عرض الإجابة كاملة', collapse: 'طي', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 0778ab19a5..e11c89ed75 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -62,6 +62,9 @@ const de = { noRecords: 'Keine Datensätze gefunden', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: 'Erweitern', + collapse: 'Einklappen', addToFavorites: "Zu Favoriten hinzufügen", removeFromFavorites: "Aus Favoriten entfernen", loading: "Wird geladen…", @@ -127,6 +130,9 @@ const de = { helpMultiValue: 'Wird dem Flow als Entscheidungsausgabe übergeben. Mehrere Werte durch Komma trennen.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: 'Wert anzeigen', + hideValue: 'Wert verbergen', defaultTitle: 'Diesen Wert jetzt speichern', acknowledge: 'Ich habe ihn gespeichert', copyAll: 'Alle kopieren', @@ -232,6 +238,20 @@ const de = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: '{{name}} öffnen', + remove: '{{name}} entfernen', + }, + password: { + show: 'Passwort anzeigen', + hide: 'Passwort verbergen', + }, + qrCode: { + copy: 'Wert kopieren', + show: 'QR-Code anzeigen', + hide: 'QR-Code verbergen', + }, image: { upload: "Bild hochladen", addMore: "Weitere Bilder hinzufügen", @@ -2968,6 +2988,8 @@ const de = { forecastSoon: "Prognoseansicht kommt bald", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'Kontomenü', profile: "Profil", settings: "Einstellungen", logout: "Abmelden", @@ -3392,6 +3414,9 @@ const de = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'Nachricht senden', + scrollToBottom: 'Zur neuesten Nachricht scrollen', answer: { showFull: 'Ganze Antwort anzeigen', collapse: 'Einklappen', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f050e925e1..72e4d1ac05 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -68,6 +68,9 @@ const en = { retry: 'Retry', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: 'Expand', + collapse: 'Collapse', loading: 'Loading…', save: 'Save', // The SHARED "throw away the unsaved draft" verb, read by @@ -144,6 +147,9 @@ const en = { helpMultiValue: 'Handed to the flow as a decision output. Comma-separate multiple values.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: 'Show value', + hideValue: 'Hide value', defaultTitle: 'Save this value now', acknowledge: 'I have saved this', copyAll: 'Copy all', @@ -348,6 +354,20 @@ const en = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: 'Open {{name}}', + remove: 'Remove {{name}}', + }, + password: { + show: 'Show password', + hide: 'Hide password', + }, + qrCode: { + copy: 'Copy value', + show: 'Show QR code', + hide: 'Hide QR code', + }, relativeDate: { overdue: 'Overdue {{count}}d', overdue_one: 'Overdue {{count}}d', @@ -3689,6 +3709,8 @@ const en = { forecastSoon: 'Forecast view coming soon', }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'Account menu', profile: 'Profile', settings: 'Settings', logout: 'Log out', @@ -4131,6 +4153,9 @@ const en = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'Send message', + scrollToBottom: 'Scroll to the latest message', answer: { showFull: 'Show full answer', collapse: 'Collapse', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8e1554503b..4a18880144 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -63,6 +63,9 @@ const es = { noRecords: 'No se encontraron registros', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: 'Expandir', + collapse: 'Contraer', addToFavorites: "Añadir a favoritos", removeFromFavorites: "Quitar de favoritos", loading: "Cargando…", @@ -129,6 +132,9 @@ const es = { helpMultiValue: 'Se transfiere al flujo como salida de la decisión. Separe varios valores con comas.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: 'Mostrar valor', + hideValue: 'Ocultar valor', defaultTitle: 'Guarda este valor ahora', acknowledge: 'Lo he guardado', copyAll: 'Copiar todo', @@ -239,6 +245,20 @@ const es = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: 'Abrir {{name}}', + remove: 'Quitar {{name}}', + }, + password: { + show: 'Mostrar contraseña', + hide: 'Ocultar contraseña', + }, + qrCode: { + copy: 'Copiar valor', + show: 'Mostrar código QR', + hide: 'Ocultar código QR', + }, image: { upload: "Subir imagen", addMore: "Añadir más imágenes", @@ -3026,6 +3046,8 @@ const es = { forecastSoon: "La vista de pronóstico llegará pronto", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'Menú de la cuenta', profile: "Perfil", settings: "Configuración", logout: "Cerrar sesión", @@ -3459,6 +3481,9 @@ const es = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'Enviar mensaje', + scrollToBottom: 'Ir al último mensaje', answer: { showFull: 'Mostrar respuesta completa', collapse: 'Contraer', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 23c1974f20..7743e8db24 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -63,6 +63,9 @@ const fr = { noRecords: 'Aucun enregistrement trouvé', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: 'Développer', + collapse: 'Réduire', addToFavorites: "Ajouter aux favoris", removeFromFavorites: "Retirer des favoris", loading: "Chargement…", @@ -129,6 +132,9 @@ const fr = { helpMultiValue: 'Transmis au flux comme sortie de décision. Séparez les valeurs multiples par des virgules.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: 'Afficher la valeur', + hideValue: 'Masquer la valeur', defaultTitle: 'Enregistrez cette valeur maintenant', acknowledge: 'Je l\'ai enregistrée', copyAll: 'Tout copier', @@ -234,6 +240,20 @@ const fr = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: 'Ouvrir {{name}}', + remove: 'Supprimer {{name}}', + }, + password: { + show: 'Afficher le mot de passe', + hide: 'Masquer le mot de passe', + }, + qrCode: { + copy: 'Copier la valeur', + show: 'Afficher le code QR', + hide: 'Masquer le code QR', + }, image: { upload: "Téléverser une image", addMore: "Ajouter d'autres images", @@ -3022,6 +3042,8 @@ const fr = { forecastSoon: "La vue de prévision arrive bientôt", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'Menu du compte', profile: "Profil", settings: "Paramètres", logout: "Se déconnecter", @@ -3455,6 +3477,9 @@ const fr = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'Envoyer le message', + scrollToBottom: 'Aller au dernier message', answer: { showFull: 'Afficher toute la réponse', collapse: 'Réduire', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f9ebbfe3ab..15ba06815e 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -62,6 +62,9 @@ const ja = { noRecords: 'レコードが見つかりません', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: '展開', + collapse: '折りたたむ', addToFavorites: "お気に入りに追加", removeFromFavorites: "お気に入りから削除", loading: "読み込み中…", @@ -127,6 +130,9 @@ const ja = { helpMultiValue: '決定の出力としてフローに渡されます。複数の値はカンマで区切ってください。', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: '値を表示', + hideValue: '値を非表示', defaultTitle: 'この値を今すぐ保存してください', acknowledge: '保存しました', copyAll: 'すべてコピー', @@ -232,6 +238,20 @@ const ja = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: '{{name}} を開く', + remove: '{{name}} を削除', + }, + password: { + show: 'パスワードを表示', + hide: 'パスワードを非表示', + }, + qrCode: { + copy: '値をコピー', + show: 'QR コードを表示', + hide: 'QR コードを非表示', + }, image: { upload: "画像をアップロード", addMore: "画像を追加", @@ -2970,6 +2990,8 @@ const ja = { forecastSoon: "予測ビューは近日公開予定", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'アカウントメニュー', profile: "プロフィール", settings: "設定", logout: "ログアウト", @@ -3394,6 +3416,9 @@ const ja = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'メッセージを送信', + scrollToBottom: '最新のメッセージまでスクロール', answer: { showFull: '全文を表示', collapse: '折りたたむ', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index df5db68bbe..96cfba1fb9 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -62,6 +62,9 @@ const ko = { noRecords: '레코드를 찾을 수 없습니다', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: '펼치기', + collapse: '접기', addToFavorites: "즐겨찾기에 추가", removeFromFavorites: "즐겨찾기에서 제거", loading: "로딩 중…", @@ -127,6 +130,9 @@ const ko = { helpMultiValue: '결정 출력으로 플로우에 전달됩니다. 여러 값은 쉼표로 구분하세요.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: '값 표시', + hideValue: '값 숨기기', defaultTitle: '이 값을 지금 저장하세요', acknowledge: '저장했습니다', copyAll: '모두 복사', @@ -232,6 +238,20 @@ const ko = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: '{{name}} 열기', + remove: '{{name}} 제거', + }, + password: { + show: '비밀번호 표시', + hide: '비밀번호 숨기기', + }, + qrCode: { + copy: '값 복사', + show: 'QR 코드 표시', + hide: 'QR 코드 숨기기', + }, image: { upload: "이미지 업로드", addMore: "이미지 더 추가", @@ -2967,6 +2987,8 @@ const ko = { forecastSoon: "예측 보기가 곧 출시됩니다", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: '계정 메뉴', profile: "프로필", settings: "설정", logout: "로그아웃", @@ -3391,6 +3413,9 @@ const ko = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: '메시지 보내기', + scrollToBottom: '최신 메시지로 스크롤', answer: { showFull: '전체 답변 보기', collapse: '접기', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 6a0ea4d882..a6fa90e50f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -63,6 +63,9 @@ const pt = { noRecords: 'Nenhum registro encontrado', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: 'Expandir', + collapse: 'Recolher', addToFavorites: "Adicionar aos favoritos", removeFromFavorites: "Remover dos favoritos", loading: "Carregando…", @@ -129,6 +132,9 @@ const pt = { helpMultiValue: 'Entregue ao fluxo como saída da decisão. Separe vários valores por vírgulas.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: 'Mostrar valor', + hideValue: 'Ocultar valor', defaultTitle: 'Guarde este valor agora', acknowledge: 'Eu o salvei', copyAll: 'Copiar tudo', @@ -234,6 +240,20 @@ const pt = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: 'Abrir {{name}}', + remove: 'Remover {{name}}', + }, + password: { + show: 'Mostrar senha', + hide: 'Ocultar senha', + }, + qrCode: { + copy: 'Copiar valor', + show: 'Mostrar código QR', + hide: 'Ocultar código QR', + }, image: { upload: "Enviar imagem", addMore: "Adicionar mais imagens", @@ -3021,6 +3041,8 @@ const pt = { forecastSoon: "A exibição de previsão está chegando em breve", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'Menu da conta', profile: "Perfil", settings: "Configurações", logout: "Sair", @@ -3454,6 +3476,9 @@ const pt = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'Enviar mensagem', + scrollToBottom: 'Ir para a mensagem mais recente', answer: { showFull: 'Mostrar resposta completa', collapse: 'Recolher', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index a17b83e721..9593633388 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -64,6 +64,9 @@ const ru = { noRecords: 'Записи не найдены', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: 'Развернуть', + collapse: 'Свернуть', addToFavorites: "Добавить в избранное", removeFromFavorites: "Удалить из избранного", loading: "Загрузка…", @@ -134,6 +137,9 @@ const ru = { helpMultiValue: 'Передаётся в поток как результат решения. Несколько значений разделяйте запятыми.', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: 'Показать значение', + hideValue: 'Скрыть значение', defaultTitle: 'Сохраните это значение сейчас', acknowledge: 'Я сохранил(а) это', copyAll: 'Копировать всё', @@ -239,6 +245,20 @@ const ru = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: 'Открыть {{name}}', + remove: 'Удалить {{name}}', + }, + password: { + show: 'Показать пароль', + hide: 'Скрыть пароль', + }, + qrCode: { + copy: 'Копировать значение', + show: 'Показать QR-код', + hide: 'Скрыть QR-код', + }, image: { upload: "Загрузить изображение", addMore: "Добавить ещё изображения", @@ -3093,6 +3113,8 @@ const ru = { forecastSoon: "Прогнозное представление скоро будет", }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: 'Меню аккаунта', profile: "Профиль", settings: "Настройки", logout: "Выйти", @@ -3535,6 +3557,9 @@ const ru = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: 'Отправить сообщение', + scrollToBottom: 'Перейти к последнему сообщению', answer: { showFull: 'Показать ответ полностью', collapse: 'Свернуть', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b7289ebbec..307e7a4e63 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -67,6 +67,9 @@ const zh = { retry: '重试', }, common: { + // objectui#13461 — a disclosure toggle's name (the tree-view node toggle). + expand: '展开', + collapse: '收起', loading: '加载中…', save: '保存', discard: '放弃', @@ -132,6 +135,9 @@ const zh = { helpMultiValue: '作为决策输出传递给流程。多个值请用逗号分隔。', }, resultDialog: { + // objectui#13461 — the secret block's reveal toggle, an icon-only button. + showValue: '显示值', + hideValue: '隐藏值', defaultTitle: '请立即保存此值', acknowledge: '我已保存', copyAll: '全部复制', @@ -238,6 +244,20 @@ const zh = { }, }, fields: { + // objectui#13461 — names for icon-only buttons in the field widgets. + masterDetail: { + view: '打开 {{name}}', + remove: '移除 {{name}}', + }, + password: { + show: '显示密码', + hide: '隐藏密码', + }, + qrCode: { + copy: '复制值', + show: '显示二维码', + hide: '隐藏二维码', + }, relativeDate: { overdue: '逾期 {{count}} 天', overdue_one: '逾期 {{count}} 天', @@ -3175,6 +3195,8 @@ const zh = { forecastSoon: '预测视图即将上线', }, user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: '账户菜单', profile: '个人资料', settings: '设置', logout: '注销', @@ -3572,6 +3594,9 @@ const zh = { // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them // against the registry instead of against its own hand-held list. chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: '发送消息', + scrollToBottom: '滚动到最新消息', answer: { showFull: '展开全文', collapse: '收起', diff --git a/packages/plugin-chatbot/src/elements/conversation.tsx b/packages/plugin-chatbot/src/elements/conversation.tsx index 44994b2012..2de2ba5d97 100644 --- a/packages/plugin-chatbot/src/elements/conversation.tsx +++ b/packages/plugin-chatbot/src/elements/conversation.tsx @@ -8,6 +8,7 @@ import { Button } from "@object-ui/components"; import { cn } from "@object-ui/components"; +import { useObjectTranslation } from "@object-ui/i18n"; import { ArrowDownIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { useCallback } from "react"; @@ -86,6 +87,7 @@ export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => { + const { t } = useObjectTranslation(); const { isAtBottom, scrollToBottom } = useStickToBottomContext(); const handleScrollToBottom = useCallback(() => { @@ -104,6 +106,8 @@ export const ConversationScrollButton = ({ size="icon" type="button" variant="outline" + // Before the spread, so a caller's own name still wins. + aria-label={t("chatbot.scrollToBottom")} {...props} > diff --git a/packages/plugin-chatbot/src/index.tsx b/packages/plugin-chatbot/src/index.tsx index 3f358296c7..e512569191 100644 --- a/packages/plugin-chatbot/src/index.tsx +++ b/packages/plugin-chatbot/src/index.tsx @@ -10,6 +10,7 @@ import * as React from "react" import { cn } from "@object-ui/components" import { Button, Input, ScrollArea, Avatar, AvatarFallback, AvatarImage } from "@object-ui/components" import { Send } from "lucide-react" +import { useObjectTranslation } from "@object-ui/i18n" // The package's single chat message contract — the RUNTIME shape, named // `ChatbotEnhancedMessage` so it no longer shares a name with // `@object-ui/types`' authoring `ChatMessage` (objectui#6349, batch 5). See the @@ -49,6 +50,7 @@ const Chatbot = React.forwardRef( }, ref ) => { + const { t } = useObjectTranslation() const [inputValue, setInputValue] = React.useState("") const scrollRef = React.useRef(null) const inputRef = React.useRef(null) @@ -127,6 +129,7 @@ const Chatbot = React.forwardRef( onClick={handleSend} disabled={disabled || !inputValue.trim()} size="icon" + aria-label={t("chatbot.sendMessage")} >