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 .changeset/11802-form-dnd-announcements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': patch
---

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

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.

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.
18 changes: 18 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2917,6 +2917,16 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.designer.newGroup': 'New group',
'engine.studio.designer.hint': 'Drag to reorder / move across groups · click a field to edit its properties',
'engine.studio.designer.addGroup': 'Add group',
// What the form designer's drag live region says (objectui#11802): labels,
// never the canvas's internal ids, and the place as "N of M" in the group.
'engine.studio.formDnd.instructions':
'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.',
'engine.studio.formDnd.start': 'Picked up {field}. It is in {group}, position {position} of {total}.',
'engine.studio.formDnd.over': '{field} is over {group}, position {position} of {total}.',
'engine.studio.formDnd.overNone': '{field} is not over a group.',
'engine.studio.formDnd.end': '{field} moved to {group}, position {position} of {total}.',
'engine.studio.formDnd.endNone': '{field} was dropped outside the groups and is back in {group}, position {position} of {total}.',
'engine.studio.formDnd.cancel': 'Dragging cancelled. {field} is back in {group}, position {position} of {total}.',
'engine.studio.rules.title': 'Validation rules',
'engine.studio.rules.none': 'No validation rules yet.',
'engine.studio.rules.explain': 'Rules run when a record is saved: when the condition is true, the save is rejected with the message.',
Expand Down Expand Up @@ -5896,6 +5906,14 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.designer.newGroup': '新分组',
'engine.studio.designer.hint': '拖动字段排序 / 拖到其它分组 · 点选字段改属性',
'engine.studio.designer.addGroup': '添加分组',
'engine.studio.formDnd.instructions':
'按空格键或回车键拿起字段。拖动时用方向键移动,再按空格键或回车键放到新位置,按 Esc 键取消。',
'engine.studio.formDnd.start': '已拿起 {field},当前在 {group},第 {position} 个,共 {total} 个。',
'engine.studio.formDnd.over': '{field} 正移到 {group},第 {position} 个,共 {total} 个。',
'engine.studio.formDnd.overNone': '{field} 不在任何分组上。',
'engine.studio.formDnd.end': '{field} 已移到 {group},第 {position} 个,共 {total} 个。',
'engine.studio.formDnd.endNone': '{field} 没有放到分组里,已回到 {group},第 {position} 个,共 {total} 个。',
'engine.studio.formDnd.cancel': '已取消拖动。{field} 已回到 {group},第 {position} 个,共 {total} 个。',
'engine.studio.rules.title': '验证规则',
'engine.studio.rules.none': '还没有验证规则。',
'engine.studio.rules.explain': '规则在保存记录时执行:条件为真 ⇒ 拒绝保存并提示消息。',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The form designer's drag live region speaks the author's labels, never the
* canvas's ids — objectui#11802.
*
* The defect: the designer's `DndContext` passed no `accessibility`, so
* dnd-kit's default sentences read its ids to a screen reader, in English
* whatever the locale ("Draggable item f:name was dropped over droppable area
* g:new_group"). Every case below reads the REAL live region and the REAL
* screen-reader instructions dnd-kit renders.
*
* The instrument: the real `DndContext`, the real sensors and the real
* handlers run. Only `collisionDetection` is replaced, by a test geometry in
* which the pointer's x picks the droppable (one band per 100px), because the
* test DOM measures every box as zero. Band 0 is where the pointer goes down.
* The browser reading that the bands copy is on the pull request: a pointer
* that carries a field into another group is over that group first, then over
* the moved field's own card once the canvas has moved it.
*
* The control rides every drop: the place the region announces is compared
* with the place the committed `fields` give the field, so a sentence cannot
* stay green while the drop lands somewhere else.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { I18nProvider, createI18n } from '@object-ui/i18n';

const geometry = vi.hoisted(() => ({ bands: [] as Array<string | null> }));

vi.mock('@dnd-kit/core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@dnd-kit/core')>();
const ReactMod = await import('react');
const byBand: import('@dnd-kit/core').CollisionDetection = ({ pointerCoordinates }) => {
const id = pointerCoordinates ? (geometry.bands[Math.floor(pointerCoordinates.x / 100)] ?? null) : null;
return id ? [{ id }] : [];
};
const GeometryDndContext = (props: Record<string, unknown>) =>
ReactMod.createElement(actual.DndContext, { ...props, collisionDetection: byBand } as never);
return { ...actual, DndContext: GeometryDndContext };
});

