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
10 changes: 10 additions & 0 deletions .changeset/26732-studio-input-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): Studio's group-title, Description, Help text, nav "Link to object" and nav "Label" inputs have accessible names

Clause-②: no
Metadata-protocol: none

Five Studio inputs were anonymous to screen readers and automated accessibility checks. The form designer's inline group-title input is now named "Group name", the label the group inspector gives the same value. The field inspector's Description and Help text textareas, and the Interfaces nav-item inspector's "Link to object" picker and "Label" input, are now tied to the labels already drawn above them, so each is announced by its label and clicking the label focuses it. Nothing changes visually and nothing changes in what is saved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26732 — the field inspector's Description and Help text
* textareas are named by their visible labels.
*
* Before: the local `TextareaField` drew its `<Label>` beside the textarea
* without closing the pair, so assistive tech read two anonymous edit fields
* and clicking a label focused nothing. It now closes the pair the way
* `_shared.tsx`'s form atoms do (`htmlFor` ⇄ a per-instance `useId()`).
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';

vi.mock('../useMetadata', () => ({
useMetadataClient: () => ({
withPreviewDrafts: () => ({ list: vi.fn().mockResolvedValue([]) }),
}),
}));

vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { ObjectFieldInspector } from './ObjectFieldInspector';
import { t, type SupportedLocale } from '../i18n';
// `designer.field.description` / `designer.field.helpText` live in
// `@object-ui/i18n`'s `studio` namespace, whose `zh` catalogue is fetched on
// demand. Resident before any case, so the zh names are read after it lands.
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
await loadStudioCatalogue('zh');

afterEach(cleanup);

function renderTextField(locale: SupportedLocale) {
render(
<ObjectFieldInspector
type="object"
name="account"
draft={{
name: 'account',
fields: {
notes: { type: 'text', label: 'Notes', description: 'What the note is for', inlineHelpText: 'Shown under the field' },
},
}}
selection={{ kind: 'field', id: 'notes' }}
onPatch={vi.fn()}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
locale={locale}
/>,
);
}

