Skip to content

Commit 60d11cd

Browse files
fix(app-shell): form designer arrow keys reach every place on a multi-column canvas, and a keyboard drop commits the place it announced (objectui#11898) (#11940)
Fixes #11898 Clause-②: no. Keyboard geometry and the keyboard drag's drop target inside the Studio form designer: no export, prop, `@object-ui/types` member or accepted input changes. ## What changes All in `packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx`, inside the claim's fence (the keyboard sensor's coordinate getter, and `formCollision`'s keyboard branch): - **The keyboard sensor's coordinate getter** is no longer dnd-kit's `sortableKeyboardCoordinates`. A module-private `keyboardStep` walks the designer's own layout (the container map `onDragOver` / `onDragEnd` read) in reading order: ArrowDown and ArrowRight one place later, ArrowUp and ArrowLeft one place earlier, whatever the column count. Past either end of a group it enters the next group shown, first place going down and last place going up, so an empty group is one step like any other. At either end of the canvas it goes nowhere. It moves the chip onto the droppable the drag is now over, so the canvas still scrolls to follow it. - **`formCollision`'s keyboard branch** answers the droppable that place reads off the layout (`keyboardOverAt`): the card at that index in the field's own group, the card it goes before in another group, the group's section when it goes last, and the dragged card itself once `onDragOver` has carried it there. This replaces `closestCorners` there. The pointer branch is still `pointerWithin`. - The place is held as state for the collision and as a ref for the getter (the sensor keeps the getter from the pick-up on). It is reset on every keyboard pick-up through the sensor's own `onActivation` option, so nothing outside the sensor's configuration changes. - **Unchanged:** `onDragOver`, `onDragEnd`, `dropPlaceIn`, `formDndAnnouncements.ts`, `SortableField`, and the whole pointer path. No handler change was needed: once the keyboard drag is over the drop target its place names, the existing arithmetic commits the place the live region announced. New pin file `ObjectFormDesigner.keyboardGrid-11898.test.tsx`, and `.changeset/11898-form-keyboard-grid.md` (`@object-ui/app-shell` patch). ## Measured first (real Chromium, before the fix, on `d52d908`) The dispatch's three mechanism hypotheses, measured on a dev-only harness: a Vite page mounting this `ObjectFormDesigner` (readOnly off, sensors on) with objectui#11871's four-field fixture and an eleven-field one (Contact holds Phone, Email, Mobile, Fax, the full-row Notes textarea and Website). Playwright pressed the keys, a MutationObserver recorded every live-region sentence, and a probe wrapping the stock getter recorded its direction-filtered candidates with their `closestCorners` values. The harness was never committed and is deleted. - **H1, holds in part.** At 1280px, Email ArrowDown ranked `f:industry` 262, `g:new_group` 371, `g:__ungrouped__` 451, `f:name` 630, so the card won and Email landed in Ungrouped at 2 of 3, skipping the empty group. Name ArrowUp ranked `f:phone` 230 first (Contact 1 of 3). At 480px, Name ArrowUp ranked its own section `g:__ungrouped__` 69 first; the getter moved the chip to that section's corner, the drag stayed over its own card, and nothing moved (case 3). **Correction for case 2:** the full-row Notes' chip is as wide as its section, so Notes' OWN section won ArrowUp (`g:contact` 163, then `f:email` 410). The drag was then over the section, and the same-group arithmetic put Notes at the group's END: "Notes moved to Contact, position 6 of 6", from 5 of 6. A Notes that is already last reads as "does nothing". - **H2, holds, with one amendment.** The reading-order getter reaches every case at both widths (readings below). The amendment: the getter alone is not enough. The keyboard collision has to answer from the same layout, because a rect test misses the empty group even with the chip placed on it (reverse-check leg 2 below). - **H3, the mechanism holds, the place to fix it does not.** At 480px, Phone ArrowDown ×3: the third step was over `f:name`, and the live region said "Phone is over Ungrouped, position 1 of 3". `onDragOver` carried Phone before Name. A probe at a no-op key right before the drop read the drag still over `f:name`, and `onDragEnd`'s same-group branch (old index 0, new index 1) committed "position 2 of 3". The announcement is right, and the commit arithmetic is right for the `over` it is given. The `over` was wrong: after the carry, the rect test under the chip landed on the card the field had been carried before. With the keyboard `over` following the layout (the dragged card itself after a carry, the pattern of dnd-kit's own multi-container example), the existing arithmetic commits the announced place. `onDragEnd` and the pointer two-step objectui#11802 pinned are untouched. ## Real-browser reading after the fix (Chromium, HEAD blob `c947f010ae64` of `ObjectFormDesigner.tsx`, both widths) Identical sentences and commits at 1280px and at 480px: | case | keys | live region after the keys, then the drop | committed | |---|---|---|---| | 1 | Email, ArrowDown | over New group 1 of 1 → moved to New group 1 of 1 | `new_group: [email]` | | 1 | Name, ArrowUp | over New group 1 of 1 → moved to New group 1 of 1 | `new_group: [name]` | | 2 | Notes (full-row), ArrowUp | over Contact 4 of 6 → moved to Contact 4 of 6 | `contact: [phone, email, mobile, notes, fax, website]` | | 2 | Notes, ArrowDown | over Contact 6 of 6 → moved to Contact 6 of 6 | `contact: [… website, notes]` | | 3 | Name, ArrowUp ×2 | over New group 1 of 1, over Contact 3 of 3 → moved to Contact 3 of 3 | `contact: [phone, email, name]` | | 4 | Phone, ArrowDown ×3 | over Contact 2 of 2, over New group 1 of 1, over Ungrouped 1 of 3 → moved to Ungrouped 1 of 3 | `'': [phone, name, industry]` | Also read at both widths: eleven fields, Phone ArrowDown ×8 and Owner ArrowUp ×8 on a 420px-tall viewport. Every place is reached in order, including Contact's end slot, the page scrolls with the chip, and the drop commits the last place announced. A keyboard drag at either end of the canvas commits the unchanged layout. **Pointer control:** four pointer drags (Name onto the empty group, Phone onto Email, Name onto Email, Name released outside every grid) gave identical live-region sequences and commits before and after the fix, at both widths. ## Pins `ObjectFormDesigner.keyboardGrid-11898.test.tsx` runs the real `DndContext`, `KeyboardSensor` and `PointerSensor`, the designer's collision and handlers, with nothing in `@dnd-kit` mocked. Its layout stub copies Chromium's geometry at both widths: the container-query column count, a full-row card starting its own row, and the section headers between grids. For each width it pins the four cases plus a boundary case, each with the committed layout and the live region's sentences. Case 4 also compares the last announced place with the committed one. Controls: the one-column keyboard move objectui#11871 already reached (Email ArrowDown at 480px), and two pointer drags on the two-column canvas. ## Reverse checks (WRAP mode of objectstack's `scripts/ablation-replace.mjs`, inside the verify lock, committed head `8c1b262`) The tests import `./ObjectFormDesigner` from source, so there is no dist leg. Every leg's restore was proven by `blob c947f01 == HEAD` and an empty `git diff HEAD`. - **Leg 1, the base mechanism back** (`sortableKeyboardCoordinates` as the getter and `closestCorners` as the keyboard branch; 4 anchors, each 1 → 0): **9 failed / 13 passed**. Red: cases 1, 2, 3 and 4 at 1280px, and Name ArrowUp, case 3 and case 4 at 480px, each with Chromium's own pre-fix sentence. For example, 1280px Email read "over Ungrouped, position 2 of 3", and 480px Phone committed "position 2 of 3" after "over … 1 of 3". objectui#11871's three-step pin also went red. Green: the 480px Email move and the 480px Notes moves (Chromium reached those before the fix too), the boundary cases, and the pointer controls. The stub reproduces the pre-fix Chromium readings. - **Leg 2, the keyboard collision only back to `closestCorners`** (new getter kept): **8 failed / 14 passed**, case 4 red at both widths ("position 2 of 3" committed at 480px), and cases 1 to 3 red at 1280px (Email ArrowDown over "Contact, position 1 of 2": the empty section loses on corner distance even under the chip). - **Leg 3, the stock getter only** (new collision kept): **15 failed / 7 passed**. Every keyboard move stays put, the one-column control included, because nothing records a place. The pointer controls and the boundary cases stay green. ## Gates (on the merge head `a06a2a4`, origin/main `f1781be` merged in, no rebase) - `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` → 0 (28 packages) - `pnpm exec vitest run` over the six `ObjectFormDesigner*` / `StudioDesignSurface.formFields` files plus `viewCacheInvalidation.guard.test.tsx` → 0, 7 files / 60 tests passed - `pnpm exec vitest run packages/app-shell/src/views/studio-design/` → 0, 136 files / 854 tests passed - `pnpm --filter @object-ui/app-shell type-check` → 0 (`tsc -p tsconfig.test.json --listFilesOnly` lists both pin files) - `pnpm check:control-bytes`, `check:new-line-citations`, `check:changeset-claims`, `check:pending-changeset-literals` → 0 each; derived from this diff: `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:phantom-deps`, `check:unused-deps`, `check:unreferenced-sources`, `check:i18n-keys`, `check:shell-escape-residue`, and `scripts/check-changeset-presence.mjs` / `-no-major` / `-fixed` / `-overwrite` → 0 each - ESLint, narrowed: `eslint --no-inline-config --format json` on the 3 changed source and test files → 3 files, 0 errors, 1 warning (`react-hooks/set-state-in-effect` on the untouched draft re-sync effect, present on `main`). Population: the root `eslint.config.js` as `--print-config` resolves it. Invariance: no `parserOptions.project` / `projectService`, so no type-aware rule can move an untouched file's verdict. The repo-wide `pnpm lint` and the full test farm are CI's. ## A conflict in the dispatch, said here rather than chosen silently The dispatch rules that objectui#11871's pins "stay green as they are". One of them, "carries a field up through the empty group into a group that has fields", pinned this card's case 3: its first ArrowUp from Name, the first field of Ungrouped, moved nothing. Fixing case 3, which the same dispatch rules in, changes that pin's intermediate sentences. They now read one place per step: over New group 1 of 1, over Contact 3 of 3, over Contact 2 of 3. Chromium reads the same after the fix. Its final sentence and committed layout are unchanged, and every other objectui#11871 pin (pointer controls included) and every objectui#11802 pin stays as it was. The pin's header line that named `sortableKeyboardCoordinates` as its instrument now says "the designer's own coordinate getter". ## Acceptance notes - ArrowDown is "next place in reading order", not "next row", on a multi-column canvas: one step is one position, which is what the live region speaks. ArrowRight and ArrowLeft step the same way. - If a field is the only one in Ungrouped and a keyboard drag carries it into a group, the designer hides the now-empty Ungrouped bucket mid-drag, so ArrowDown cannot bring the field back there. Escape cancels the drag. A pointer drag behaves the same, because the section is gone. - The pending objectui#11871 changeset says the designer uses `closestCorners` for a keyboard drag. This PR replaces that branch, and its changeset says so, so the two entries read in order in the next CHANGELOG. That changeset file is outside this claim and was not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2fec2e0 commit 60d11cd

4 files changed

Lines changed: 485 additions & 23 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio's form designer can be arranged from the keyboard on a canvas of any width (objectui#11898). With a field card picked up, each arrow key now moves it one place in the form's reading order: ArrowDown and ArrowRight one place later, ArrowUp and ArrowLeft one place earlier, from the end of one group into the next and into an empty group, whatever the number of columns. The drop commits the place the live region last announced.
6+
7+
Before, on a form wider than one column, the arrow keys skipped an empty group, so a keyboard author could not put a field into a new group; a full-row field such as a textarea did not move up, or moved to the end of its group; and the first ArrowUp from a group's first field could do nothing. A keyboard drop could also commit one place past the one announced, after the field had been carried into another group.
8+
9+
The cause was the keyboard sensor's coordinate getter, dnd-kit's `sortableKeyboardCoordinates`, which picks each step's target by distance on screen, and the keyboard branch of the drag's collision detection, `closestCorners` (objectui#11871), which then judged the drop target by the same distance. The designer now steps through its own layout, and a keyboard drag is over the drop target that layout names for the place it reached. A pointer drag is unchanged.
10+
11+
Nothing is added to the package entry: no export, prop or type member changes.

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* outside the groups", and the draft never changed.
1111
*
1212
* The instrument: the REAL `DndContext`, the real `KeyboardSensor` with the
13-
* designer's `sortableKeyboardCoordinates`, the real `PointerSensor`, the
13+
* designer's own coordinate getter, the real `PointerSensor`, the
1414
* designer's own collision detection and its real handlers. Nothing in
1515
* `@dnd-kit` is mocked. The test DOM does no layout, so `layoutRect` lays the
1616
* canvas out the way real Chromium does for this draft at a narrow width, one
@@ -204,11 +204,12 @@ describe('a keyboard drag in the form designer reaches a drop target (objectui#1
204204
it('carries a field up through the empty group into a group that has fields', async () => {
205205
renderDesigner();
206206
const heard = await keyboardMove('Name', ['ArrowUp', 'ArrowUp', 'ArrowUp']);
207-
// The first step reaches the top of the field's own group, which moves
208-
// nothing; Chromium does the same.
207+
// Each step is one place in reading order (objectui#11898): the first
208+
// leaves the field's own group, which until objectui#11898 moved nothing,
209+
// and the second enters Contact at its end. Chromium does the same.
209210
expect(heard).toEqual([
210-
'Picked up Name. It is in Ungrouped, position 1 of 2.',
211211
'Name is over New group, position 1 of 1.',
212+
'Name is over Contact, position 3 of 3.',
212213
'Name is over Contact, position 2 of 3.',
213214
]);
214215
expect(said()).toBe('Name moved to Contact, position 2 of 3.');
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The form designer's arrow keys reach every place on a canvas of any column
5+
* count, and a keyboard drop commits the place the live region announced —
6+
* objectui#11898.
7+
*
8+
* The defect: the keyboard sensor's coordinate getter was dnd-kit's
9+
* `sortableKeyboardCoordinates`, which picks each step's target by corner
10+
* distance among the droppables in the arrow's direction, and the keyboard
11+
* drag was over whatever droppable the same measure then found under the
12+
* chip. On a multi-column canvas a section's droppable lost to the nearest
13+
* card, so the arrow keys skipped an empty group; upward, a field's own
14+
* section was the nearest, so the first ArrowUp from a group's first field did
15+
* nothing, and a full-row field's chip, as wide as its section, landed on its
16+
* own section and went to the group's end. After the canvas carried a field
17+
* into another group, the chip still sat on the card it had been carried
18+
* before, and the drop's same-group arithmetic moved it one place past the
19+
* place announced.
20+
*
21+
* The instrument: the REAL `DndContext`, the real `KeyboardSensor` with the
22+
* designer's own coordinate getter, the real `PointerSensor`, the designer's
23+
* own collision detection and its real handlers. Nothing in `@dnd-kit` is
24+
* mocked. The test DOM does no layout, so `layoutRect` lays the canvas out the
25+
* way real Chromium does for these drafts at a 1280px and a 480px viewport
26+
* (the reading is on the pull request): the field grid's container-query
27+
* columns, a full-row card starting its own row, the section headers between
28+
* grids. Each case runs at both widths, and reads the committed `fields` and
29+
* the live region.
30+
*
31+
* The controls ride beside the cases: a one-column keyboard move
32+
* objectui#11871 already reached, and pointer drags on the multi-column
33+
* canvas, which still answer through `pointerWithin`.
34+
*/
35+
36+
import '@testing-library/jest-dom/vitest';
37+
import * as React from 'react';
38+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
39+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
40+
import { ObjectFormDesigner } from './ObjectFormDesigner';
41+
42+
type Field = { name: string; type: string; label: string; group?: string };
43+
type Draft = { name: string; fields: Field[]; fieldGroups: Array<{ key: string; label: string }> };
44+
45+
const GROUPS = [
46+
{ key: 'contact', label: 'Contact' },
47+
{ key: 'new_group', label: 'New group' },
48+
];
49+
50+
/** objectui#11871's fixture: Contact (Phone, Email), New group (empty), Ungrouped (Name, Industry). */
51+
const FOUR: Draft = {
52+
name: 'account',
53+
fields: [
54+
{ name: 'name', type: 'text', label: 'Name' },
55+
{ name: 'industry', type: 'text', label: 'Industry' },
56+
{ name: 'phone', type: 'text', label: 'Phone', group: 'contact' },
57+
{ name: 'email', type: 'text', label: 'Email', group: 'contact' },
58+
],
59+
fieldGroups: GROUPS,
60+
};
61+
62+
/** Eleven fields, three columns wide at 1280px, with the full-row Notes textarea inside Contact. */
63+
const ELEVEN: Draft = {
64+
name: 'account',
65+
fields: [
66+
{ name: 'name', type: 'text', label: 'Name' },
67+
{ name: 'industry', type: 'text', label: 'Industry' },
68+
{ name: 'rating', type: 'text', label: 'Rating' },
69+
{ name: 'revenue', type: 'text', label: 'Revenue' },
70+
{ name: 'owner', type: 'text', label: 'Owner' },
71+
{ name: 'phone', type: 'text', label: 'Phone', group: 'contact' },
72+
{ name: 'email', type: 'text', label: 'Email', group: 'contact' },
73+
{ name: 'mobile', type: 'text', label: 'Mobile', group: 'contact' },
74+
{ name: 'fax', type: 'text', label: 'Fax', group: 'contact' },
75+
{ name: 'notes', type: 'textarea', label: 'Notes', group: 'contact' },
76+
{ name: 'website', type: 'text', label: 'Website', group: 'contact' },
77+
],
78+
fieldGroups: GROUPS,
79+
};
80+
81+
// Chromium's layout of these canvases. Each section's field grid (the section
82+
// droppable) starts at GRID_LEFT, is GRID_WIDTH wide and pads its cards by
83+
// GRID_PAD; cards are CARD_HEIGHT tall (a textarea's WIDE_CARD_HEIGHT), GAP
84+
// apart in both directions; an empty grid is EMPTY_GRID_HEIGHT tall. A
85+
// section's header sits between its grid and the grid above: a declared
86+
// group's (its name is an input) is BEFORE_GROUP_GRID tall with the borders
87+
// and the gap, the Ungrouped bucket's BEFORE_UNGROUPED_GRID. The drag
88+
// overlay's label chip, which dnd-kit measures as the dragged box, is
89+
// CHIP_HEIGHT tall.
90+
type Width = 1280 | 480;
91+
const CANVAS: Record<Width, { GRID_LEFT: number; GRID_WIDTH: number; FIRST_GRID_TOP: number }> = {
92+
1280: { GRID_LEFT: 65, GRID_WIDTH: 1150, FIRST_GRID_TOP: 93 },
93+
480: { GRID_LEFT: 18, GRID_WIDTH: 444, FIRST_GRID_TOP: 109.5 },
94+
};
95+
const GRID_PAD = 10;
96+
const GAP = 16;
97+
const CARD_HEIGHT = 66;
98+
const WIDE_CARD_HEIGHT = 94;
99+
const EMPTY_GRID_HEIGHT = 62.5;
100+
const BEFORE_GROUP_GRID = 50.5;
101+
const BEFORE_UNGROUPED_GRID = 46.5;
102+
const CHIP_HEIGHT = 34;
103+
104+
let width: Width = 1280;
105+
106+
const isGrid = (el: Element) => el.classList.contains('min-h-[52px]');
107+
const isCard = (el: Element) => el.classList.contains('cursor-grab');
108+
const cardsOf = (grid: Element) => Array.from(grid.children).filter(isCard);
109+
110+
type Box = { left: number; top: number; width: number; height: number };
111+
112+
/**
113+
* How many columns a field grid shows: its widest container-query step. A
114+
* 1280px canvas's sections reach every step the grid names, a 480px one's
115+
* none of them.
116+
*/
117+
function columnsOf(grid: Element): number {
118+
if (width === 480) return 1;
119+
const steps = Array.from(grid.className.matchAll(/@\w+:grid-cols-(\d)/g), (m) => Number(m[1]));
120+
return Math.max(1, ...steps);
121+
}
122+
123+
/** A grid's height, and its cards' boxes, laid out from `top` the way CSS grid auto-placement does. */
124+
function layGrid(grid: Element, top: number): { height: number; boxes: Map<Element, Box> } {
125+
const { GRID_LEFT, GRID_WIDTH } = CANVAS[width];
126+
const columns = columnsOf(grid);
127+
const column = (GRID_WIDTH - 2 * GRID_PAD - (columns - 1) * GAP) / columns;
128+
const boxes = new Map<Element, Box>();
129+
const cards = cardsOf(grid);
130+
let y = top + GRID_PAD;
131+
let col = 0;
132+
let rowHeight = 0;
133+
const nextRow = () => {
134+
y += rowHeight + GAP;
135+
col = 0;
136+
rowHeight = 0;
137+
};
138+
for (const card of cards) {
139+
// A full-row card starts its own row and takes all of it.
140+
const span = card.classList.contains('col-span-full') ? columns : 1;
141+
if (span > 1 && col > 0) nextRow();
142+
const height = card.querySelector('.h-14') ? WIDE_CARD_HEIGHT : CARD_HEIGHT;
143+
boxes.set(card, { left: GRID_LEFT + GRID_PAD + col * (column + GAP), top: y, width: span * column + (span - 1) * GAP, height });
144+
rowHeight = Math.max(rowHeight, height);
145+
col += span;
146+
if (col >= columns) nextRow();
147+
}
148+
const bottom = col > 0 ? y + rowHeight : y - GAP;
149+
return { height: cards.length ? bottom + GRID_PAD - top : EMPTY_GRID_HEIGHT, boxes };
150+
}
151+
152+
/** Where Chromium puts `el` on the canvas as the DOM stands now; `null` for an element it does not lay out here. */
153+
function layoutRect(el: HTMLElement): Box | null {
154+
// dnd-kit's drag overlay is a `position: fixed` wrapper at the box dnd-kit
155+
// gave it, and dnd-kit measures the wrapper's one child, the label chip:
156+
// the wrapper's width at the chip's own height.
157+
const wrapper = el.parentElement;
158+
if (wrapper?.style.position === 'fixed') {
159+
return {
160+
left: parseFloat(wrapper.style.left),
161+
top: parseFloat(wrapper.style.top),
162+
width: parseFloat(wrapper.style.width),
163+
height: CHIP_HEIGHT,
164+
};
165+
}
166+
if (!isGrid(el) && !isCard(el)) return null;
167+
const { GRID_LEFT, GRID_WIDTH, FIRST_GRID_TOP } = CANVAS[width];
168+
let top = FIRST_GRID_TOP;
169+
for (const [i, grid] of Array.from(document.querySelectorAll('div')).filter(isGrid).entries()) {
170+
if (i > 0) top += grid.parentElement?.firstElementChild?.querySelector('input') ? BEFORE_GROUP_GRID : BEFORE_UNGROUPED_GRID;
171+
const { height, boxes } = layGrid(grid, top);
172+
if (grid === el) return { left: GRID_LEFT, top, width: GRID_WIDTH, height };
173+
const box = boxes.get(el);
174+
if (box) return box;
175+
top += height;
176+
}
177+
return null;
178+
}
179+
180+
const originalRect = Element.prototype.getBoundingClientRect;
181+
beforeEach(() => {
182+
Element.prototype.getBoundingClientRect = function (this: HTMLElement) {
183+
const box = layoutRect(this) ?? { left: 0, top: 0, width: 0, height: 0 };
184+
const { left, top, width: w, height } = box;
185+
return { left, top, width: w, height, x: left, y: top, right: left + w, bottom: top + height, toJSON: () => box };
186+
};
187+
});
188+
189+
afterEach(() => {
190+
cleanup();
191+
Element.prototype.getBoundingClientRect = originalRect;
192+
});
193+
194+
/** Every `fields` the designer committed, in order. */
195+
let commits: Field[][] = [];
196+
197+
function Host({ draft: initial }: { draft: Draft }): React.ReactElement {
198+
const [draft, setDraft] = React.useState<Record<string, unknown>>(initial);
199+
return (
200+
<ObjectFormDesigner
201+
draft={draft}
202+
systemFieldNames={new Set()}
203+
onChange={(patch) => {
204+
if (patch.fields) commits.push(patch.fields as Field[]);
205+
setDraft((d) => ({ ...d, ...patch }));
206+
}}
207+
onSelectField={() => {}}
208+
/>
209+
);
210+
}
211+
212+
function renderDesigner(at: Width, draft: Draft = FOUR) {
213+
width = at;
214+
commits = [];
215+
render(<Host draft={draft} />);
216+
}
217+
218+
/** What the live region says now. */
219+
const said = () => document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? null;
220+
const card = (label: string) => screen.getByText(label, { exact: true }).closest('.cursor-grab') as HTMLElement;
221+
const tick = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });
222+
223+
/**
224+
* Focus the card, pick it up with Space, press `arrows`, drop with Space.
225+
* Returns what the region said after each arrow key.
226+
*/
227+
async function keyboardMove(label: string, arrows: string[]): Promise<string[]> {
228+
const el = card(label);
229+
el.focus();
230+
fireEvent.keyDown(el, { code: 'Space', key: ' ' });
231+
// dnd-kit's keyboard sensor listens for the next key one tick later.
232+
await tick();
233+
const heard: string[] = [];
234+
for (const code of arrows) {
235+
fireEvent.keyDown(document, { code, key: code });
236+
await tick();
237+
heard.push(said() ?? '');
238+
}
239+
fireEvent.keyDown(document, { code: 'Space', key: ' ' });
240+
await tick();
241+
return heard;
242+
}
243+
244+
/** The last commit, as group key → field names in order (`''` = ungrouped). */
245+
function layoutOfLastCommit(): Record<string, string[]> {
246+
const out: Record<string, string[]> = {};
247+
for (const f of commits.at(-1) ?? []) (out[f.group ?? ''] ??= []).push(f.name);
248+
return out;
249+
}
250+
251+
/** The place the last commit gives a field, in the live region's words: "Group, position N of M". */
252+
function committedPlace(name: string): string {
253+
const fields = commits.at(-1) ?? [];
254+
const group = fields.find((f) => f.name === name)?.group;
255+
const members = fields.filter((f) => f.group === group).map((f) => f.name);
256+
const label = GROUPS.find((g) => g.key === group)?.label ?? 'Ungrouped';
257+
return `${label}, position ${members.indexOf(name) + 1} of ${members.length}`;
258+
}
259+
260+
describe.each([1280, 480] as const)('the arrow keys step in reading order at %ipx (objectui#11898)', (at) => {
261+
it('case 1: Email, ArrowDown reaches the empty New group, and the drop commits it there', async () => {
262+
renderDesigner(at);
263+
const heard = await keyboardMove('Email', ['ArrowDown']);
264+
expect(heard).toEqual(['Email is over New group, position 1 of 1.']);
265+
expect(said()).toBe('Email moved to New group, position 1 of 1.');
266+
expect(layoutOfLastCommit()).toEqual({ contact: ['phone'], new_group: ['email'], '': ['name', 'industry'] });
267+
});
268+
269+
it('case 1: Name, ArrowUp reaches the empty New group from below', async () => {
270+
renderDesigner(at);
271+
const heard = await keyboardMove('Name', ['ArrowUp']);
272+
expect(heard).toEqual(['Name is over New group, position 1 of 1.']);
273+
expect(said()).toBe('Name moved to New group, position 1 of 1.');
274+
expect(layoutOfLastCommit()).toEqual({ contact: ['phone', 'email'], new_group: ['name'], '': ['industry'] });
275+
});
276+
277+
it('case 2: the full-row Notes moves up one place, and down one place', async () => {
278+
renderDesigner(at, ELEVEN);
279+
expect(await keyboardMove('Notes', ['ArrowUp'])).toEqual(['Notes is over Contact, position 4 of 6.']);
280+
expect(said()).toBe('Notes moved to Contact, position 4 of 6.');
281+
expect(layoutOfLastCommit().contact).toEqual(['phone', 'email', 'mobile', 'notes', 'fax', 'website']);
282+
283+
cleanup();
284+
renderDesigner(at, ELEVEN);
285+
expect(await keyboardMove('Notes', ['ArrowDown'])).toEqual(['Notes is over Contact, position 6 of 6.']);
286+
expect(said()).toBe('Notes moved to Contact, position 6 of 6.');
287+
expect(layoutOfLastCommit().contact).toEqual(['phone', 'email', 'mobile', 'fax', 'website', 'notes']);
288+
});
289+
290+
it("case 3: the first ArrowUp from a group's first field moves it, and the next one reaches the end of the group above", async () => {
291+
renderDesigner(at);
292+
const heard = await keyboardMove('Name', ['ArrowUp', 'ArrowUp']);
293+
expect(heard).toEqual(['Name is over New group, position 1 of 1.', 'Name is over Contact, position 3 of 3.']);
294+
expect(said()).toBe('Name moved to Contact, position 3 of 3.');
295+
expect(layoutOfLastCommit()).toEqual({ contact: ['phone', 'email', 'name'], '': ['industry'] });
296+
});
297+
298+
it('case 4: Phone, ArrowDown ×3 commits the place the region announced', async () => {
299+
renderDesigner(at);
300+
const heard = await keyboardMove('Phone', ['ArrowDown', 'ArrowDown', 'ArrowDown']);
301+
expect(heard).toEqual([
302+
'Phone is over Contact, position 2 of 2.',
303+
'Phone is over New group, position 1 of 1.',
304+
'Phone is over Ungrouped, position 1 of 3.',
305+
]);
306+
expect(said()).toBe('Phone moved to Ungrouped, position 1 of 3.');
307+
expect(layoutOfLastCommit()).toEqual({ contact: ['email'], '': ['phone', 'name', 'industry'] });
308+
// Control: the last place announced is the place committed.
309+
expect(heard.at(-1)).toBe(`Phone is over ${committedPlace('phone')}.`);
310+
});
311+
312+
it('at either end of the canvas the step goes nowhere, and the drop leaves the field where it was', async () => {
313+
renderDesigner(at);
314+
await keyboardMove('Phone', ['ArrowUp', 'ArrowLeft']);
315+
expect(said()).toBe('Phone moved to Contact, position 1 of 2.');
316+
expect(layoutOfLastCommit()).toEqual({ contact: ['phone', 'email'], '': ['name', 'industry'] });
317+
});
318+
});
319+
320+
describe('controls (objectui#11898)', () => {
321+
it('a one-column keyboard move objectui#11871 already reached: Email, ArrowDown at 480px', async () => {
322+
renderDesigner(480);
323+
expect(await keyboardMove('Email', ['ArrowDown'])).toEqual(['Email is over New group, position 1 of 1.']);
324+
expect(layoutOfLastCommit()).toEqual({ contact: ['phone'], new_group: ['email'], '': ['name', 'industry'] });
325+
});
326+
327+
/** Press on Name's card, cross the 4px activation distance, move to each point in `path`. */
328+
function pointerDrag(from: { x: number; y: number }, path: Array<{ x: number; y: number }>) {
329+
fireEvent.pointerDown(card('Name'), { isPrimary: true, button: 0, clientX: from.x, clientY: from.y });
330+
fireEvent.pointerMove(document, { isPrimary: true, clientX: from.x + 10, clientY: from.y });
331+
for (const p of path) fireEvent.pointerMove(document, { isPrimary: true, clientX: p.x, clientY: p.y });
332+
fireEvent.pointerUp(document, { isPrimary: true, button: 0 });
333+
}
334+
335+
it('a pointer over the empty group on the two-column canvas drops the field into it', () => {
336+
renderDesigner(1280);
337+
// Name's card is at (75, 338.5 + 10); the New group's grid spans y 229.5..292.
338+
// The second move is the pointer resting while the canvas moves the card.
339+
pointerDrag({ x: 100, y: 380 }, [{ x: 600, y: 260 }, { x: 601, y: 260 }]);
340+
expect(said()).toBe('Name moved to New group, position 1 of 1.');
341+
expect(layoutOfLastCommit()).toEqual({ contact: ['phone', 'email'], new_group: ['name'], '': ['industry'] });
342+
});
343+
344+
it('a pointer released outside every group on the two-column canvas commits nothing', () => {
345+
renderDesigner(1280);
346+
// Right of every grid (they end at x 1215): no droppable is under it.
347+
pointerDrag({ x: 100, y: 380 }, [{ x: 1250, y: 260 }]);
348+
expect(said()).toBe('Name was dropped outside the groups and is back in Ungrouped, position 1 of 2.');
349+
expect(commits).toHaveLength(0);
350+
});
351+
});

0 commit comments

Comments
 (0)