Skip to content

Commit 33e3583

Browse files
fix(app-shell): the form designer's drag live region names labels and places, not ids (objectui#11802) (#11869)
Fixes #11802 Clause-②: no ## What changed The Studio form designer's `DndContext` passed no `accessibility` prop, so dnd-kit read its default English sentences to a screen reader, naming the canvas ids. A real Chromium reading of the live region before this change, dragging *Name* into *New group*: ```text Picked up draggable item f:name. Draggable item f:name was moved over droppable area f:name. Draggable item f:name was moved over droppable area g:__ungrouped__. Draggable item f:name is no longer over a droppable area. Draggable item f:name was moved over droppable area g:new_group. Draggable item f:name was moved over droppable area f:name. Draggable item f:name was dropped over droppable area f:name ``` The `DndContext` now takes `accessibility={dndAccessibility}`: - **Every sentence is localized, en and zh,** under `engine.studio.formDnd.*` in the designer's own table: pick-up (`onDragStart`), over (`onDragOver`, with a not-over-a-group form), drop (`onDragEnd`, with a dropped-outside form), cancel (`onDragCancel`), and the screen-reader instructions dnd-kit points every card's `aria-describedby` at. - **Each names labels, never an id.** The field label comes from `fieldLabelOf` (the resolver the cards render), the group label from `labelOf` (the resolver the section headers render). No second map is built. - **The place is "N of M" inside the target group.** It is read off the container map `onDragEnd` reads, with `onDragEnd`'s own arithmetic (`dropPlaceIn`, beside the id helpers), so the place a drop announces is the place it commits. A drop outside every group, or a cancel, names the place the field is back in (the draft's layout, which the handlers restore). - **A sentence about the place the region already named is skipped.** dnd-kit replaces the region's text on every event, and a pointer drag's first `onDragOver` is the field over its own card; before this change that overwrote the pick-up sentence before the browser's next observer callback. The wording lives in a new module-private helper, `formDndAnnouncements.ts` (`formDndAccessibility(locale, lookups)`), because `ObjectFormDesigner.tsx` exports components only. **Unchanged:** the drag handlers (`onDragStart` / `onDragOver` / `onDragEnd` / `onDragCancel`), the layout model, `isKeptOffLayout`, the sensors and the collision detection. No export, prop, `@object-ui/types` member, accepted input, `package.json` or `packages/i18n` key is added. The designer hint and the Form tab caption are not touched. ## The same drag after this change (real Chromium, `8f5fb15`) ```text Picked up Name. It is in Ungrouped, position 1 of 2. Name is over Ungrouped, position 2 of 2. Name is not over a group. Name is over New group, position 1 of 1. Name moved to New group, position 1 of 1. ``` zh, with the object translations the canvas shows: ```text 已拿起 名称,当前在 未分组,第 1 个,共 2 个。 名称 正移到 未分组,第 2 个,共 2 个。 名称 不在任何分组上。 名称 正移到 新分组,第 1 个,共 1 个。 名称 已移到 新分组,第 1 个,共 1 个。 ``` The committed draft put `name` in `new_group` both times. A second drag (*Industry* carried across *New group* onto *Phone* in *Contact*) ended "Industry moved to Contact, position 1 of 2." and the draft had `industry` first in `contact`. The readings came from a dev-only harness that mounts `ObjectFormDesigner` with an `I18nProvider` in the console's Vite dev server; a MutationObserver with `characterDataOldValue` recorded every value the region took, including ones replaced before the observer ran. The harness was never committed. ## The PM's hypotheses, measured - **H1, confirmed.** One `DndContext` in the designer; each section's `SortableContext` sits inside it, and no other dnd context exists in `packages/app-shell/src`. Fields are sortables `f:` + field name (draggable and droppable); sections are droppables `g:` + group key, the ungrouped bucket included (`g:__ungrouped__`). Sections are not draggable; they move by buttons. - **H2, confirmed.** `entryByName` + `fieldLabelOf` and `labelOf` are the lookups the cards and section headers render from. The announcements reuse them. - **H3, corrected.** dnd-kit's `LiveRegion` is `role="status"`, `aria-live="assertive"`, `aria-atomic="true"`, and it is not debounced: `announce` sets the text at once, and each sentence replaces the one before. Measured on the drag above: before, 7 texts, two of them replaced before the observer's next callback ran (the pick-up sentence among them); after, 5 texts, each at least about 100 ms after the previous one. A longer drag across two groups still gives one sentence per change of what the pointer is over, a few of them about 20 ms apart while the cards shift under the pointer. Each such sentence names a place, so it is kept: an over sentence with the place is what a keyboard user needs on each arrow key, and only the repeat of a place already spoken is skipped. ## Tests New: `ObjectFormDesigner.dndAnnouncements-11802.test.tsx`, 8 cases, reading the real live region and the real instructions. The real `DndContext`, sensors and handlers run; only `collisionDetection` is replaced by a test geometry where the pointer's x picks the droppable, because the test DOM measures every box as zero. - Name into New group: pick-up, over and drop, each with labels and place; "Name moved to New group, position 1 of 1." No sentence matches `\b[fg]:\w`. - The same in zh: "名称 已移到 新分组,第 1 个,共 1 个。" - The localized instructions every card's `aria-describedby` points at, en and zh. - A reorder inside one group; a drop on a field of another group after the canvas moved the field; a drop still over the field it was carried onto (the handler puts it after that field). - Leaving every group then dropping: "Name is not over a group." and the dropped-outside sentence; nothing committed. - Keyboard pick-up and Escape cancel. **Control:** every drop case compares the announced place with the place the committed `fields` give the field. The existing move test, `DataPillar.hiddenSystemFields-11780.test.tsx` (its write-back drop), runs unchanged and green. **Reverse check** (fix committed first; `ObjectFormDesigner.tsx` checked out from the base `5aa7f55`; `accessibility={dndAccessibility}` count 1, then 0 on disk; restored with `git checkout HEAD --`; restored blob equal to the HEAD blob, `git diff HEAD` and `git diff --cached` empty): 8 of 8 failed in the predicted direction, the live region carrying the ids. The first: expected "Picked up Name. It is in Ungrouped, position 1 of 2.", received "Picked up draggable item f:name.". ## Gates (run on `8f5fb15`) | Command | Exit | Verdict | |---|---|---| | `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` | 0 | 28 buildable packages Done (29 in scope; `@object-ui/test-support` has no build script) | | `pnpm --filter @object-ui/app-shell type-check` | 0 | both `tsc` passes; `tsconfig.test.json --listFiles` includes the new test | | `pnpm exec vitest run` over the 8 files below | 0 | 8 files, 247 tests passed | | `pnpm exec eslint --no-inline-config` on the 4 touched source files | 0 | 0 errors; 1 warning, `react-hooks/set-state-in-effect`, which the base file already carries | | `pnpm check:control-bytes` · `check:new-line-citations` · `check:changeset-claims` · `check:pending-changeset-literals` · `check:i18n-designer-parity` | 0 each | OK; 0 new citations; every en row has a zh row with the same placeholders | | `pnpm check:vi-mock-specifiers` · `check:vi-mock-inherit` · `check:vi-mock-override-shape` · `check:test-path-roots` | 0 each | OK | | `pnpm check:i18n-keys` · `check:i18n-drift` · `check:esm-specifiers` · `check:self-import` · `check:phantom-deps` · `check:unreferenced-sources` | 0 each | OK | | `node scripts/check-changeset-presence.mjs` · `pnpm changeset:check` · `node scripts/check-changeset-overwrite.mjs` | 0 each | 1 changeset declared; no major; none overwritten | | `pnpm check:handler-key-reads` · `check:metadata-write-doors` · `scripts/check-type-check-coverage.mjs` · `scripts/check-lint-coverage.mjs` | 0 each | OK | | `node scripts/check-governed-queue-guard.mjs --test` (5 paths) | 0 | NOT GOVERNED | Test files: `ObjectFormDesigner.test.tsx`, `ObjectFormDesigner.dndAnnouncements-11802.test.tsx`, `DataPillar.hiddenSystemFields-11780.test.tsx`, `StudioDesignSurface.formFields.test.tsx`, `select-placeholder-literal-11252.test.ts`, `block-config-i18n.test.ts`, `scripts/__tests__/check-i18n-en-drift.test.ts`, `scripts/__tests__/check-i18n-dead-keys.test.ts`. `pnpm check:i18n-dead-keys` (report-only) lists no `engine.studio.formDnd.*` key. The lint run is narrowed, not repo-wide: the population is app-shell's own `eslint .` under the root `eslint.config.js`; `--format json` counted 4 files; the config sets no `parserOptions` / `projectService` (type-aware linting off), so this diff cannot move a verdict on an untouched file. Repo-wide `pnpm lint` and the full test farm are CI's. Changeset: `.changeset/11802-form-dnd-announcements.md`, `@object-ui/app-shell` patch. ## Acceptance notes Found while measuring, outside this card's fence, not fixed here: 1. **A keyboard drag never reaches a drop target.** `collisionDetection={pointerWithin}` returns nothing without pointer coordinates, and a keyboard drag has none (its activator is a keyboard event), so `over` stays null. In the browser, *Industry*: Space, ArrowDown three times, Space left the draft unchanged; before this change the region said "Picked up draggable item f:industry." then "Draggable item f:industry was dropped.". After it, the region says the field is back in its place, which is true, but the instructions (dnd-kit's default, now localized) still describe moving with the arrow keys. The fix is a collision detection that also answers for the keyboard. That changes drag behaviour, which this card rules out. 2. **Every field card announces `aria-roledescription="sortable"` in English,** in zh too: the `useSortable` default in `SortableField`, outside the `DndContext` props this card may touch. The PM's report carries both, with their reach. --- _Generated by [Claude Code](https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bee5d6f commit 33e3583

