Skip to content
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
9 changes: 9 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -889,3 +889,12 @@ The outer animated surface owns both paint and backdrop blur; its content is
transparent and unfiltered. An opaque fallback covers unsupported filters and
reduced-transparency preferences. Tooltip declares ownership of its entrance
motion to avoid the product's additional popup animation.

### Layout-owned field labels

`FieldAccessibilityProvider` accepts `aria-labelledby` and `aria-describedby`
references for layouts that render labels outside their controls. Input, Switch,
Checkbox, Select, Combobox, MultiSelect, NumberInput, SearchField and Textarea
consume those references through intervening wrappers. A control's explicit name
or own label takes precedence; descriptions are combined. Give each child in a
composite field its own name. Nested `Field` controls retain their own labels.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFieldAccessibility } from "../Field/FieldAccessibility";
import { Check as LucideCheck, Minus as LucideMinus } from 'lucide-react';
import {
forwardRef,
Expand Down Expand Up @@ -44,6 +45,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Che
size = "md",
...props
}, forwardedRef) {
const fieldAccessibility = useFieldAccessibility(props, label !== undefined || children !== undefined);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);

Expand All @@ -67,6 +69,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Che
<span className={styles.control} data-openbitfun-part="control">
<input
{...props}
{...fieldAccessibility}
aria-invalid={invalid || undefined}
checked={checked}
className={styles.input}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFieldAccessibility } from "../Field/FieldAccessibility";
import {
forwardRef,
useCallback,
Expand Down Expand Up @@ -501,13 +502,14 @@ const CollectionPicker = forwardRef<HTMLDivElement, PickerProps>(function Collec
const activeDescendant = resolvedOpen && activeIndex >= 0
? optionId(activeIndex)
: undefined;
const fieldAccessibility = useFieldAccessibility({ ...props, "aria-describedby": ariaDescribedBy }, Boolean(label));
const resolvedTriggerLabel = ariaLabel
?? (label || providedId || typeof placeholder !== "string" ? undefined : placeholder);
?? (label || fieldAccessibility["aria-labelledby"] || providedId || typeof placeholder !== "string" ? undefined : placeholder);
const resolvedLabelledBy = ariaLabel
? ariaLabelledBy
: ariaLabelledBy ?? (label ? labelId : undefined);
: ariaLabelledBy ?? (label ? labelId : fieldAccessibility["aria-labelledby"]);
const resolvedDescribedBy = [
ariaDescribedBy,
fieldAccessibility["aria-describedby"],
errorMessage ? errorId : undefined,
].filter(Boolean).join(" ") || undefined;

Expand Down
3 changes: 2 additions & 1 deletion design-system/packages/ui/src/components/Field/Field.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FieldAccessibilityProvider } from "./FieldAccessibility";
import {
cloneElement,
forwardRef,
Expand Down Expand Up @@ -112,7 +113,7 @@ export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field({
{controlLeading}
</span>
)}
{control}
<FieldAccessibilityProvider>{control}</FieldAccessibilityProvider>
{controlTrailing !== undefined && controlTrailing !== null && (
<span className={styles.controlAdornment} data-openbitfun-part="control-trailing">
{controlTrailing}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createContext, useContext, type AriaAttributes, type ReactNode } from "react";

type FieldAccessibility = Pick<AriaAttributes, "aria-labelledby" | "aria-describedby">;
const FieldAccessibilityContext = createContext<FieldAccessibility>({});

/** Associates a layout-owned label with controls, including controls inside wrappers.
* Composite fields should give each child its own accessible name.
*/
export function FieldAccessibilityProvider({ children, ...value }: FieldAccessibility & { children: ReactNode }) {
return <FieldAccessibilityContext.Provider value={value}>{children}</FieldAccessibilityContext.Provider>;
}

export function useFieldAccessibility(props: AriaAttributes, hasOwnLabel = false): FieldAccessibility {
const inherited = useContext(FieldAccessibilityContext);
return {
"aria-labelledby": props["aria-labelledby"] ?? (
props["aria-label"] || hasOwnLabel ? undefined : inherited["aria-labelledby"]
),
"aria-describedby": [...new Set([
props["aria-describedby"], inherited["aria-describedby"],
].filter(Boolean).flatMap(value => value!.split(/\s+/)))].join(" ") || undefined,
};
}
3 changes: 3 additions & 0 deletions design-system/packages/ui/src/components/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFieldAccessibility } from "../Field/FieldAccessibility";
import {
forwardRef,
useRef,
Expand Down Expand Up @@ -41,6 +42,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
type = "text",
...props
}, ref) {
const fieldAccessibility = useFieldAccessibility(props);
const compositionActiveRef = useRef(false);
const fieldSurface = useFieldSurface();
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
Expand All @@ -67,6 +69,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
)}
<input
{...props}
{...fieldAccessibility}
aria-invalid={resolvedAriaInvalid}
className={styles.input}
data-hide-native-password-reveal={hideNativePasswordReveal ? "true" : undefined}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFieldAccessibility } from "../Field/FieldAccessibility";
import {
forwardRef,
useCallback,
Expand Down Expand Up @@ -67,6 +68,11 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(functi
"aria-invalid": ariaInvalid,
}, ref) {
const fieldSurface = useFieldSurface();
const fieldAccessibility = useFieldAccessibility({
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
"aria-describedby": ariaDescribedBy,
}, Boolean(label));
const format = useCallback((next: number) => precision > 0 ? next.toFixed(precision) : String(Math.round(next)), [precision]);
const clamp = useCallback((next: number) => Math.min(max, Math.max(min, next)), [max, min]);
const [draft, setDraft] = useState(() => format(value));
Expand Down Expand Up @@ -106,8 +112,8 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(functi
<input
id={id}
required={required}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
aria-labelledby={fieldAccessibility["aria-labelledby"]}
aria-describedby={fieldAccessibility["aria-describedby"]}
aria-invalid={ariaInvalid}
aria-label={ariaLabel ?? label}
className={styles.input}
Expand Down
20 changes: 13 additions & 7 deletions design-system/packages/ui/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFieldAccessibility } from "../Field/FieldAccessibility";
import {
forwardRef,
useCallback,
Expand Down Expand Up @@ -159,6 +160,11 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
}, forwardedRef) {
const designSystem = useDesignSystem();
const fieldSurface = useFieldSurface();
const fieldAccessibility = useFieldAccessibility({
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
"aria-describedby": ariaDescribedBy,
});
const generatedId = useId();
const id = providedId ?? `openbitfun-select-${generatedId}`;
const listboxId = `${id}-listbox`;
Expand Down Expand Up @@ -390,12 +396,12 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
<button
data-overflow-trigger
aria-controls={listboxId}
aria-describedby={ariaDescribedBy}
aria-describedby={fieldAccessibility["aria-describedby"]}
aria-expanded="true"
aria-haspopup="listbox"
aria-invalid={invalid ? true : ariaInvalid}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-labelledby={fieldAccessibility["aria-labelledby"]}
aria-required={required || ariaRequired || undefined}
className={classNames(styles.trigger, styles.popoverHeader)}
data-openbitfun-part="header"
Expand All @@ -413,7 +419,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
<div className={styles.options} data-openbitfun-part="options">
<Listbox
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : ariaLabelledBy ?? id}
aria-labelledby={ariaLabel ? undefined : fieldAccessibility["aria-labelledby"] ?? id}
autoFocusOption
className={styles.listbox}
id={listboxId}
Expand Down Expand Up @@ -452,11 +458,11 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
>
<select
{...nativeProps}
aria-describedby={ariaDescribedBy}
aria-describedby={fieldAccessibility["aria-describedby"]}
aria-hidden="true"
aria-invalid={invalid ? true : ariaInvalid}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-labelledby={fieldAccessibility["aria-labelledby"]}
aria-required={required || ariaRequired || undefined}
className={styles.native}
disabled={disabled}
Expand All @@ -479,13 +485,13 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
</select>
<button data-overflow-trigger
aria-controls={resolvedOpen ? undefined : listboxId}
aria-describedby={ariaDescribedBy}
aria-describedby={fieldAccessibility["aria-describedby"]}
aria-expanded={resolvedOpen ? undefined : false}
aria-hidden={resolvedOpen || undefined}
aria-haspopup="listbox"
aria-invalid={invalid ? true : ariaInvalid}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-labelledby={fieldAccessibility["aria-labelledby"]}
aria-required={required || ariaRequired || undefined}
autoFocus={!resolvedOpen && autoFocus}
className={styles.trigger}
Expand Down
3 changes: 3 additions & 0 deletions design-system/packages/ui/src/components/Switch/Switch.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFieldAccessibility } from "../Field/FieldAccessibility";
import {
forwardRef,
type ChangeEventHandler,
Expand All @@ -17,6 +18,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(
onCheckedChange,
...props
}, ref) {
const fieldAccessibility = useFieldAccessibility(props);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
onChange?.(event);
onCheckedChange?.(event.currentTarget.checked);
Expand All @@ -29,6 +31,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(
>
<input
{...props}
{...fieldAccessibility}
className={styles.input}
onChange={handleChange}
ref={ref}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFieldAccessibility } from "../Field/FieldAccessibility";
import {
forwardRef,
useCallback,
Expand Down Expand Up @@ -68,6 +69,11 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
const resolvedInvalid = invalid || (ariaInvalid !== undefined && ariaInvalid !== false && ariaInvalid !== "false");
const hasSupport = Boolean((resolvedInvalid && errorMessage) || (!resolvedInvalid && hint) || showCount);

const fieldAccessibility = useFieldAccessibility({
...props,
"aria-describedby": [ariaDescribedBy, hasSupport ? supportId : undefined].filter(Boolean).join(" "),
}, Boolean(label));

const resizeToContent = useCallback((node: HTMLTextAreaElement) => {
if (!autoResize) return;
node.style.height = "auto";
Expand Down Expand Up @@ -111,7 +117,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
)}
<textarea
{...props}
aria-describedby={hasSupport ? supportId : ariaDescribedBy}
{...fieldAccessibility}
aria-invalid={resolvedInvalid || undefined}
className={styles.textarea}
data-openbitfun-part="input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,22 @@ export interface ToolDurationProps {
showCompletedDuration?: boolean;
completedStatus?: 'success' | 'error' | 'cancelled';
completionLabel?: string;
/** Host-owned localization. Omit to retain the default compact format. */
formatDuration?: (milliseconds: number, precision: 'live' | 'precise') => string;
children?: ReactNode;
}

/** Controlled duration anatomy. Clock, timeout mutations and overlays belong to the host. */
export function ToolDuration({
isRunning, showIcon = true, elapsedMs = 0, remainingMs, timeoutMs, timeoutDisabled = false, open = false,
completedDurationMs, showCompletedDuration = true, completedStatus, completionLabel, children,
completedDurationMs, showCompletedDuration = true, completedStatus, completionLabel, children, formatDuration,
}: ToolDurationProps) {
if (!isRunning) {
if (completedDurationMs == null || !showCompletedDuration) return null;
return <span data-openbitfun-component="tool-timeout-indicator" data-openbitfun-part="root" data-openbitfun-mode="completed"
className={`duration-text duration-text--completed${completedStatus ? ` duration-text--completed-${completedStatus}` : ''}`}
title={completionLabel} aria-label={completionLabel}>
<span data-openbitfun-component="tool-timeout-indicator" data-openbitfun-part="duration">{formatDurationPrecise(completedDurationMs)}</span>
<span data-openbitfun-component="tool-timeout-indicator" data-openbitfun-part="duration">{formatDuration?.(completedDurationMs, 'precise') ?? formatDurationPrecise(completedDurationMs)}</span>
</span>;
}
const hasTimeout = Boolean(timeoutMs && timeoutMs > 0);
Expand All @@ -62,13 +64,13 @@ export function ToolDuration({
data-openbitfun-state={[isWarning && 'warning', timeoutDisabled && 'disabled', open && 'open'].filter(Boolean).join(' ')} className="tool-timeout-indicator">
<span data-openbitfun-component="tool-timeout-indicator" data-openbitfun-part="duration" className={`duration-text duration-text--live ${isWarning ? 'duration-text--warning' : ''}`}>
{showIcon && <Icon glyph={Timer} size="sm" />}
<span data-openbitfun-component="tool-timeout-indicator" data-openbitfun-part="elapsed" className="duration-elapsed">{formatDurationLive(elapsedMs)}</span>
<span data-openbitfun-component="tool-timeout-indicator" data-openbitfun-part="elapsed" className="duration-elapsed">{formatDuration?.(elapsedMs, 'live') ?? formatDurationLive(elapsedMs)}</span>
{hasTimeout && <>
<span className="duration-separator">/</span>
<span data-openbitfun-component="tool-timeout-indicator" data-openbitfun-part="timeout"
className={`duration-timeout ${timeoutDisabled ? 'duration-timeout--disabled' : ''} ${isWarning ? 'duration-timeout--warning' : ''}`}>
{timeoutDisabled ? <Icon glyph={InfinityIcon} size="sm" className="duration-timeout--infinity" />
: formatDurationLive(displayRemaining ?? timeoutMs!)}
: (formatDuration?.(displayRemaining ?? timeoutMs!, 'live') ?? formatDurationLive(displayRemaining ?? timeoutMs!))}
</span>
</>}
</span>
Expand Down
2 changes: 2 additions & 0 deletions design-system/packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,5 @@ export {
export { LayersPlusIcon } from "./components/Icon";
export { SplitView, SplitViewPrimaryDock, SplitViewSurface } from './components/SplitView';
export type { SplitViewProps, SplitViewMode, SplitViewHeaderGeometry } from './components/SplitView';

export { FieldAccessibilityProvider } from "./components/Field/FieldAccessibility";
2 changes: 2 additions & 0 deletions docs/architecture/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,5 @@ store canonical ids.
5. Run focused checks for touched runtime code.
6. If a surface intentionally does not support the locale, document that
decision in the contract-facing PR description.

Duration presentation uses `src/shared/i18n/formatDuration.ts` with each surface's number formatter. The policy carries rounded seconds into minutes and hours, while live timers truncate elapsed seconds. Web tools, model rounds and usage reports use the Web UI formatting wrapper; mobile uses its own i18n provider without importing Web UI catalogs. Protocol durations remain milliseconds.
22 changes: 21 additions & 1 deletion scripts/i18n-dynamic-key-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"resolveSubagentNameKey",
"subagentIdentity.names.bouncyBean"
],
"keyPrefixes": ["subagentIdentity.names."]
"keyPrefixes": [
"subagentIdentity.names."
]
},
{
"id": "installer-install-path-errors",
Expand Down Expand Up @@ -190,6 +192,24 @@
"filters.",
"categories."
]
},
{
"id": "web-global-search-capability-labels",
"surface": "web-ui",
"namespace": "scenes/capabilities",
"owner": "src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.ts",
"description": "Search display labels follow the UI locale. Catalog and item coverage is verified in interactiveCapabilitySearchProvider.test.ts; bilingual catalog text remains search aliases only.",
"sourceReferences": [
"search.capabilities.${capability.id}.title",
"search.capabilities.${capability.id}.items.${item.id}",
"search.categories.${capability.categoryId}",
"search.kinds.${capability.kind}"
],
"keyPrefixes": [
"search.capabilities.",
"search.categories.",
"search.kinds."
]
}
]
}
9 changes: 2 additions & 7 deletions src/mobile-web/src/components/ChatTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ import ChatToolApprovalActions, { isToolAwaitingApproval } from './ChatToolAppro