describe('objectstack#26732 — the field inspector names its Description and Help text textareas', () => {
it('en: each textarea is found by its label and holds its own key', () => {
renderTextField('en-US');
const description = screen.getByRole('textbox', { name: t('designer.field.description', 'en-US') });
const helpText = screen.getByRole('textbox', { name: t('designer.field.helpText', 'en-US') });
expect(description.tagName).toBe('TEXTAREA');
expect(description).toHaveValue('What the note is for');
expect(helpText.tagName).toBe('TEXTAREA');
expect(helpText).toHaveValue('Shown under the field');
expect(screen.getByLabelText(t('designer.field.description', 'en-US'))).toBe(description);
});

it('zh: the same textareas carry the Chinese labels as their names', () => {
renderTextField('zh-CN');
const name = t('designer.field.description', 'zh-CN');
// The zh catalogue is resident, so this is the Chinese label, not the en fallback.
expect(name).toBe('描述');
expect(screen.getByRole('textbox', { name })).toHaveValue('What the note is for');
expect(screen.getByRole('textbox', { name: t('designer.field.helpText', 'zh-CN') })).toHaveValue(
'Shown under the field',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1516,10 +1516,14 @@ function TextareaField({
mono?: boolean;
placeholder?: string;
}) {
// The pair is closed the way `_shared.tsx`'s form atoms close theirs: an id
// minted per instance, so the label names the textarea.
const id = React.useId();
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Label htmlFor={id} className="text-xs text-muted-foreground">{label}</Label>
<textarea
id={id}
value={value}
disabled={disabled}
rows={rows}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26732 — the form designer's inline group-title input has an
* accessible name.
*
* Before: a declared group's header held a bare `<input>` with no label, no
* `aria-label` and no `id`, so a screen reader announced an unnamed edit field.
* There is no visible label beside it, so it names itself with the label the
* group inspector gives the same value (`engine.studio.designer.group.nameLabel`).
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { ObjectFormDesigner } from './ObjectFormDesigner';
import { t } from '../metadata-admin/i18n';

afterEach(cleanup);

const draft = {
name: 'crm_account',
fields: [{ name: 'name', type: 'text', label: 'Account Name', group: 'basic' }],
fieldGroups: [{ key: 'basic', label: 'Basic Information' }],
};

describe('objectstack#26732 — the form designer names its group-title input', () => {
it("a declared group's title input is found by its name and holds the group's label", () => {
render(
<ObjectFormDesigner
draft={draft}
systemFieldNames={new Set()}
onChange={vi.fn()}
onSelectField={() => {}}
onSelectGroup={() => {}}
/>,
);
const input = screen.getByRole('textbox', { name: t('engine.studio.designer.group.nameLabel', 'en-US') });
expect(input).toHaveValue('Basic Information');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,9 @@ function Section({
<div className="flex h-11 items-center gap-2 border-b px-4">
{isDeclared && !readOnly ? (
<input
// No visible label sits beside the inline title: the input names
// itself with the group inspector's own name-field label.
aria-label={t('engine.studio.designer.group.nameLabel', locale)}
defaultValue={title}
onBlur={(e) => e.target.value.trim() && e.target.value !== title && onRename(e.target.value)}
onKeyDown={(e) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26732 — the Interfaces nav-item inspector's *Link to object*
* picker and *Label* input are named by their visible labels.
*
* Before: both labels were drawn above their controls without `htmlFor`, so
* the picker's trigger and the text input were anonymous to assistive tech.
* Each now closes the pair with a `useId()`, as the inspector's target, URL
* and icon fields already did.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { MetadataCtx } from '@object-ui/react';

import { StudioNavItemInspector } from './StudioDesignSurface';
import { t } from '../metadata-admin/i18n';

afterEach(cleanup);

const METADATA = {
apps: [],
objects: [{ name: 'account', label: 'Customers' }],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
};

describe('objectstack#26732 — the nav-item inspector names its object picker and label input', () => {
it('an object entry: the picker and the Label field are found by their labels', () => {
render(
<MetadataCtx.Provider value={METADATA as never}>
<StudioNavItemInspector
navId="navigation[0]"
appDraft={{ navigation: [{ id: 'nav_account', type: 'object', objectName: 'account', label: 'Accounts' }] }}
objects={[{ name: 'account', label: 'Customers' }]}
packageId="com.acme.app"
onNavPatch={vi.fn()}
onClear={vi.fn()}
/>
</MetadataCtx.Provider>,
);
expect(screen.getByRole('combobox', { name: t('engine.studio.nav.linkObject', 'en-US') })).toBe(
screen.getByTestId('nav-link-object'),
);
expect(screen.getByRole('textbox', { name: t('engine.studio.nav.label', 'en-US') })).toHaveValue('Accounts');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
* What is pinned:
* - each picker IS the primitive (a Radix combobox trigger), shows what the
* entry or the dialog holds, and no native select is left;
* - each picker keeps the accessible name the native control had (the
* object picker had none, and has none);
* - each picker keeps the accessible name the native control had, and the
* object picker, which had none, is named by its *Link to object* label
* (objectstack#26732);
* - every option of every picker writes what the native control wrote:
* the inspector's `onNavPatch` value as JSON text plus the keys it holds
* as `undefined` (JSON erases those), the *New object* save and the
Expand Down Expand Up @@ -158,7 +159,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});

/** The object picker's handle: it has no accessible name, as the native control had none. */
/** The object picker's handle, kept from when it had no accessible name (the native control had none). */
const OBJECT_PICKER = 'nav-link-object';
const CHOOSE_OBJECT = en('engine.studio.nav.chooseObject');
const CHOOSE = en('engine.inspector.appNav.choose');
Expand Down Expand Up @@ -337,21 +338,22 @@ describe('the nav-item inspector picks with the shared Select (objectui#11865)',
});

// The names were read on the native controls; red there only because the object picker is found by its test id.
it('each picker keeps the name its label gave the native control; the object picker had none', async () => {
// The object picker had none there; objectstack#26732 names it by its *Link to object* label.
it('each picker keeps the name its label gave the native control; the object picker is named by its label', async () => {
const names: Record<string, string[]> = {};
for (const key of ['objBorn', 'pageBorn', 'dashBound', 'reportBorn', 'actionBorn', 'compBorn', 'docBorn', 'urlDefault']) {
renderEntry(key);
names[key] = screen.getAllByRole('combobox').map((c) => {
// objectui#12455 — the inspector's Icon picker is a combobox named by its label too.
const named = ['Page', 'Dashboard', 'Report', 'Action', 'Component', 'Doc page', 'Book', 'Open in', 'Icon'].find(
const named = ['Link to object', 'Page', 'Dashboard', 'Report', 'Action', 'Component', 'Doc page', 'Book', 'Open in', 'Icon'].find(
(n) => screen.queryByRole('combobox', { name: n }) === c,
);
return named ?? '';
});
cleanup();
}
expect(names).toEqual({
objBorn: ['', 'Icon'],
objBorn: ['Link to object', 'Icon'],
pageBorn: ['Page', 'Icon'],
dashBound: ['Dashboard', 'Icon'],
reportBorn: ['Report', 'Icon'],
Expand All @@ -361,7 +363,7 @@ describe('the nav-item inspector picks with the shared Select (objectui#11865)',
urlDefault: ['Open in', 'Icon'],
});
renderEntry('objBorn');
expect(screen.getByTestId(OBJECT_PICKER)).not.toHaveAccessibleName();
expect(screen.getByTestId(OBJECT_PICKER)).toHaveAccessibleName('Link to object');
});

it('each picker lists its options in the order the native control did', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2614,6 +2614,8 @@ export function StudioNavItemInspector({
const targetLabel = useNavTargetLabel();
const typeGroupName = React.useId();
const iconFieldId = React.useId();
const objectFieldId = React.useId();
const labelFieldId = React.useId();
const idx = React.useMemo(() => {
const m = /^navigation\[(\d+)\]$/.exec(navId);
return m ? Number(m[1]) : -1;
Expand Down Expand Up @@ -2732,8 +2734,11 @@ export function StudioNavItemInspector({
: inheritedNavEntryText(node as NavEntryLike, targetLabel);
const labelField = (
<div>
<label className="mb-1 block text-[11px] font-medium text-muted-foreground">{t('engine.studio.nav.label', locale)}</label>
<label htmlFor={labelFieldId} className="mb-1 block text-[11px] font-medium text-muted-foreground">
{t('engine.studio.nav.label', locale)}
</label>
<input
id={labelFieldId}
value={navItemLabelText(label, locale)}
onChange={(e) => editLabel(e.target.value)}
placeholder={placeholderText}
Expand Down Expand Up @@ -2772,8 +2777,11 @@ export function StudioNavItemInspector({
</fieldset>
{(kind === 'object' || kind === null) && (
<div>
<label className="mb-1 block text-[11px] font-medium text-muted-foreground">{t('engine.studio.nav.linkObject', locale)}</label>
<label htmlFor={objectFieldId} className="mb-1 block text-[11px] font-medium text-muted-foreground">
{t('engine.studio.nav.linkObject', locale)}
</label>
<StudioPicker
id={objectFieldId}
value={boundObject}
options={[
{ value: '', label: t('engine.studio.nav.chooseObject', locale) },
Expand Down Expand Up @@ -3044,7 +3052,8 @@ const PICKER_XS = 'h-auto rounded px-2 py-1 text-xs';
* opens there.
* - Each caller keeps the name its native control had: a `<label htmlFor>`
* names the trigger by `id`, and a wrapping `<label>` names it as it named
* the native control. The object picker had none and still has none.
* the native control. The nav-item inspector's object picker is named by
* its *Link to object* label the same way.
*/
function StudioPicker({
id,
Expand Down
Loading