import { ObjectFormDesigner } from './ObjectFormDesigner';

type Field = { name: string; type: string; label: string; group?: string };

const DRAFT = {
name: 'account',
fields: [
{ name: 'name', type: 'text', label: 'Name' },
{ name: 'industry', type: 'text', label: 'Industry' },
{ name: 'phone', type: 'text', label: 'Phone', group: 'contact' },
{ name: 'email', type: 'text', label: 'Email', group: 'contact' },
] as Field[],
fieldGroups: [
{ key: 'contact', label: 'Contact' },
{ key: 'new_group', label: 'New group' },
],
};

/** The zh object translations the cards and section headers resolve through. */
const zh = () =>
createI18n({
defaultLanguage: 'zh',
detectBrowserLanguage: false,
resources: {
zh: {
app: {
objects: { account: { _sections: { contact: { label: '联系方式' }, new_group: { label: '新分组' } } } },
fields: { account: { name: '名称', industry: '行业', phone: '电话', email: '邮箱' } },
},
},
},
});

/** Every `fields` the designer committed, in order. */
let commits: Field[][] = [];

function Host(): React.ReactElement {
const [draft, setDraft] = React.useState<Record<string, unknown>>(DRAFT);
return (
<ObjectFormDesigner
draft={draft}
systemFieldNames={new Set()}
onChange={(patch) => {
if (patch.fields) commits.push(patch.fields as Field[]);
setDraft((d) => ({ ...d, ...patch }));
}}
onSelectField={() => {}}
/>
);
}

function renderDesigner(lang: 'en' | 'zh' = 'en') {
commits = [];
return render(lang === 'zh' ? <I18nProvider instance={zh()}><Host /></I18nProvider> : <Host />);
}

afterEach(() => {
cleanup();
geometry.bands = [];
});

/** What the live region says now. */
const said = () => document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? null;
const card = (label: string) => screen.getByText(label, { exact: true }).closest('.cursor-grab') as HTMLElement;
const tick = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });

/**
* Pick the card up with the pointer, then move it through `bands` (band 1, 2,
* …), recording what the region says after the pick-up and after each move.
*/
function pointerDrag(cardEl: HTMLElement, bands: Array<string | null>): string[] {
geometry.bands = [null, ...bands];
const heard: string[] = [];
fireEvent.pointerDown(cardEl, { isPrimary: true, button: 0, clientX: 50, clientY: 10 });
// Past the designer's 4px activation distance: the drag starts at band 0.
fireEvent.pointerMove(document, { isPrimary: true, clientX: 60, clientY: 10 });
heard.push(said() ?? '');
bands.forEach((_, i) => {
fireEvent.pointerMove(document, { isPrimary: true, clientX: (i + 1) * 100 + 50, clientY: 10 });
heard.push(said() ?? '');
});
return heard;
}
const pointerDrop = () => fireEvent.pointerUp(document, { isPrimary: true, button: 0 });

/** Where the last commit put a field: its group key and its 1-based place in that group. */
function landed(name: string): { group: string; position: number; total: number } {
const fields = commits.at(-1)!;
const group = fields.find((f) => f.name === name)?.group ?? '';
const members = fields.filter((f) => (f.group ?? '') === group).map((f) => f.name);
return { group, position: members.indexOf(name) + 1, total: members.length };
}

const NO_ID = /\b[fg]:\w/;