type ToolApprovalHandler = (toolId: string, updatedInput?: Record<string, unknown>) => Promise<void>;

function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}

function getEnglishPluralSuffix(language: string, count: number): string {
return language === 'en-US' && count !== 1 ? 's' : '';
}
Expand Down Expand Up @@ -250,7 +245,7 @@ export const TaskToolCard: React.FC<{
onApproveTool?: ToolApprovalHandler;
onRejectTool?: ToolApprovalHandler;
}> = ({ tool, now, subItems = [], onCancelTool, onApproveTool, onRejectTool }) => {
const { t, language } = useI18n();
const { t, language, formatDuration } = useI18n();
const scrollRef = useRef<HTMLDivElement>(null);
const [stepsExpanded, setStepsExpanded] = useState(false);
const isRunning = tool.status === 'running';
Expand Down Expand Up @@ -449,7 +444,7 @@ const ToolCard: React.FC<{
onApproveTool?: ToolApprovalHandler;
onRejectTool?: ToolApprovalHandler;
}> = ({ tool, now, onCancelTool, onApproveTool, onRejectTool }) => {
const { t } = useI18n();
const { t, formatDuration } = useI18n();
const toolKey = tool.name.toLowerCase().replace(/[\s-]/g, '_');
const typeLabelKey = TOOL_TYPE_MAP[toolKey] || TOOL_TYPE_MAP[tool.name];
const typeLabel = typeLabelKey ? t(typeLabelKey) : '';
Expand Down
Loading
Loading