5 files changed

Lines changed: 449 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio's form designer tells a screen reader which field it is dragging and where, by name and in the author's language (objectui#11802).
6+
7+
A screen reader heard the canvas's internal ids during a drag, in English whatever the console language: "Draggable item f:name was dropped over droppable area g:new_group". The designer now gives dnd-kit its own sentences for picking a field up, carrying it over a group, dropping it and cancelling, plus the keyboard instructions read when a field card is focused, in en and zh. Each sentence names the field and the group by the labels the canvas shows, through the same object translations, and says the place as "N of M" inside the group: "Name moved to New group, position 1 of 1." The place a drop announces is the place it commits. A drop outside every group, or a cancelled drag, says the field is back where it was, and names that place.
8+
9+
Dragging works as before: which field moves, where it lands and what is saved are unchanged. Nothing is added to the package entry: no export, prop, type member or language-pack key. The new copy lives in the metadata-admin designer's own string tables.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2940,6 +2940,16 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
29402940
'engine.studio.designer.hint': 'Drag to reorder / move across groups · click a field to edit its properties',
29412941
'engine.studio.designer.hintReadOnly': 'Read-only package · click a field to view its properties',
29422942
'engine.studio.designer.addGroup': 'Add group',
2943+
// What the form designer's drag live region says (objectui#11802): labels,
2944+
// never the canvas's internal ids, and the place as "N of M" in the group.
2945+
'engine.studio.formDnd.instructions':
2946+
'To pick up a field, press Space or Enter. While dragging, use the arrow keys to move it. Press Space or Enter again to drop it in its new place, or press Escape to cancel.',
2947+
'engine.studio.formDnd.start': 'Picked up {field}. It is in {group}, position {position} of {total}.',
2948+
'engine.studio.formDnd.over': '{field} is over {group}, position {position} of {total}.',
2949+
'engine.studio.formDnd.overNone': '{field} is not over a group.',
2950+
'engine.studio.formDnd.end': '{field} moved to {group}, position {position} of {total}.',
2951+
'engine.studio.formDnd.endNone': '{field} was dropped outside the groups and is back in {group}, position {position} of {total}.',
2952+
'engine.studio.formDnd.cancel': 'Dragging cancelled. {field} is back in {group}, position {position} of {total}.',
29432953
'engine.studio.rules.title': 'Validation rules',
29442954
'engine.studio.rules.none': 'No validation rules yet.',
29452955
'engine.studio.rules.explain': 'Rules run when a record is saved: when the condition is true, the save is rejected with the message.',
@@ -5962,6 +5972,14 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
59625972
'engine.studio.designer.hint': '拖动字段排序 / 拖到其它分组 · 点选字段改属性',
59635973
'engine.studio.designer.hintReadOnly': '只读软件包 · 点选字段查看属性',
59645974
'engine.studio.designer.addGroup': '添加分组',
5975+
'engine.studio.formDnd.instructions':
5976+
'按空格键或回车键拿起字段。拖动时用方向键移动,再按空格键或回车键放到新位置,按 Esc 键取消。',
5977+
'engine.studio.formDnd.start': '已拿起 {field},当前在 {group},第 {position} 个,共 {total} 个。',
5978+
'engine.studio.formDnd.over': '{field} 正移到 {group},第 {position} 个,共 {total} 个。',
5979+
'engine.studio.formDnd.overNone': '{field} 不在任何分组上。',
5980+
'engine.studio.formDnd.end': '{field} 已移到 {group},第 {position} 个,共 {total} 个。',
5981+
'engine.studio.formDnd.endNone': '{field} 没有放到分组里,已回到 {group},第 {position} 个,共 {total} 个。',
5982+
'engine.studio.formDnd.cancel': '已取消拖动。{field} 已回到 {group},第 {position} 个,共 {total} 个。',
59655983
'engine.studio.rules.title': '验证规则',
59665984
'engine.studio.rules.none': '还没有验证规则。',
59675985
'engine.studio.rules.explain': '规则在保存记录时执行:条件为真 ⇒ 拒绝保存并提示消息。',
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The form designer's drag live region speaks the author's labels, never the
5+
* canvas's ids — objectui#11802.
6+
*
7+
* The defect: the designer's `DndContext` passed no `accessibility`, so
8+
* dnd-kit's default sentences read its ids to a screen reader, in English
9+
* whatever the locale ("Draggable item f:name was dropped over droppable area
10+
* g:new_group"). Every case below reads the REAL live region and the REAL
11+
* screen-reader instructions dnd-kit renders.
12+
*
13+
* The instrument: the real `DndContext`, the real sensors and the real
14+
* handlers run. Only `collisionDetection` is replaced, by a test geometry in
15+
* which the pointer's x picks the droppable (one band per 100px), because the
16+
* test DOM measures every box as zero. Band 0 is where the pointer goes down.
17+
* The browser reading that the bands copy is on the pull request: a pointer
18+
* that carries a field into another group is over that group first, then over
19+
* the moved field's own card once the canvas has moved it.
20+
*
21+
* The control rides every drop: the place the region announces is compared
22+
* with the place the committed `fields` give the field, so a sentence cannot
23+
* stay green while the drop lands somewhere else.
24+
*/
25+
26+
import '@testing-library/jest-dom/vitest';
27+
import * as React from 'react';
28+
import { afterEach, describe, expect, it, vi } from 'vitest';
29+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
30+
import { I18nProvider, createI18n } from '@object-ui/i18n';
31+
32+
const geometry = vi.hoisted(() => ({ bands: [] as Array<string | null> }));
33+
34+
vi.mock('@dnd-kit/core', async (importOriginal) => {
35+
const actual = await importOriginal<typeof import('@dnd-kit/core')>();
36+
const ReactMod = await import('react');
37+
const byBand: import('@dnd-kit/core').CollisionDetection = ({ pointerCoordinates }) => {
38+
const id = pointerCoordinates ? (geometry.bands[Math.floor(pointerCoordinates.x / 100)] ?? null) : null;
39+
return id ? [{ id }] : [];
40+
};
41+
const GeometryDndContext = (props: Record<string, unknown>) =>
42+
ReactMod.createElement(actual.DndContext, { ...props, collisionDetection: byBand } as never);
43+
return { ...actual, DndContext: GeometryDndContext };
44+
});
45+
46+
import { ObjectFormDesigner } from './ObjectFormDesigner';
47+
48+
type Field = { name: string; type: string; label: string; group?: string };
49+
50+
const DRAFT = {
51+
name: 'account',
52+
fields: [
53+
{ name: 'name', type: 'text', label: 'Name' },
54+
{ name: 'industry', type: 'text', label: 'Industry' },
55+
{ name: 'phone', type: 'text', label: 'Phone', group: 'contact' },
56+
{ name: 'email', type: 'text', label: 'Email', group: 'contact' },
57+
] as Field[],
58+
fieldGroups: [
59+
{ key: 'contact', label: 'Contact' },
60+
{ key: 'new_group', label: 'New group' },
61+
],
62+
};
63+
64+
/** The zh object translations the cards and section headers resolve through. */
65+
const zh = () =>
66+
createI18n({
67+
defaultLanguage: 'zh',
68+
detectBrowserLanguage: false,
69+
resources: {
70+
zh: {
71+
app: {
72+
objects: { account: { _sections: { contact: { label: '联系方式' }, new_group: { label: '新分组' } } } },
73+
fields: { account: { name: '名称', industry: '行业', phone: '电话', email: '邮箱' } },
74+
},
75+
},
76+
},
77+
});
78+
79+
/** Every `fields` the designer committed, in order. */
80+
let commits: Field[][] = [];
81+
82+
function Host(): React.ReactElement {
83+
const [draft, setDraft] = React.useState<Record<string, unknown>>(DRAFT);
84+
return (
85+
<ObjectFormDesigner
86+
draft={draft}
87+
systemFieldNames={new Set()}
88+
onChange={(patch) => {
89+
if (patch.fields) commits.push(patch.fields as Field[]);
90+
setDraft((d) => ({ ...d, ...patch }));
91+
}}
92+
onSelectField={() => {}}
93+
/>
94+
);
95+
}
96+
97+
function renderDesigner(lang: 'en' | 'zh' = 'en') {
98+
commits = [];
99+
return render(lang === 'zh' ? <I18nProvider instance={zh()}><Host /></I18nProvider> : <Host />);
100+
}
101+
102+
afterEach(() => {
103+
cleanup();
104+
geometry.bands = [];
105+
});
106+
107+
/** What the live region says now. */
108+
const said = () => document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? null;
109+
const card = (label: string) => screen.getByText(label, { exact: true }).closest('.cursor-grab') as HTMLElement;
110+
const tick = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });
111+
112+
/**
113+
* Pick the card up with the pointer, then move it through `bands` (band 1, 2,
114+
* …), recording what the region says after the pick-up and after each move.
115+
*/
116+
function pointerDrag(cardEl: HTMLElement, bands: Array<string | null>): string[] {
117+
geometry.bands = [null, ...bands];
118+
const heard: string[] = [];
119+
fireEvent.pointerDown(cardEl, { isPrimary: true, button: 0, clientX: 50, clientY: 10 });
120+
// Past the designer's 4px activation distance: the drag starts at band 0.
121+
fireEvent.pointerMove(document, { isPrimary: true, clientX: 60, clientY: 10 });
122+
heard.push(said() ?? '');
123+
bands.forEach((_, i) => {
124+
fireEvent.pointerMove(document, { isPrimary: true, clientX: (i + 1) * 100 + 50, clientY: 10 });
125+
heard.push(said() ?? '');
126+
});
127+
return heard;
128+
}
129+
const pointerDrop = () => fireEvent.pointerUp(document, { isPrimary: true, button: 0 });
130+
131+
/** Where the last commit put a field: its group key and its 1-based place in that group. */
132+
function landed(name: string): { group: string; position: number; total: number } {
133+
const fields = commits.at(-1)!;
134+
const group = fields.find((f) => f.name === name)?.group ?? '';
135+
const members = fields.filter((f) => (f.group ?? '') === group).map((f) => f.name);
136+
return { group, position: members.indexOf(name) + 1, total: members.length };
137+
}
138+
139+
const NO_ID = /\b[fg]:\w/;
140+
141+
describe('the form designer announces labels, not ids (objectui#11802)', () => {
142+
it('dragging Name into New group: picked up, over and dropped, each by label and place', () => {
143+
renderDesigner();
144+
const heard = pointerDrag(card('Name'), ['g:new_group', 'f:name']);
145+
expect(heard[0]).toBe('Picked up Name. It is in Ungrouped, position 1 of 2.');
146+
expect(heard[1]).toBe('Name is over New group, position 1 of 1.');
147+
// The field over its own moved card names no new place, so the region keeps
148+
// the sentence about the group rather than repeating it.
149+
expect(heard[2]).toBe('Name is over New group, position 1 of 1.');
150+
151+
pointerDrop();
152+
expect(said()).toBe('Name moved to New group, position 1 of 1.');
153+
for (const sentence of [...heard, said()]) expect(sentence).not.toMatch(NO_ID);
154+
155+
// Control: the drop landed where the region says it did.
156+
expect(landed('name')).toEqual({ group: 'new_group', position: 1, total: 1 });
157+
});
158+
159+
it('says it in zh, with the zh labels the cards show', () => {
160+
renderDesigner('zh');
161+
const heard = pointerDrag(card('名称'), ['g:new_group', 'f:name']);
162+
expect(heard[0]).toBe('已拿起 名称,当前在 未分组,第 1 个,共 2 个。');
163+
expect(heard[1]).toBe('名称 正移到 新分组,第 1 个,共 1 个。');
164+
165+
pointerDrop();
166+
expect(said()).toBe('名称 已移到 新分组,第 1 个,共 1 个。');
167+
for (const sentence of [...heard, said()]) expect(sentence).not.toMatch(NO_ID);
168+
expect(landed('name')).toEqual({ group: 'new_group', position: 1, total: 1 });
169+
});
170+
171+
it('the screen-reader instructions every card points at are the localized ones', () => {
172+
renderDesigner();
173+
const describedBy = card('Name').getAttribute('aria-describedby');
174+
expect(describedBy).toBeTruthy();
175+
expect(document.getElementById(describedBy!)?.textContent).toBe(
176+
'To pick up a field, press Space or Enter. While dragging, use the arrow keys to move it. Press Space or Enter again to drop it in its new place, or press Escape to cancel.',
177+
);
178+
cleanup();
179+
180+
renderDesigner('zh');
181+
const zhDescribedBy = card('名称').getAttribute('aria-describedby');
182+
expect(document.getElementById(zhDescribedBy!)?.textContent).toBe(
183+
'按空格键或回车键拿起字段。拖动时用方向键移动,再按空格键或回车键放到新位置,按 Esc 键取消。',
184+
);
185+
});
186+
187+
it('a reorder inside one group announces the place the drop commits', () => {
188+
renderDesigner();
189+
const heard = pointerDrag(card('Phone'), ['f:email']);
190+
expect(heard[0]).toBe('Picked up Phone. It is in Contact, position 1 of 2.');
191+
expect(heard[1]).toBe('Phone is over Contact, position 2 of 2.');
192+
193+
pointerDrop();
194+
expect(said()).toBe('Phone moved to Contact, position 2 of 2.');
195+
expect(landed('phone')).toEqual({ group: 'contact', position: 2, total: 2 });
196+
});
197+
198+
it('a drop on a field of another group announces the place the drop commits, after the canvas moved the field', () => {
199+
renderDesigner();
200+
const heard = pointerDrag(card('Name'), ['f:email', 'f:name']);
201+
expect(heard[1]).toBe('Name is over Contact, position 2 of 3.');
202+
203+
pointerDrop();
204+
expect(said()).toBe('Name moved to Contact, position 2 of 3.');
205+
expect(landed('name')).toEqual({ group: 'contact', position: 2, total: 3 });
206+
});
207+
208+
it('a drop that is still over the field it was carried onto announces where the handler puts it', () => {
209+
// No band for the moved card: the drop arrives while the pointer is still
210+
// over Email, and the drop handler places Name after it.
211+
renderDesigner();
212+
pointerDrag(card('Name'), ['f:email']);
213+
214+
pointerDrop();
215+
const place = landed('name');
216+
expect(place).toEqual({ group: 'contact', position: 3, total: 3 });
217+
expect(said()).toBe(`Name moved to Contact, position ${place.position} of ${place.total}.`);
218+
});
219+
220+
it('leaving every group, then dropping, says so and names where the field stays', () => {
221+
renderDesigner();
222+
const heard = pointerDrag(card('Name'), ['g:new_group', null]);
223+
expect(heard[2]).toBe('Name is not over a group.');
224+
225+
pointerDrop();
226+
expect(said()).toBe('Name was dropped outside the groups and is back in Ungrouped, position 1 of 2.');
227+
// Control: nothing was committed, and the card is back in its section.
228+
expect(commits).toHaveLength(0);
229+
});
230+
231+
it('a keyboard pick-up and cancel each announce by label', async () => {
232+
renderDesigner();
233+
const industry = card('Industry');
234+
industry.focus();
235+
fireEvent.keyDown(industry, { code: 'Space', key: ' ' });
236+
expect(said()).toBe('Picked up Industry. It is in Ungrouped, position 2 of 2.');
237+
238+
// dnd-kit's keyboard sensor listens for the next key one tick later.
239+
await tick();
240+
fireEvent.keyDown(document, { code: 'Escape', key: 'Escape' });
241+
expect(said()).toBe('Dragging cancelled. Industry is back in Ungrouped, position 2 of 2.');
242+
expect(said()).not.toMatch(NO_ID);
243+
expect(commits).toHaveLength(0);
244+
});
245+
});