describe('the form designer announces labels, not ids (objectui#11802)', () => {
it('dragging Name into New group: picked up, over and dropped, each by label and place', () => {
renderDesigner();
const heard = pointerDrag(card('Name'), ['g:new_group', 'f:name']);
expect(heard[0]).toBe('Picked up Name. It is in Ungrouped, position 1 of 2.');
expect(heard[1]).toBe('Name is over New group, position 1 of 1.');
// The field over its own moved card names no new place, so the region keeps
// the sentence about the group rather than repeating it.
expect(heard[2]).toBe('Name is over New group, position 1 of 1.');

pointerDrop();
expect(said()).toBe('Name moved to New group, position 1 of 1.');
for (const sentence of [...heard, said()]) expect(sentence).not.toMatch(NO_ID);

// Control: the drop landed where the region says it did.
expect(landed('name')).toEqual({ group: 'new_group', position: 1, total: 1 });
});

it('says it in zh, with the zh labels the cards show', () => {
renderDesigner('zh');
const heard = pointerDrag(card('名称'), ['g:new_group', 'f:name']);
expect(heard[0]).toBe('已拿起 名称,当前在 未分组,第 1 个,共 2 个。');
expect(heard[1]).toBe('名称 正移到 新分组,第 1 个,共 1 个。');

pointerDrop();
expect(said()).toBe('名称 已移到 新分组,第 1 个,共 1 个。');
for (const sentence of [...heard, said()]) expect(sentence).not.toMatch(NO_ID);
expect(landed('name')).toEqual({ group: 'new_group', position: 1, total: 1 });
});

it('the screen-reader instructions every card points at are the localized ones', () => {
renderDesigner();
const describedBy = card('Name').getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
expect(document.getElementById(describedBy!)?.textContent).toBe(
'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.',
);
cleanup();

renderDesigner('zh');
const zhDescribedBy = card('名称').getAttribute('aria-describedby');
expect(document.getElementById(zhDescribedBy!)?.textContent).toBe(
'按空格键或回车键拿起字段。拖动时用方向键移动,再按空格键或回车键放到新位置,按 Esc 键取消。',
);
});

it('a reorder inside one group announces the place the drop commits', () => {
renderDesigner();
const heard = pointerDrag(card('Phone'), ['f:email']);
expect(heard[0]).toBe('Picked up Phone. It is in Contact, position 1 of 2.');
expect(heard[1]).toBe('Phone is over Contact, position 2 of 2.');

pointerDrop();
expect(said()).toBe('Phone moved to Contact, position 2 of 2.');
expect(landed('phone')).toEqual({ group: 'contact', position: 2, total: 2 });
});

it('a drop on a field of another group announces the place the drop commits, after the canvas moved the field', () => {
renderDesigner();
const heard = pointerDrag(card('Name'), ['f:email', 'f:name']);
expect(heard[1]).toBe('Name is over Contact, position 2 of 3.');

pointerDrop();
expect(said()).toBe('Name moved to Contact, position 2 of 3.');
expect(landed('name')).toEqual({ group: 'contact', position: 2, total: 3 });
});

it('a drop that is still over the field it was carried onto announces where the handler puts it', () => {
// No band for the moved card: the drop arrives while the pointer is still
// over Email, and the drop handler places Name after it.
renderDesigner();
pointerDrag(card('Name'), ['f:email']);

pointerDrop();
const place = landed('name');
expect(place).toEqual({ group: 'contact', position: 3, total: 3 });
expect(said()).toBe(`Name moved to Contact, position ${place.position} of ${place.total}.`);
});

it('leaving every group, then dropping, says so and names where the field stays', () => {
renderDesigner();
const heard = pointerDrag(card('Name'), ['g:new_group', null]);
expect(heard[2]).toBe('Name is not over a group.');

pointerDrop();
expect(said()).toBe('Name was dropped outside the groups and is back in Ungrouped, position 1 of 2.');
// Control: nothing was committed, and the card is back in its section.
expect(commits).toHaveLength(0);
});

it('a keyboard pick-up and cancel each announce by label', async () => {
renderDesigner();
const industry = card('Industry');
industry.focus();
fireEvent.keyDown(industry, { code: 'Space', key: ' ' });
expect(said()).toBe('Picked up Industry. It is in Ungrouped, position 2 of 2.');

// dnd-kit's keyboard sensor listens for the next key one tick later.
await tick();
fireEvent.keyDown(document, { code: 'Escape', key: 'Escape' });
expect(said()).toBe('Dragging cancelled. Industry is back in Ungrouped, position 2 of 2.');
expect(said()).not.toMatch(NO_ID);
expect(commits).toHaveLength(0);
});
});
69 changes: 69 additions & 0 deletions packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
import { useSafeFieldLabel } from '@object-ui/i18n';
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js';
import { isStudioHiddenSystemField } from './studioHiddenSystemField.js';
import { formDndAccessibility, type FormDndLookups, type FormDndSlot } from './formDndAnnouncements.js';

