diff --git a/.changeset/10976-table-slot-relay.md b/.changeset/10976-table-slot-relay.md index 58317210e6..f1be59d2f4 100644 --- a/.changeset/10976-table-slot-relay.md +++ b/.changeset/10976-table-slot-relay.md @@ -48,3 +48,8 @@ doc or builder in this repository writes one. The slot's key list and the withheld set are pinned against drift by `packages/types/src/__tests__/object-view-slot-key-lists.test.ts`, which also requires the validator to refuse every withheld key by name, so the two faces refuse the same keys. + +**Correction, 2026-10-03 (objectui#11068).** `keyboardNavigation` moved from the first group to +the second: `ObjectGrid` honours it on an `object-grid` node since objectui#11068's build, and +the view still does not hand it on, so the slot still refuses it, now with that reason +(`.changeset/11068-keyboard-navigation.md`). diff --git a/.changeset/11068-grid-declared-keys.md b/.changeset/11068-grid-declared-keys.md index 90a0ee96d4..c374fa26e5 100644 --- a/.changeset/11068-grid-declared-keys.md +++ b/.changeset/11068-grid-declared-keys.md @@ -46,3 +46,8 @@ optional strings. It is the spec's `EmptyStateSchema` by reference, which `@obje 17.6.0 declares on its `object-grid` row: `title` and `message` are `string | I18nLabel` (a plain string or an inline locale map, which the grid resolves against the display locale), and an unknown member is still refused by name (`.changeset/11227-object-grid-17-6-keys.md`). + +**Correction, 2026-10-03 (objectui#11068, the `keyboardNavigation` build).** `keyboardNavigation` +is no longer unread. The grid honours it, with arrow-key cell navigation that is on by default +when the grid renders editable, and it is in the grid's declared inputs +(`.changeset/11068-keyboard-navigation.md`). diff --git a/.changeset/11068-keyboard-navigation.md b/.changeset/11068-keyboard-navigation.md new file mode 100644 index 0000000000..17c43a888a --- /dev/null +++ b/.changeset/11068-keyboard-navigation.md @@ -0,0 +1,41 @@ +--- +'@object-ui/components': minor +'@object-ui/plugin-grid': minor +'@object-ui/types': minor +--- + +An `object-grid` honours `keyboardNavigation`: arrow-key cell navigation on the WAI-ARIA grid +pattern (objectui#11068). `@objectstack/spec` 17.6.0 declares the key on its `object-grid` row +(objectstack#20694), and the grid now reads it. + +**`@object-ui/plugin-grid` (feature).** + +- With the key on, the grid's data cells take one place in the Tab order instead of one each: + Tab reaches them once, on the cell that last held focus (the first cell to begin with), and + the next Tab moves past them. The arrow keys move focus one cell, Home / End go to the first / + last cell of the row, and Ctrl+Home / Ctrl+End to the first / last cell of the page. On an + editable grid, Enter still opens the focused cell, and an edit ended with Enter or Escape + hands focus back to its cell. A widget a cell renders (the record link, a row's action menu, + a selection checkbox) keeps its own Tab stop. +- The default is on when the grid renders editable: the authored `editable` and the viewer's + permission to update the object, the value inline editing itself obeys. A read-only grid + keeps every cell its own Tab stop unless `keyboardNavigation: true` is written, and + `keyboardNavigation: false` turns it off on an editable grid. **An editable grid changes + without an edit to its document:** its cells become one Tab stop, and the arrow keys move + between them. +- `keyboardNavigation` is in the grid's declared inputs, so the designer panel, the component + manifest and the generated `sdui-intrinsics.d.ts` offer it, and the SDUI parser no longer + reports it as `unknown-prop`. This supersedes the line in objectui#11227's entry that says + the key is not published and nothing reads it. + +**`@object-ui/components` (feature).** `data-table` takes a `keyboardNavigation` flag that does +the above: the table is exposed to assistive technology as a `grid`, its data cells are one +roving Tab stop, and the keys move it. Off, which stays the default, the table is unchanged: +every data cell is its own Tab stop and the table carries no grid role. `ObjectGrid` sets the +flag in code; it is not an authoring key on a `data-table` node. + +**`@object-ui/types` (feature).** `DataTableSchema` declares `keyboardNavigation?: boolean`, the +flag above, set in code by a host as `editable` is. `ObjectGridSchema.keyboardNavigation`'s +documentation describes the behaviour and the default. An `object-view`'s `table` slot still +refuses `keyboardNavigation`; its message now says the grid honours the key on an +`object-grid` node and the view does not hand it on. diff --git a/.changeset/11068-row1-booking-17-6.md b/.changeset/11068-row1-booking-17-6.md index 3af92c9bdb..9e800d4e90 100644 --- a/.changeset/11068-row1-booking-17-6.md +++ b/.changeset/11068-row1-booking-17-6.md @@ -2,3 +2,5 @@ --- Test-only change in `@object-ui/console`; no published behaviour changes. Under objectui#11438 ruling A″, row 1 of the `@objectstack/spec` 17.6.0 bump, `object-grid.keyboardNavigation`, is booked as owed to objectui#11068 in `registry-inputs-spec-parity.test.ts`, with an expiry (2026-11-02, or when objectui#11068's build lands). The entry joins the file's objectui#11111 ledger, whose `unpublishedKeys` cap rises by exactly this one entry. The GA-block split row admits this one id by name and refuses every other exemption on the four GA blocks as before. The file sits under `apps/console/src/__tests__/`, which nothing outside `__tests__/` imports, so neither the console bundle nor `plugin.*` carries it. + +**Correction, 2026-10-03 (objectui#11068, the `keyboardNavigation` build).** The booking is struck. objectui#11068's build publishes `keyboardNavigation` in the grid's declared inputs together with its reader, so the entry went stale; the build removes it, lowers the `unpublishedKeys` cap by the same one entry, and leaves the GA-block split row admitting no booked id (`.changeset/11068-keyboard-navigation.md`). diff --git a/.changeset/11227-object-grid-17-6-keys.md b/.changeset/11227-object-grid-17-6-keys.md index a1bc3f18b8..d37cfc0dd5 100644 --- a/.changeset/11227-object-grid-17-6-keys.md +++ b/.changeset/11227-object-grid-17-6-keys.md @@ -37,3 +37,10 @@ string is drawn as before. - TypeScript code that reads `emptyState.title` or `.message` as a `string` no longer type-checks. Resolve the value first, for example with `resolveI18nLabel` from `@objectstack/spec/ui`. + +**Correction, 2026-10-03 (objectui#11068, the `keyboardNavigation` build).** The bullet above +that says `keyboardNavigation` "is not published" and that "nothing in the grid reads it yet" is +superseded. The grid honours the key, with arrow-key cell navigation that is on by default when +the grid renders editable, and it is in the grid's declared inputs, so the designer panel, the +component manifest and the generated `sdui-intrinsics.d.ts` offer it +(`.changeset/11068-keyboard-navigation.md`). diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 1c88395bfd..83dafcb816 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -758,11 +758,11 @@ function isRetiredUpstream(type: string): boolean { * same way, to their own cards". Row 1 is such a row: * * - objectui#11068 — `object-grid.keyboardNavigation`, the one key on a GA - * block that 17.6.0's row declares and `inputs` does not publish. That card - * is BUILDING the key's reader and publishes the key with it; this is the - * only OWED entry on any of the four GA blocks, pinned by name in `the four - * GA blocks resolve their ruled split`, because objectui#4648's split - * otherwise refuses any exemption on them (see THE FOUR GA BLOCKS below). + * block that 17.6.0's row declares and `inputs` did not publish. STRUCK by + * that card's build, which publishes the key in `GRID_QUERY_INPUTS` together + * with its reader (arrow-key cell navigation on the WAI-ARIA grid pattern), + * so the card owns no entry now and the four GA blocks carry no OWED entry + * again (see THE FOUR GA BLOCKS below). * * Each owner card decides every key by its own measurement: declare what the * renderer honours, refuse or retire what it does not. ⛔ Batch-declaring an @@ -786,8 +786,9 @@ function isRetiredUpstream(type: string): boolean { * their ruling as the 17.5.0 date is from its own: record 5902351047 was ruled * 2026-09-30 and its entries expire 2026-10-30; record 5965062249 was ruled * 2026-10-03, so objectui#11536's entries expire 2026-11-02. Record 5968177777 - * (ruling A″) was ruled the same day, so objectui#11068's entry shares that - * date: it expires 2026-11-02, or when that card's build lands. + * (ruling A″) was ruled the same day, so objectui#11068's entry shared that + * date: it expired 2026-11-02, or when that card's build landed — the build + * landed first and struck it. */ const OBJECTUI_11111_EXPIRES = '2026-10-30'; @@ -889,7 +890,7 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 12, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′); objectui#11068: 1 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″) + unpublishedKeys: 11, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′); objectui#11068: 0 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″, struck by that card's build) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked by objectui#11438 ruling A′) } as const; @@ -1555,27 +1556,11 @@ const UNPUBLISHED_EXEMPTIONS: Record = { 'A SPEC KEY NOT PUBLISHED: `record:line_items` entered `covered` with 17.6.0 and its `inputs` omit this key its spec row declares.', ), - /* - * ⚠️ ROW 1 OF THE 17.6.0 BUMP — objectui#11438 ruling A″ (record 5968177777), - * which books a row whose owner card's slice is not accepted when the row-3 - * booking is pushed, "the same way, to their own cards". - * - * `object-grid.keyboardNavigation`: the 17.6.0 row declares it and the - * grid's `inputs` do not publish it. objectui#11068 is building its reader - * (arrow-key cell navigation on the WAI-ARIA grid pattern) and publishes the - * key in `GRID_QUERY_INPUTS` together with that reader, never ahead of it. - * `object-grid` is a GA block, where objectui#4648's split admits no - * exemption but the ruled carve-out, so this entry is the one exception and - * `the four GA blocks resolve their ruled split` pins it by name. Capped - * with the entries above at `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`; - * the build's landing makes it stale, and strikes it and lowers the cap. - */ - ...owedEntries( - 'object-grid', - ['keyboardNavigation'], - 'objectui#11068', - 'A SPEC KEY NOT PUBLISHED, ITS READER IN FLIGHT: `object-grid`\'s 17.6.0 row declares `keyboardNavigation` and its `inputs` omit it; objectui#11068 publishes it with the reader it is building.', - ), + // objectui#11068's one entry — row 1 of the 17.6.0 bump, + // `object-grid.keyboardNavigation`, booked by objectui#11438 ruling A″ + // (record 5968177777) — is STRUCK: that card's build published the key in + // `GRID_QUERY_INPUTS` together with its reader, so the entry went stale and + // `carries no stale unpublished-key exemption` would refuse it. }; /** @@ -1756,14 +1741,13 @@ const isDormantOnThisPin = (exemptionKey: string): boolean => { * these blocks gain is a plain A-class defect: declare it at the registration * site. Do not add an entry here to silence one. * - * ⚠️ ONE BOOKED EXCEPTION, BY RULING AND WITH AN EXPIRY — not a silenced key. - * objectui#11438 ruling A″ (record 5968177777) books row 1 of the 17.6.0 bump, - * `object-grid.keyboardNavigation`, OWED TO objectui#11068, which declares it - * at the registration site together with the reader it is building. The entry - * expires 2026-11-02 or at that card's landing, whichever is first. - * `the four GA blocks resolve their ruled split` admits exactly that id and no - * other OWED entry on these four blocks, so the rule above still holds for - * every other key. + * ⚠️ ONE BOOKED EXCEPTION STOOD HERE, BY RULING AND WITH AN EXPIRY, and is + * STRUCK. objectui#11438 ruling A″ (record 5968177777) booked row 1 of the + * 17.6.0 bump, `object-grid.keyboardNavigation`, OWED TO objectui#11068, until + * that card declared it at the registration site together with its reader. Its + * build did, and struck the entry. `the four GA blocks resolve their ruled + * split` now admits no OWED entry on these four blocks, so the rule above holds + * for every key again. */ const exemptedFor = (type: string): string[] => @@ -4959,16 +4943,14 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) ).toContain(`object-grid.${key}`); } - // ⚠️ THE ONE BOOKED KEY — objectui#11438 ruling A″ (record 5968177777). - // Row 1 of the 17.6.0 bump, `object-grid.keyboardNavigation`, is OWED TO - // objectui#11068, which publishes it together with the reader it is - // building. It is neither declared nor carved out, so it is admitted BY - // NAME: the OWED entries on the four blocks must be exactly this list, and a - // second one is red here whatever the ledger cap says. It expires 2026-11-02 - // or at that card's landing; the landing declares the key, which turns the - // first assertion in the loop below red until this list and the entry are - // struck together. - const BOOKED_GA_KEYS = ['object-grid.keyboardNavigation']; + // ⚠️ THE BOOKED KEYS — objectui#11438 ruling A″ (record 5968177777) booked + // one, row 1 of the 17.6.0 bump, `object-grid.keyboardNavigation`, OWED TO + // objectui#11068. That card's build published the key together with its + // reader and struck the booking and this list together, as the booking + // prescribed. The list stays, EMPTY, so an OWED entry on a GA block is + // still red here whatever the ledger cap says: a booking admitted here is a + // ruling's, by name, and none stands. + const BOOKED_GA_KEYS: string[] = []; expect( owedIdsOf(UNPUBLISHED_EXEMPTIONS).filter((id) => GA_ONLY_BLOCKS.includes(splitExemptionKey(id)[0])), 'an OWED entry on a GA block that objectui#11438 ruling A″ did not book — declare the key instead', @@ -6011,7 +5993,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'objectui#8652': 0, 'objectui#8649': 0, 'objectui#11536': 12, - 'objectui#11068': 1, + 'objectui#11068': 0, }); }); }); diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index 3069fa0e5c..e3dbc4af18 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -146,6 +146,22 @@ a sibling field — a `dependsOn` lookup — should read `pendingRow`, so a pare edited in the same row re-scopes the child before anything is saved; `row` stays the place to read what the data source last returned. +## Keyboard navigation + +`keyboardNavigation` is set by the host in code too, beside `editable`, and is off +unless a host turns it on. `object-grid` is that host: it resolves its own +`keyboardNavigation` key (on by default when the grid renders editable) and sets +the answer on its table — see +[Keyboard navigation](/docs/plugins/plugin-grid#keyboard-navigation) in the grid +plugin (objectui#11068). + +On, the table follows the WAI-ARIA grid pattern: it is exposed as a `grid`, its +data cells take one place in the Tab order instead of one each (the cell that last +held focus), the arrow keys move focus one cell, Home / End go to the ends of the +row and Ctrl+Home / Ctrl+End to the ends of the page, and an edit ended with Enter +or Escape hands focus back to its cell. A widget a cell renders keeps its own Tab +stop. Off, every data cell is its own Tab stop, as before. + ## Masked columns A column with `masked: true` holds a credential. The table withholds its raw value on diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 62cce3d0d4..abbc4ef924 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -103,6 +103,7 @@ below for why this plugin deliberately does not claim it. | `selection` | `SelectionConfig` | `{ type: 'none' \| 'single' \| 'multiple' }`. | | `rowActions` / `bulkActions` | `string[]` | **Names** of actions, not inline definitions. | | `editable` / `singleClickEdit` | `boolean` | Inline editing — see [Inline Editing](#inline-editing). | +| `keyboardNavigation` | `boolean` | Arrow keys move focus between cells, and the cells are one Tab stop — see [Keyboard navigation](#keyboard-navigation). On by default when the grid renders editable. | | `navigation` | `NavigationConfig` | What a row click does: `{ mode: 'page' \| 'drawer' \| 'modal' \| 'split' \| 'none', … }`. | | `operations` | `object` | Toggles the built-in CRUD/export/import affordances, e.g. `{ delete: false }`. | | `rowHeight`, `frozenColumns`, `resizable`, `reorderableColumns`, `showColumnTypeIcons`, `rowColor`, `conditionalFormatting`, `aggregations`, `exportOptions` | | The rest of the declared surface. (`className` is a base prop: it stays on the node, beside the bag.) | @@ -152,10 +153,12 @@ Written flat on the node, either key is refused by name and pointed at the bag refuses it. Until the row declared `description`, this page told you to write it on the node; that is now the refused spelling. -`keyboardNavigation`, the third key objectstack#20694 added to the row, is marked -`[EXPERIMENTAL — not enforced]` there, and nothing in this plugin reads it yet, -so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, -and it changes nothing. +`keyboardNavigation`, the third key objectstack#20694 added to the row, is +honoured too (objectui#11068) and is in `GRID_QUERY_INPUTS` — see +[Keyboard navigation](#keyboard-navigation). The row's own description may still +carry the `[EXPERIMENTAL — not enforced]` marker it was published with before this +build; the installed row (`ComponentPropsMap['object-grid']`) is the place to read +its current text. `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of @@ -686,6 +689,40 @@ through the host's data source (`dataSource.update`) with no callback to wire. To own persistence in a React host, supply `onCellChange` as a **component prop** — it is not a schema key. +### Keyboard navigation + +`keyboardNavigation` turns the grid's data cells into one roving Tab stop, on the +WAI-ARIA grid pattern (objectui#11068): + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "users", + "columns": ["name", "email", "status"], + "keyboardNavigation": true + } +} +``` + +- **One Tab stop.** Tab reaches the cells once — on the cell that last held + focus, or the first cell of the first row — and the next Tab moves past them. + A widget a cell renders (the record link, a row's action menu, a selection + checkbox) keeps its own Tab stop. +- **Arrow keys** move focus one cell; **Home** / **End** go to the first / last + cell of the row, and **Ctrl+Home** / **Ctrl+End** to the first / last cell of + the page. At an edge, focus stays put. +- **Editing.** On an editable grid, Enter still opens the focused cell, and an + open cell's editor keeps every key. An edit ended with Enter or Escape hands + focus back to its cell, so the arrows carry on from there. +- **Default.** On when the grid renders editable — the authored `editable` *and* + the viewer's permission to update the object, the same value inline editing + obeys. A read-only grid keeps every cell its own Tab stop unless you write + `true`, and `false` turns it off on an editable grid. +- While it is on, the table is exposed to assistive technology as a `grid`. A + grouped grid navigates within each group's table; the mobile card layout has + no cells and is unaffected. + ### Batch Editing & Multi-Row Save Edit multiple cells across multiple rows and save them individually or all at diff --git a/packages/components/src/__tests__/data-table-keyboard-navigation-11068.test.tsx b/packages/components/src/__tests__/data-table-keyboard-navigation-11068.test.tsx new file mode 100644 index 0000000000..787123b9c7 --- /dev/null +++ b/packages/components/src/__tests__/data-table-keyboard-navigation-11068.test.tsx @@ -0,0 +1,281 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `data-table`'s `keyboardNavigation` (objectui#11068) — arrow-key cell + * navigation on the WAI-ARIA grid pattern, the behaviour `ObjectGrid` relays + * the `object-grid` node's `keyboardNavigation` to. The relay and its default + * ("on when the grid renders editable") are pinned in `@object-ui/plugin-grid` + * (`ObjectGrid.keyboardNavigation-11068.test.tsx`); this file pins what the + * table does with the flag. + * + * 1. OFF (the default): today's table, byte for byte where it matters — + * every data cell its own Tab stop, no grid role, no cell address, and an + * arrow key left to the browser. + * 2. ON: the data cells are ONE roving Tab stop; Tab enters them once and the + * next Tab leaves; the arrow keys, Home / End and Ctrl+Home / Ctrl+End + * move focus; the stop follows focus and survives a shorter page. + * 3. ON, editable: Enter still opens the focused cell, the editor keeps the + * arrows, and an edit ended with Enter or Escape hands focus back to its + * cell, so the arrows carry on. + * + * Every "unchanged" reading has a lit control that moves on the same probe. + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, fireEvent, cleanup, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +// Registers `data-table` at module scope, not in a hook (objectui#3010). +import '../renderers'; + +afterEach(cleanup); + +const COLUMNS = [ + { header: 'Name', accessorKey: 'name' }, + { header: 'Status', accessorKey: 'status' }, + { header: 'Owner', accessorKey: 'owner' }, +]; + +const ROWS = [ + { id: '1', name: 'Alpha', status: 'Open', owner: 'Ada' }, + { id: '2', name: 'Beta', status: 'Closed', owner: 'Bo' }, + { id: '3', name: 'Gamma', status: 'Open', owner: 'Cy' }, +]; + +/** + * A table whose ONLY tabbable elements are its data cells — no search box, no + * pager, no selection — between two buttons, so a Tab sequence reads exactly + * the cells' share of it. + */ +function renderTable(extra: Record = {}, rows = ROWS) { + const DataTable = ComponentRegistry.get('data-table')!; + const schema = { + type: 'data-table', + columns: COLUMNS, + data: rows, + searchable: false, + pagination: false, + ...extra, + }; + const utils = render( +
+ + + +
, + ); + const rerenderTable = (nextExtra: Record, nextRows: typeof ROWS) => + utils.rerender( +
+ + + +
, + ); + return { ...utils, rerenderTable }; +} + +/** The data cell at (row, column) — by position, the way the arrows count. */ +const cellAt = (container: HTMLElement, row: number, col: number) => + container.querySelectorAll('tbody tr')[row].querySelectorAll('td')[col] as HTMLElement; + +/** Every body cell that is in the Tab sequence. */ +const tabStops = (container: HTMLElement) => + Array.from(container.querySelectorAll('tbody td')).filter((td) => td.tabIndex === 0); + +/** Press a key on whatever holds focus; `false` means the table took the key. */ +const press = (key: string, init: Record = {}) => + fireEvent.keyDown(document.activeElement as Element, { key, ...init }); + +/* ── 1. Off ──────────────────────────────────────────────────────────────── */ + +describe('data-table `keyboardNavigation` OFF — the default, unchanged (objectui#11068)', () => { + it('every data cell is its own Tab stop, the table is a plain table, and no cell carries an address', () => { + const { container } = renderTable(); + expect(tabStops(container)).toHaveLength(ROWS.length * COLUMNS.length); + expect(container.querySelector('table')).not.toHaveAttribute('role'); + expect(container.querySelector('[data-grid-cell]')).toBeNull(); + }); + + it('an arrow key on a focused cell is left to the browser: focus stays, nothing is prevented', () => { + const { container } = renderTable(); + cellAt(container, 0, 0).focus(); + expect(press('ArrowDown')).toBe(true); + expect(press('ArrowRight')).toBe(true); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + }); + + it('Tab walks every cell, one stop each', async () => { + const user = userEvent.setup(); + const { container, getByText } = renderTable(); + getByText('before').focus(); + await user.tab(); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + await user.tab(); + expect(document.activeElement).toBe(cellAt(container, 0, 1)); + }); +}); + +/* ── 2. On ───────────────────────────────────────────────────────────────── */ + +describe('data-table `keyboardNavigation` ON — one roving Tab stop the arrows move (objectui#11068)', () => { + it('the data cells are ONE Tab stop, on the first cell, and the table is a grid', () => { + const { container } = renderTable({ keyboardNavigation: true }); + expect(tabStops(container)).toEqual([cellAt(container, 0, 0)]); + expect(container.querySelector('table')).toHaveAttribute('role', 'grid'); + expect(cellAt(container, 1, 2)).toHaveAttribute('tabindex', '-1'); + }); + + it('Tab enters the cells once and the next Tab leaves the grid; Shift+Tab comes back to the same cell', async () => { + const user = userEvent.setup(); + const { container, getByText } = renderTable({ keyboardNavigation: true }); + getByText('before').focus(); + await user.tab(); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + await user.tab(); + expect(document.activeElement).toBe(getByText('after')); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + }); + + it('the arrow keys move focus one cell; at an edge focus stays and the key is still the grid\'s', () => { + const { container } = renderTable({ keyboardNavigation: true }); + cellAt(container, 0, 0).focus(); + + expect(press('ArrowRight')).toBe(false); + expect(document.activeElement).toBe(cellAt(container, 0, 1)); + expect(press('ArrowDown')).toBe(false); + expect(document.activeElement).toBe(cellAt(container, 1, 1)); + expect(press('ArrowLeft')).toBe(false); + expect(document.activeElement).toBe(cellAt(container, 1, 0)); + expect(press('ArrowUp')).toBe(false); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + + // Edges: no wrap, no escape, and no page scroll under a focused cell. + expect(press('ArrowUp')).toBe(false); + expect(press('ArrowLeft')).toBe(false); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + }); + + it('Home / End go to the ends of the row, Ctrl+Home / Ctrl+End to the ends of the page', () => { + const { container } = renderTable({ keyboardNavigation: true }); + cellAt(container, 1, 1).focus(); + + press('End'); + expect(document.activeElement).toBe(cellAt(container, 1, 2)); + press('Home'); + expect(document.activeElement).toBe(cellAt(container, 1, 0)); + press('End', { ctrlKey: true }); + expect(document.activeElement).toBe(cellAt(container, 2, 2)); + press('Home', { ctrlKey: true }); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + }); + + it('Shift, Alt and Meta combinations are left to the browser', () => { + const { container } = renderTable({ keyboardNavigation: true }); + cellAt(container, 0, 0).focus(); + expect(press('ArrowDown', { shiftKey: true })).toBe(true); + expect(press('ArrowRight', { altKey: true })).toBe(true); + expect(press('ArrowRight', { metaKey: true })).toBe(true); + expect(press('ArrowDown', { ctrlKey: true })).toBe(true); + expect(document.activeElement).toBe(cellAt(container, 0, 0)); + }); + + it('the Tab stop follows focus — an arrow, or a focus from anywhere else — and stays the only one', () => { + const { container } = renderTable({ keyboardNavigation: true }); + cellAt(container, 0, 0).focus(); + press('ArrowDown'); + expect(tabStops(container)).toEqual([cellAt(container, 1, 0)]); + + // A click (or any focus) on another cell moves the stop there. Inside + // `act`, so the state the focus wrote is flushed before it is read. + act(() => cellAt(container, 2, 1).focus()); + expect(document.activeElement).toBe(cellAt(container, 2, 1)); + expect(tabStops(container)).toEqual([cellAt(container, 2, 1)]); + }); + + it('a shorter page never leaves the grid without a Tab stop', () => { + const { container, rerenderTable } = renderTable({ keyboardNavigation: true }); + act(() => cellAt(container, 2, 2).focus()); + expect(tabStops(container)).toEqual([cellAt(container, 2, 2)]); + + rerenderTable({ keyboardNavigation: true }, ROWS.slice(0, 1)); + expect(tabStops(container)).toEqual([cellAt(container, 0, 2)]); + }); +}); + +/* ── 3. On, editable ─────────────────────────────────────────────────────── */ + +describe('data-table `keyboardNavigation` ON with `editable` — Enter edits, and focus comes back (objectui#11068)', () => { + const editableNav = { keyboardNavigation: true, editable: true }; + + it('Enter opens the focused cell, the editor keeps the arrows, and Enter hands focus back to the cell', async () => { + const { container } = renderTable(editableNav); + const cell = cellAt(container, 0, 1); + cell.focus(); + + press('Enter'); + const input = await waitFor(() => { + const el = cell.querySelector('input'); + expect(el).not.toBeNull(); + return el as HTMLInputElement; + }); + expect(document.activeElement).toBe(input); + + // The editor's own keys: the caret moves, the cell does not. + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + expect(cell.querySelector('input')).toBe(input); + + fireEvent.change(input, { target: { value: 'Pending' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(cell.querySelector('input')).toBeNull()); + expect(cell).toHaveTextContent('Pending'); + await waitFor(() => expect(document.activeElement).toBe(cell)); + + // …so the arrows carry on from the cell that was just edited. + press('ArrowDown'); + expect(document.activeElement).toBe(cellAt(container, 1, 1)); + }); + + it('Escape cancels the edit and hands focus back to the cell too', async () => { + const { container } = renderTable(editableNav); + const cell = cellAt(container, 2, 2); + cell.focus(); + press('Enter'); + const input = await waitFor(() => { + const el = cell.querySelector('input'); + expect(el).not.toBeNull(); + return el as HTMLInputElement; + }); + fireEvent.change(input, { target: { value: 'Nobody' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + await waitFor(() => expect(cell.querySelector('input')).toBeNull()); + expect(cell).toHaveTextContent('Cy'); + await waitFor(() => expect(document.activeElement).toBe(cell)); + press('ArrowLeft'); + expect(document.activeElement).toBe(cellAt(container, 2, 1)); + }); + + it('LIT CONTROL — with the flag off, the same Enter-commit leaves focus on , where no arrow reaches a cell', async () => { + const { container } = renderTable({ editable: true }); + const cell = cellAt(container, 0, 1); + cell.focus(); + press('Enter'); + const input = await waitFor(() => { + const el = cell.querySelector('input'); + expect(el).not.toBeNull(); + return el as HTMLInputElement; + }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(cell.querySelector('input')).toBeNull()); + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index a66e61b25e..61f41badf4 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -784,6 +784,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { reorderableColumns = true, editable = false, singleClickEdit = false, + keyboardNavigation = false, rowClassName, rowStyle, className, @@ -1900,7 +1901,114 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { setSaveError(null); }; - const handleCellKeyDown = (e: React.KeyboardEvent, rowIndex: number, columnKey: string) => { + // ── Keyboard navigation (objectui#11068) ────────────────────────────────── + // + // Arrow-key cell navigation on the WAI-ARIA grid pattern, behind + // `keyboardNavigation`. Off — the default, and every host but `ObjectGrid` + // leaves it off — nothing below changes the table: every data cell is its own + // Tab stop (`tabIndex={0}`), the markup carries no grid role, and the arrows + // do what the browser does with them. + // + // On, the table is a `grid` and its data cells are a ROVING tab stop: exactly + // one of them is in the Tab sequence (`tabIndex` 0, the rest -1), so Tab + // reaches the cells once instead of once per cell. That cell is the one that + // last held focus — a click, a Tab, an arrow — and the first cell of the first + // row until one has. Its position is clamped to the page on every render, so a + // shorter page, a filter or a removed column never leaves the grid with NO + // cell in the Tab sequence. + // + // Only the data cells rove. A widget a cell renders (a record link, a row's + // action menu, a selection checkbox) keeps its own Tab stop: it is the cell + // renderer's markup, not this table's, and taking it out of the sequence + // would leave it with no keyboard path at all. + const tableRef = useRef(null); + const [rovingCell, setRovingCell] = useState<{ rowIndex: number; colIndex: number }>({ rowIndex: 0, colIndex: 0 }); + const rovingRowIndex = Math.max(0, Math.min(rovingCell.rowIndex, paginatedData.length - 1)); + const rovingColIndex = Math.max(0, Math.min(rovingCell.colIndex, columns.length - 1)); + // A cell whose edit was just ended from the keyboard, to be focused once the + // editor has unmounted (the effect below). A ref, not state: it is consumed + // by the commit that follows, and must not cause a render of its own. + const pendingCellFocusRef = useRef<{ rowIndex: number; colIndex: number } | null>(null); + + const rove = (rowIndex: number, colIndex: number) => { + setRovingCell((prev) => + prev.rowIndex === rowIndex && prev.colIndex === colIndex ? prev : { rowIndex, colIndex }); + }; + + const focusGridCell = (rowIndex: number, colIndex: number) => { + const cell = tableRef.current?.querySelector(`[data-grid-cell="${rowIndex}:${colIndex}"]`); + if (!cell) return; + rove(rowIndex, colIndex); + cell.focus(); + }; + + // The edit's editor is gone once this runs, so focus has nowhere to land but + // `` — hand it back to the cell, where the arrows carry on. Only an edit + // ended by a key that bubbled through its cell arms this (see + // `handleCellKeyDown`): one committed by a pointer press elsewhere leaves + // focus where that press put it. + useEffect(() => { + const target = pendingCellFocusRef.current; + if (!target || editingCell) return; + pendingCellFocusRef.current = null; + if (!keyboardNavigation) return; + focusGridCell(target.rowIndex, target.colIndex); + }); + + /** + * The cell an arrow / Home / End press moves to, or `null` when the key is + * not a navigation key. At an edge the answer is the cell itself: the key is + * still the grid's (the page must not scroll under a focused cell), and focus + * stays put, as the pattern asks. + */ + const navigationTarget = ( + e: React.KeyboardEvent, + rowIndex: number, + colIndex: number, + ): { rowIndex: number; colIndex: number } | null => { + // Shift / Alt / Meta combinations belong to the browser and the OS (text + // selection, history, app shortcuts); Ctrl only qualifies Home / End. + if (e.shiftKey || e.altKey || e.metaKey) return null; + const lastRow = paginatedData.length - 1; + const lastCol = columns.length - 1; + if (e.ctrlKey) { + if (e.key === 'Home') return { rowIndex: 0, colIndex: 0 }; + if (e.key === 'End') return { rowIndex: lastRow, colIndex: lastCol }; + return null; + } + switch (e.key) { + case 'ArrowUp': return { rowIndex: Math.max(0, rowIndex - 1), colIndex }; + case 'ArrowDown': return { rowIndex: Math.min(lastRow, rowIndex + 1), colIndex }; + case 'ArrowLeft': return { rowIndex, colIndex: Math.max(0, colIndex - 1) }; + case 'ArrowRight': return { rowIndex, colIndex: Math.min(lastCol, colIndex + 1) }; + case 'Home': return { rowIndex, colIndex: 0 }; + case 'End': return { rowIndex, colIndex: lastCol }; + default: return null; + } + }; + + const handleCellKeyDown = (e: React.KeyboardEvent, rowIndex: number, colIndex: number, columnKey: string) => { + if (keyboardNavigation) { + // An edit in this cell that the key now bubbling through it just ended + // (Enter committed it, Escape cancelled it): `editingCellRef` is cleared + // synchronously by the editor's own handler, while `editingCell` is still + // this render's value. Focus goes back to the cell after the commit. + if (editingCell && editingCellRef.current === null && (e.key === 'Enter' || e.key === 'Escape')) { + pendingCellFocusRef.current = { rowIndex, colIndex }; + return; + } + // The cell itself, not a widget inside it: a link, a picker or an editor + // keeps the keys it handles. + if (!editingCell && e.target === e.currentTarget) { + const next = navigationTarget(e, rowIndex, colIndex); + if (next) { + e.preventDefault(); + focusGridCell(next.rowIndex, next.colIndex); + return; + } + } + } + // Copy cell value with Ctrl+C / Cmd+C if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !editingCell) { e.preventDefault(); @@ -2118,7 +2226,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { wrapper must NOT create a second, height-unbounded scroll context; otherwise the horizontal scrollbar drops to the bottom of all rows and is only reachable after scrolling to the last row. */} - +
{caption && {caption}} @@ -2576,8 +2691,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { startEdit(rowIndex, col.accessorKey); } }} - onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)} - tabIndex={0} + onKeyDown={(e) => handleCellKeyDown(e, rowIndex, colIndex, col.accessorKey)} + // objectui#11068 — one roving Tab stop across the + // data cells when `keyboardNavigation` is on (see + // `handleCellKeyDown`); every cell its own stop when + // it is off, exactly as before. The address and the + // focus tracking exist only in the first case. + tabIndex={keyboardNavigation ? (rowIndex === rovingRowIndex && colIndex === rovingColIndex ? 0 : -1) : 0} + data-grid-cell={keyboardNavigation ? `${rowIndex}:${colIndex}` : undefined} + onFocus={keyboardNavigation ? () => rove(rowIndex, colIndex) : undefined} > {isEditing ? ( (() => { diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 21ff6cd8e9..65ab0d9dc1 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -209,6 +209,7 @@ const grid: ObjectGridBlockNode = { | `selection` | `SelectionConfig` | `{ type: 'none' \| 'single' \| 'multiple' }`. | | `rowActions` / `bulkActions` | `string[]` | **Names** of actions, not definitions. | | `editable` / `singleClickEdit` | `boolean` | Inline editing — see [Inline Editing](#inline-editing). | +| `keyboardNavigation` | `boolean` | Arrow keys move focus between cells, and the cells are one Tab stop — see [Keyboard navigation](#keyboard-navigation). On by default when the grid renders editable. | | `navigation` | `NavigationConfig` | What a row click does, `{ mode: 'page' \| 'drawer' \| 'modal' \| 'split' \| 'none', … }`. | | `operations` | `object` | Toggles the built-in CRUD/export/import affordances, e.g. `{ delete: false }`. | | `rowHeight`, `frozenColumns`, `resizable`, `reorderableColumns`, `showColumnTypeIcons`, `rowColor`, `conditionalFormatting`, `aggregations`, `exportOptions` | | The rest of the declared surface. (`className` is a base prop: it stays on the node, beside the bag.) | @@ -258,10 +259,12 @@ Written flat on the node, either key is refused by name and pointed at the bag refuses it. Until the row declared `description`, this page told you to write it on the node; that is now the refused spelling. -`keyboardNavigation`, the third key objectstack#20694 added to the row, is marked -`[EXPERIMENTAL — not enforced]` there, and nothing in this package reads it yet, -so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, -and it changes nothing. +`keyboardNavigation`, the third key objectstack#20694 added to the row, is +honoured too (objectui#11068) and is in `GRID_QUERY_INPUTS` — see +[Keyboard navigation](#keyboard-navigation). The row's own description may still +carry the `[EXPERIMENTAL — not enforced]` marker it was published with before this +build; the installed row (`ComponentPropsMap['object-grid']`) is the place to read +its current text. `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of @@ -861,6 +864,40 @@ the host's data source (`dataSource.update`) with no callback to wire. - Spreadsheet-like editing experience - Real-time updates with backend synchronization +### Keyboard navigation + +`keyboardNavigation` turns the grid's data cells into one roving Tab stop, on the +WAI-ARIA grid pattern (objectui#11068): + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "users", + "columns": ["name", "email", "status"], + "keyboardNavigation": true + } +} +``` + +- **One Tab stop.** Tab reaches the cells once — on the cell that last held + focus, or the first cell of the first row — and the next Tab moves past them. + A widget a cell renders (the record link, a row's action menu, a selection + checkbox) keeps its own Tab stop. +- **Arrow keys** move focus one cell; **Home** / **End** go to the first / last + cell of the row, and **Ctrl+Home** / **Ctrl+End** to the first / last cell of + the page. At an edge, focus stays put. +- **Editing.** On an editable grid, Enter still opens the focused cell, and an + open cell's editor keeps every key. An edit ended with Enter or Escape hands + focus back to its cell, so the arrows carry on from there. +- **Default.** On when the grid renders editable — the authored `editable` *and* + the viewer's permission to update the object, the same value inline editing + obeys. A read-only grid keeps every cell its own Tab stop unless you write + `true`, and `false` turns it off on an editable grid. +- While it is on, the table is exposed to assistive technology as a `grid`. A + grouped grid navigates within each group's table; the mobile card layout has + no cells and is unaffected. + ### Batch Editing & Multi-Row Save Edit multiple cells across multiple rows and save them individually or all at once: diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index d0327129fe..ba53274d75 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -5437,6 +5437,16 @@ export const ObjectGrid: React.FC = ({ } : undefined, singleClickEdit: schema.singleClickEdit ?? true, + // objectui#11068 — arrow-key cell navigation on the WAI-ARIA grid pattern: + // the data cells become ONE roving Tab stop that the arrows move (the + // behaviour is `data-table`'s, documented on `DataTableSchema`). The + // declared default is "on when `editable`", and the `editable` that counts + // is the one this grid renders — `inlineEditable`, the authored key AND the + // viewer's write verdict (#5143), the same value handed down above. So a + // grid that renders read-only keeps every cell its own Tab stop unless the + // author writes `true`, and an explicit `false` turns it off on an editable + // grid. Not an alias fallback: the right side is the key's own default. + keyboardNavigation: schema.keyboardNavigation ?? inlineEditable, className: schema.className, cellClassName: rowHeightMode === 'compact' ? 'px-3 py-1 text-[13px] leading-tight' diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.keyboardNavigation-11068.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.keyboardNavigation-11068.test.tsx new file mode 100644 index 0000000000..caef17519c --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.keyboardNavigation-11068.test.tsx @@ -0,0 +1,197 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `object-grid`'s `keyboardNavigation` (objectui#11068) — the key the spec's + * `object-grid` row declares since `@objectstack/spec` 17.6.0, now READ: + * `ObjectGrid` resolves it and relays the answer to the `data-table` it builds, + * which moves a roving focus across its cells with the arrow keys + * (`data-table-keyboard-navigation-11068.test.tsx` in `@object-ui/components` + * pins that behaviour). This file pins the RELAY and the DEFAULT, through the + * real registration: + * + * 1. a read-only grid keeps today's Tab behaviour (every cell its own stop) + * unless `keyboardNavigation: true` is authored; + * 2. an editable grid has it ON by default — one Tab stop, arrows move it — + * and `keyboardNavigation: false` turns it off; + * 3. the `editable` the default follows is the one the grid RENDERS: an + * `editable: true` grid shown to a viewer with no update grant is + * read-only, so it keeps its Tab behaviour too; + * 4. the authored document (`{ type, properties }`) reaches the same reads. + * + * Each "off" reading is paired with an "on" reading on the same probe. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +// The viewer's update grant, switchable per test. Stable stub identity: +// `ObjectGrid` keeps `perms` in memo dependency arrays. +const { permsStub, grant } = vi.hoisted(() => { + const grant = { update: true }; + return { + grant, + permsStub: { + isLoaded: false, + checkField: () => true, + getObjectApiOperations: () => undefined, + can: (_obj: string, action: string) => (action === 'update' ? grant.update : true), + }, + }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => permsStub }; +}); + +import { ActionProvider, SchemaRenderer } from '@object-ui/react'; +// Registers `object-grid`, the block under test. +import { ObjectGridRenderer } from '../index'; + +afterEach(() => { + cleanup(); + grant.update = true; +}); + +const ROWS = [ + { id: '1', name: 'Alpha', status: 'Open' }, + { id: '2', name: 'Beta', status: 'Closed' }, + { id: '3', name: 'Gamma', status: 'Open' }, +]; +const DATA_COLUMNS = 2; + +const grid = (extra: Record = {}) => ({ + type: 'object-grid', + objectName: 'probe', + columns: ['name', 'status'], + data: { provider: 'value', items: ROWS }, + ...extra, +}); + +async function renderGrid(extra: Record = {}) { + const utils = render(); + await screen.findByText('Gamma', {}, { timeout: 5000 }); + return utils; +} + +/** The body cells the table renders for the data columns — the ones that take a `tabindex`. */ +const dataCells = (container: HTMLElement) => + Array.from(container.querySelectorAll('tbody td[tabindex]')); + +/** The data cell at (row, data column). */ +const cellAt = (container: HTMLElement, row: number, col: number) => + container.querySelectorAll('tbody tr')[row].querySelectorAll('td[tabindex]')[col]; + +/** What the arrows and the Tab sequence see, in one reading. */ +function reading(container: HTMLElement) { + const cells = dataCells(container); + cellAt(container, 0, 0).focus(); + const arrowTaken = !fireEvent.keyDown(document.activeElement as Element, { key: 'ArrowDown' }); + return { + cells: cells.length, + tabStops: cells.filter((td) => td.tabIndex === 0).length, + role: container.querySelector('table')?.getAttribute('role') ?? null, + arrowTaken, + focusAfterArrowDown: document.activeElement === cellAt(container, 1, 0) ? 'next row' : 'stayed', + }; +} + +const OFF = { cells: ROWS.length * DATA_COLUMNS, tabStops: ROWS.length * DATA_COLUMNS, role: null, arrowTaken: false, focusAfterArrowDown: 'stayed' }; +const ON = { cells: ROWS.length * DATA_COLUMNS, tabStops: 1, role: 'grid', arrowTaken: true, focusAfterArrowDown: 'next row' }; + +/* ── 1. Read-only ────────────────────────────────────────────────────────── */ + +describe('a read-only `object-grid` keeps its Tab behaviour unless `keyboardNavigation: true` (objectui#11068)', () => { + it('no `editable`, no key: every data cell is its own Tab stop and the arrows are the browser\'s', async () => { + const { container } = await renderGrid(); + expect(reading(container)).toEqual(OFF); + }); + + it('`keyboardNavigation: true` turns it on for a read-only grid', async () => { + const { container } = await renderGrid({ keyboardNavigation: true }); + expect(reading(container)).toEqual(ON); + }); +}); + +/* ── 2. Editable ─────────────────────────────────────────────────────────── */ + +describe('an editable `object-grid` has it on by default; `false` turns it off (objectui#11068)', () => { + it('`editable: true`, no key: one roving Tab stop, and ArrowDown moves it', async () => { + const { container } = await renderGrid({ editable: true }); + expect(reading(container)).toEqual(ON); + }); + + it('`editable: true` with `keyboardNavigation: false`: every cell its own stop again', async () => { + const { container } = await renderGrid({ editable: true, keyboardNavigation: false }); + expect(reading(container)).toEqual(OFF); + }); + + it('Enter still opens the focused cell, and Enter hands focus back to it', async () => { + const { container } = await renderGrid({ editable: true }); + const cell = cellAt(container, 1, 0); + cell.focus(); + fireEvent.keyDown(cell, { key: 'Enter' }); + const input = await waitFor(() => { + const el = cell.querySelector('input'); + expect(el).not.toBeNull(); + return el as HTMLInputElement; + }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(cell.querySelector('input')).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(cell)); + }); +}); + +/* ── 3. The `editable` that counts is the one the grid renders ───────────── */ + +describe('the default follows the editable the grid RENDERS, not the authored key alone (objectui#11068)', () => { + it('`editable: true` for a viewer with no update grant renders read-only, so it stays off', async () => { + grant.update = false; + const { container } = await renderGrid({ editable: true }); + // The grid really is read-only for this viewer: Enter opens nothing. + const cell = cellAt(container, 0, 0); + cell.focus(); + fireEvent.keyDown(cell, { key: 'Enter' }); + expect(cell.querySelector('input')).toBeNull(); + expect(reading(container)).toEqual(OFF); + }); + + it('LIT CONTROL — the same viewer with an explicit `keyboardNavigation: true` gets it', async () => { + grant.update = false; + const { container } = await renderGrid({ editable: true, keyboardNavigation: true }); + expect(reading(container)).toEqual(ON); + }); +}); + +/* ── 4. The authored document ────────────────────────────────────────────── */ + +describe('the authored `{ type, properties }` document reaches the same reads (objectui#11068)', () => { + const page = (properties: Record) => ( + + + + ); + + it('`properties.editable: true` turns it on, and `properties.keyboardNavigation: false` turns it off', async () => { + const on = render(page({ editable: true })); + await screen.findByText('Gamma', {}, { timeout: 5000 }); + expect(reading(on.container)).toEqual(ON); + cleanup(); + + const off = render(page({ editable: true, keyboardNavigation: false })); + await screen.findByText('Gamma', {}, { timeout: 5000 }); + expect(reading(off.container)).toEqual(OFF); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 2297da709a..483f3d0812 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -212,16 +212,18 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = * objectui#5861 removed every renderer read of it (ADR-0049 enforce-or-remove). * It stays off this list because the contract refuses it, not by exemption. * - * ## `description`, `emptyState`, and the one 17.6.0 key that is not here + * ## `description`, `emptyState` and `keyboardNavigation`: the 17.6.0 keys * * `@objectstack/spec` 17.6.0 adds three keys to the `object-grid` row * (objectstack#20694). `description` and `emptyState` are published below * (objectui#11227): `ObjectGrid` has read both since objectui#11068, and until - * the row declared them this list could not. `keyboardNavigation` is NOT here. - * The row marks it `[EXPERIMENTAL — not enforced]`, nothing in this repo reads it - * yet, and this list is what the renderer reads: the key joins it with its - * reader (objectui#11068's build). Until then the console parity gate's reverse - * direction reports it as the one unpublished `object-grid` key. + * the row declared them this list could not. `keyboardNavigation` joined the + * list WITH its reader, objectui#11068's build: `ObjectGrid` resolves it + * (default: on when the grid renders editable) and `data-table` moves a roving + * focus across its cells with the arrow keys. It was held off this list until + * then, because this list is what the renderer reads, not what the row allows. + * The row still marks it `[EXPERIMENTAL — not enforced]`; that marker is the + * spec's to drop, and no reader here depends on it. * * ## `data` declares the CONTRACT's shape, not the shortcut's (objectui#5090) * @@ -307,6 +309,11 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ // ── behaviour ───────────────────────────────────────────────────────────── { name: 'editable', type: 'boolean', description: 'Enable inline cell editing (double-click or Enter opens a cell).' }, { name: 'singleClickEdit', type: 'boolean', description: 'With `editable`, a single click opens the cell instead of a double-click. Has no effect on a non-editable grid.' }, + // `keyboardNavigation` (objectui#11068's build; the row declares it since + // 17.6.0). The behaviour is `data-table`'s and is pinned there + // (`data-table-keyboard-navigation-11068.test.tsx`); the default and the + // relay are `ObjectGrid`'s (`ObjectGrid.keyboardNavigation-11068.test.tsx`). + { name: 'keyboardNavigation', type: 'boolean', description: 'Arrow-key cell navigation on the WAI-ARIA grid pattern: the grid\'s data cells take one place in the Tab order instead of one each (a link or button inside a cell keeps its own), and the arrow keys move focus between them (Home / End to the ends of the row, Ctrl+Home / Ctrl+End to the ends of the page). Enter still opens an editable cell, and an edit ended with Enter or Escape returns focus to its cell. Defaults to on when the grid renders editable; a read-only grid keeps every cell its own Tab stop unless this is `true`, and `false` turns it off on an editable grid.' }, { name: 'navigation', type: 'object', description: 'What a row click does, `{ mode: "page" | "drawer" | "modal" | "split" | "none", … }`.' }, { name: 'operations', type: 'object', description: 'Toggles for the built-in create/read/update/delete/export/import affordances, e.g. `{ delete: false }`.' }, { name: 'exportOptions', type: 'object', description: 'Export config, `{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`. `streaming` (default true) picks server-side streaming vs browser-side assembly for the export — a behaviour fork, not decoration; set it to `false` to force browser-side assembly. Needs `operations.export` to be reachable from the toolbar.' }, diff --git a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts index fe98f904bf..142d71274a 100644 --- a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts +++ b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts @@ -129,16 +129,17 @@ const FORM_IDENTITY_KEYS = ['type', 'objectName', 'mode'] as const; */ const TABLE_WITHHELD_BY_REASON = { /** - * `ObjectGrid` has no read of it, so nothing could draw it. Five of these are - * retirement tombstones on `ObjectGridSchema` itself since objectui#11068 + * `ObjectGrid` has no read of it, so nothing could draw it. Each is a + * retirement tombstone on `ObjectGridSchema` itself since objectui#11068 * (`bulkSpecActions`, `name`, `placeholder`, `rowSpecActions`, `showFilters`). */ - unread: ['bulkSpecActions', 'keyboardNavigation', 'name', 'placeholder', 'rowSpecActions', 'showFilters'], + unread: ['bulkSpecActions', 'name', 'placeholder', 'rowSpecActions', 'showFilters'], /** * `ObjectGrid` honours it on its own node since objectui#11068, and the view - * does not hand it on: that card enforced both without widening this slot. + * does not hand it on: that card enforced these without widening this slot — + * `description` and `emptyState` first, `keyboardNavigation` with its build. */ - notRelayed: ['description', 'emptyState'], + notRelayed: ['description', 'emptyState', 'keyboardNavigation'], /** The view owns it: its own record source, its own row click, its grid's identity. */ viewOwned: ['bind', 'data', 'dataSource', 'id', 'navigation', 'onNavigate', 'staticData'], /** diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 962d14b386..960d91219c 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -516,7 +516,14 @@ * seeded long after the 121). It is ⛔ not replaced with a fresh digit, for the * reason above. The full statement is on that ledger, which owns it — read it * there, and ⛔ do not copy it back. - * - **5 entries** in `RuntimeOnlyDeclared`, **30 keys** across them — 4 / 29 + * - **5 entries** in `RuntimeOnlyDeclared`, **31 keys** across them — 5 / 30 + * until objectui#11068's `keyboardNavigation` build filed `DataTableSchema`'s + * `keyboardNavigation` here, on the entry that already existed, BY NAME with its + * reason on `RuntimeOnlyNamedAllowList`: the flag `ObjectGrid` resolves from the + * `object-grid` node's own key and sets in code on the `data-table` it builds. + * ⚠️ NOT the other side of a `UnmirroredDeclared` move: the key was declared on + * neither face of `DataTableSchema` until then, and the TS face now declares it + * while the mirror deliberately does not. The entry count held; it was 4 / 29 * until objectui#11355 round 2 filed `objectql.zod.ts#ObjectChartSchema`'s * `isAnimationActive` here: a NEW entry, its one key a host-composed render flag * (not callback-shaped), admitted BY NAME through `RuntimeOnlyNamedAllowList` with @@ -3486,12 +3493,20 @@ interface RuntimeOnlyDeclared { * reason on `RuntimeOnlyNamedAllowList` below. Both came from `UnmirroredDeclared` * above, emptying its `DataTableSchema` entry together with `selectionStyle`'s * retirement. ⛔ Neither was mirrored and no declaration moved. + * + * ⭐ GREW by one more with objectui#11068's `keyboardNavigation` build: + * `keyboardNavigation`, DECLARED on the TypeScript interface in the same change + * (it is new there, not reclassified), because `ObjectGrid` resolves the + * `object-grid` node's own key and sets the answer in code on the `data-table` + * node it builds, beside `editable`, whose value its default follows. Filed BY + * NAME with its reason on `RuntimeOnlyNamedAllowList` below; ⛔ not mirrored, + * since no document authors it on a `data-table` node. */ 'data-display.zod.ts#DataTableSchema': | 'onColumnReorder' | 'onPageChange' | 'onPageSizeChange' | 'onSearchChange' | 'onSortChange' - | 'disableInnerScroll' | 'editable' | 'manualPagination' | 'manualSearch' | 'manualSorting' + | 'disableInnerScroll' | 'editable' | 'keyboardNavigation' | 'manualPagination' | 'manualSearch' | 'manualSorting' | 'page' | 'rowActionDefs' | 'rowClassName' | 'rowCount' | 'rowStyle' | 'search' | 'selectionResetKey' | 'showAddRow' | 'showSelectionCount' | 'singleClickEdit' | 'sort'; /** @@ -3715,6 +3730,7 @@ interface RuntimeOnlyNamedAllowList { 'data-display.zod.ts#DataTableSchema': { disableInnerScroll: 'host-composition flag: set in code by ObjectGrid on each grouped sub-table so all groups share one scroll container; authored in no document'; editable: 'host-paired flag: set in code by ObjectGrid beside the onRowSave / onBatchSave save path it supplies; an authored value stages edits that nothing persists, and no document authors it'; + keyboardNavigation: 'host-resolved flag: set in code by ObjectGrid from the keyboardNavigation key of the object-grid node, defaulting to the editable it hands down beside it; authored in no document'; manualPagination: 'host-driven server paging: set in code by ObjectGrid with rowCount, page and the onPageChange slot; authored in no document'; manualSearch: 'host-driven server search: set in code by ObjectGrid with search and the onSearchChange slot; authored in no document'; manualSorting: 'host-driven server sort: set in code by ObjectGrid and RelatedList with sort and the onSortChange slot; authored in no document'; diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index ffc166359c..246990116f 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1461,6 +1461,28 @@ export interface DataTableSchema extends BaseSchema { * @default false */ singleClickEdit?: boolean; + /** + * Arrow-key cell navigation on the WAI-ARIA grid pattern (objectui#11068). + * + * When `true` the table is exposed as a `grid` and its data cells take ONE + * place in the Tab sequence between them — a roving tab stop that starts on + * the first cell and stays on the cell that last held focus — instead of one + * stop per cell. Arrow keys move focus one cell; Home / End go to the first / + * last cell of the row, and Ctrl+Home / Ctrl+End to the first / last cell of + * the page. A cell being edited keeps every key for its editor, and an edit + * ended from the keyboard (Enter / Escape) hands focus back to its cell, so + * the arrows carry on from there. A widget a cell renders (a link, a button) + * keeps its own Tab stop. + * + * SET IN CODE, not authored on this node (the class of + * {@link DataTableSchema.editable}): `ObjectGrid` resolves the `object-grid` + * node's own `keyboardNavigation` — the key the spec's `object-grid` row + * declares, defaulting to on when the grid is inline-editable — and hands the + * answer to the table it builds. Absent here means off: every data cell stays + * its own Tab stop, as it always was. + * @default false + */ + keyboardNavigation?: boolean; /** * Host-supplied cell editor for inline editing (`bf97b98c8`). * diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 37bd4fa5b6..e08318d8b6 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1290,10 +1290,24 @@ export interface ObjectGridSchema extends BaseSchema { rowColor?: RowColorConfig; /** - * Enable keyboard navigation (Grid mode) - * Arrow keys, Tab, Enter for cell navigation - * NOTE: This is ObjectUI-specific and not part of @objectstack/spec - * @default true when editable is true + * Arrow-key cell navigation on the WAI-ARIA grid pattern (objectui#11068). + * A member of the spec's `object-grid` row (`ComponentPropsMap['object-grid']`) + * since `@objectstack/spec` 17.6.0 (objectstack#20694). + * + * On, the grid's data cells are ONE Tab stop between them, a roving focus: + * arrow keys move it one cell, Home / End to the ends of the row, Ctrl+Home / + * Ctrl+End to the ends of the page, Enter still opens an editable cell, and an + * edit ended with Enter or Escape hands focus back to its cell. Off, every data + * cell is its own Tab stop, as it always was. A widget a cell renders (the + * record link, a row's action menu, a selection checkbox) keeps its own stop + * either way. + * + * `ObjectGrid` reads it as `schema.keyboardNavigation ?? inlineEditable`, where + * `inlineEditable` is the authored `editable` AND the viewer's write verdict on + * the object — the one value the grid's inline editing itself obeys. So a grid + * that renders editable has it on, a grid that renders read-only keeps its Tab + * behaviour unless this is `true`, and `false` turns it off on an editable grid. + * @default true when the grid renders editable */ keyboardNavigation?: boolean; @@ -2245,11 +2259,13 @@ export interface ObjectFormSchema extends BaseSchema { * below — the slot refuses each of them by name either way. * * ⛔ Every other `ObjectGridSchema` member is WITHHELD, because on the view's - * grid it reached nothing: `ObjectGrid` has no read of it - * (`keyboardNavigation`, and the five tombstones just named); `ObjectGrid` - * reads it on its own node but the view does not hand it on (`emptyState`, - * `description` — honoured by the grid since objectui#11068, and kept off - * this slot by that card's ruling, which enforced them without widening it); + * grid it reached nothing: `ObjectGrid` has no read of it (the five + * tombstones just named); `ObjectGrid` reads it on its own node but the view + * does not hand it on (`emptyState`, `description` — honoured by the grid + * since objectui#11068, and kept off this slot by that card's ruling, which + * enforced them without widening it — and `keyboardNavigation`, read since + * that card's build; a view's grid takes the key's default, on exactly when + * it renders editable); * the view owns it (the record source * `data` / `staticData` / `bind`, and since objectui#11070 the binding * `dataSource`; the row click `navigation` / `onNavigate`; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 360b2e65b1..3990a99631 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1053,7 +1053,8 @@ const tableKeyRefusal = (key: string, why: string) => ); const TABLE_KEY_UNREAD = '`ObjectGrid` has no read of it.'; // objectui#11068 — `ObjectGrid` honours these on its own node, and the view does -// not hand them on: that card enforced them without widening this slot. +// not hand them on: that card enforced them without widening this slot +// (`description` and `emptyState` first, `keyboardNavigation` with its build). const TABLE_KEY_NOT_RELAYED = '`ObjectGrid` honours it on an `object-grid` node, but the view does not hand it to the grid it draws.'; const TABLE_KEY_RECORD_SOURCE = @@ -1081,7 +1082,7 @@ const OBJECT_VIEW_TABLE_WITHHELD = { hidden: tableKeyRefusal('hidden', TABLE_KEY_NODE_LEVEL), hiddenOn: tableKeyRefusal('hiddenOn', TABLE_KEY_NODE_LEVEL), id: tableKeyRefusal('id', 'the view fixes its grid\'s identity, as it fixes `type` and `objectName`.'), - keyboardNavigation: tableKeyRefusal('keyboardNavigation', TABLE_KEY_UNREAD), + keyboardNavigation: tableKeyRefusal('keyboardNavigation', TABLE_KEY_NOT_RELAYED), name: tableKeyRefusal('name', TABLE_KEY_UNREAD), navigation: tableKeyRefusal('navigation', TABLE_KEY_ROW_CLICK), onNavigate: tableKeyRefusal('onNavigate', TABLE_KEY_ROW_CLICK),