‎packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
import { useSafeFieldLabel } from '@object-ui/i18n';
5757
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js';
5858
import { isStudioHiddenSystemField } from './studioHiddenSystemField.js';
59+
import { formDndAccessibility, type FormDndLookups, type FormDndSlot } from './formDndAnnouncements.js';
5960

6061
const UNGROUPED = '__ungrouped__';
6162

@@ -75,6 +76,46 @@ const fid = (name: string) => `f:${name}`; // sortable field id
7576
const unCid = (id: string) => id.slice(2);
7677
const unFid = (id: string) => id.slice(2);
7778

79+
/** A field's place in a container map: the container id, a 0-based index and the container's size. */
80+
interface LayoutPlace {
81+
container: string;
82+
index: number;
83+
total: number;
84+
}
85+
86+
/** Where a field sits in a container map, or `null` when no container holds it. */
87+
function placeIn(layout: Record<string, string[]>, id: string): LayoutPlace | null {
88+
const container = Object.keys(layout).find((k) => layout[k].includes(id));
89+
return container ? { container, index: layout[container].indexOf(id), total: layout[container].length } : null;
90+
}
91+
92+
/**
93+
* Where a drop of `activeId` on `overId` lands, read off the same container
94+
* map `onDragEnd` reads and with the same arithmetic, so the place the live
95+
* region announces is the place the drop commits (objectui#11802). `null`
96+
* where `onDragEnd` returns without moving anything. The handler is the rule;
97+
* this restates it for the announcements, and the pins in
98+
* `ObjectFormDesigner.dndAnnouncements-11802.test.tsx` compare the two on
99+
* every drop they make.
100+
*/
101+
function dropPlaceIn(layout: Record<string, string[]>, activeId: string, overId: string): LayoutPlace | null {
102+
const inContainer = (id: string): string | undefined =>
103+
id.startsWith('g:') && id in layout ? id : Object.keys(layout).find((k) => layout[k].includes(id));
104+
const from = inContainer(activeId);
105+
const to = inContainer(overId);
106+
if (!from || !to) return null;
107+
if (from === to) {
108+
const list = layout[from];
109+
const oldIndex = list.indexOf(activeId);
110+
const newIndex = overId.startsWith('g:') ? list.length - 1 : list.indexOf(overId);
111+
// `onDragEnd` keeps the field where it is when either index is missing.
112+
return { container: from, index: newIndex < 0 ? oldIndex : newIndex, total: list.length };
113+
}
114+
const toItems = layout[to];
115+
const overIndex = overId.startsWith('g:') ? toItems.length : toItems.indexOf(overId);
116+
return { container: to, index: overIndex < 0 ? toItems.length : overIndex, total: toItems.length + 1 };
117+
}
118+
78119
export interface ObjectFormDesignerProps {
79120
/** Object metadata draft (reads `fields` + `fieldGroups`). */
80121
draft: Record<string, unknown>;
@@ -453,6 +494,33 @@ export function ObjectFormDesigner({
453494
itemsRef.current = items;
454495
}, [items]);
455496

497+
// What the drag live region speaks (objectui#11802): the labels the cards and
498+
// section headers render, never the `f:` / `g:` ids, and places read off the
499+
// container map `onDragEnd` reads (`itemsRef` holds this render's `items`).
500+
// dnd-kit subscribes the newest object each time a render commits, so a
501+
// sentence always reads the committed layout and labels. A rebuild, here or
502+
// on React's own account, changes no sentence.
503+
const dndAccessibility = React.useMemo(() => {
504+
const slot = (place: LayoutPlace | null): FormDndSlot | null =>
505+
place && {
506+
container: place.container,
507+
group: labelOf.get(place.container) ?? t('engine.studio.designer.ungrouped', locale),
508+
position: place.index + 1,
509+
total: place.total,
510+
};
511+
const lookups: FormDndLookups = {
512+
fieldLabel: (id) => {
513+
const entry = entryByName.get(unFid(id));
514+
// A drag starts only on a rendered card, which has an entry.
515+
return entry ? fieldLabelOf(entry) : unFid(id);
516+
},
517+
slotOf: (id) => slot(placeIn(items, id)),
518+
dropSlot: (id, overId) => slot(dropPlaceIn(items, id, overId)),
519+
committedSlotOf: (id) => slot(placeIn(derived, id)),
520+
};
521+
return formDndAccessibility(locale, lookups);
522+
}, [locale, items, derived, labelOf, entryByName, fieldLabelOf]);
523+
456524
const sensors = useSensors(
457525
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
458526
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -590,6 +658,7 @@ export function ObjectFormDesigner({
590658
<DndContext
591659
sensors={readOnly ? [] : sensors}
592660
collisionDetection={pointerWithin}
661+
accessibility={dndAccessibility}
593662
onDragStart={onDragStart}
594663
onDragOver={onDragOver}
595664
onDragEnd={onDragEnd}

0 commit comments

Comments
 (0)