const UNGROUPED = '__ungrouped__';

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

/** A field's place in a container map: the container id, a 0-based index and the container's size. */
interface LayoutPlace {
container: string;
index: number;
total: number;
}

/** Where a field sits in a container map, or `null` when no container holds it. */
function placeIn(layout: Record<string, string[]>, id: string): LayoutPlace | null {
const container = Object.keys(layout).find((k) => layout[k].includes(id));
return container ? { container, index: layout[container].indexOf(id), total: layout[container].length } : null;
}

/**
* Where a drop of `activeId` on `overId` lands, read off the same container
* map `onDragEnd` reads and with the same arithmetic, so the place the live
* region announces is the place the drop commits (objectui#11802). `null`
* where `onDragEnd` returns without moving anything. The handler is the rule;
* this restates it for the announcements, and the pins in
* `ObjectFormDesigner.dndAnnouncements-11802.test.tsx` compare the two on
* every drop they make.
*/
function dropPlaceIn(layout: Record<string, string[]>, activeId: string, overId: string): LayoutPlace | null {
const inContainer = (id: string): string | undefined =>
id.startsWith('g:') && id in layout ? id : Object.keys(layout).find((k) => layout[k].includes(id));
const from = inContainer(activeId);
const to = inContainer(overId);
if (!from || !to) return null;
if (from === to) {
const list = layout[from];
const oldIndex = list.indexOf(activeId);
const newIndex = overId.startsWith('g:') ? list.length - 1 : list.indexOf(overId);
// `onDragEnd` keeps the field where it is when either index is missing.
return { container: from, index: newIndex < 0 ? oldIndex : newIndex, total: list.length };
}
const toItems = layout[to];
const overIndex = overId.startsWith('g:') ? toItems.length : toItems.indexOf(overId);
return { container: to, index: overIndex < 0 ? toItems.length : overIndex, total: toItems.length + 1 };
}

export interface ObjectFormDesignerProps {
/** Object metadata draft (reads `fields` + `fieldGroups`). */
draft: Record<string, unknown>;
Expand Down Expand Up @@ -439,6 +480,33 @@ export function ObjectFormDesigner({
itemsRef.current = items;
}, [items]);

// What the drag live region speaks (objectui#11802): the labels the cards and
// section headers render, never the `f:` / `g:` ids, and places read off the
// container map `onDragEnd` reads (`itemsRef` holds this render's `items`).
// dnd-kit subscribes the newest object each time a render commits, so a
// sentence always reads the committed layout and labels. A rebuild, here or
// on React's own account, changes no sentence.
const dndAccessibility = React.useMemo(() => {
const slot = (place: LayoutPlace | null): FormDndSlot | null =>
place && {
container: place.container,
group: labelOf.get(place.container) ?? t('engine.studio.designer.ungrouped', locale),
position: place.index + 1,
total: place.total,
};
const lookups: FormDndLookups = {
fieldLabel: (id) => {
const entry = entryByName.get(unFid(id));
// A drag starts only on a rendered card, which has an entry.
return entry ? fieldLabelOf(entry) : unFid(id);
},
slotOf: (id) => slot(placeIn(items, id)),
dropSlot: (id, overId) => slot(dropPlaceIn(items, id, overId)),
committedSlotOf: (id) => slot(placeIn(derived, id)),
};
return formDndAccessibility(locale, lookups);
}, [locale, items, derived, labelOf, entryByName, fieldLabelOf]);

const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
Expand Down Expand Up @@ -573,6 +641,7 @@ export function ObjectFormDesigner({
<DndContext
sensors={readOnly ? [] : sensors}
collisionDetection={pointerWithin}
accessibility={dndAccessibility}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
Expand Down
Loading
Loading