@ankhorage/zora 25.8.12 → 25.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/features/grid-interactions/adapters/inbound/GridInteractions.d.ts.map +1 -1
- package/dist/features/grid-interactions/adapters/inbound/GridInteractions.js +1 -1
- package/dist/features/grid-interactions/adapters/inbound/GridInteractions.js.map +1 -1
- package/dist/features/grid-interactions/adapters/inbound/GridInteractionsKeyboardProxy.web.d.ts.map +1 -1
- package/dist/features/grid-interactions/adapters/inbound/GridInteractionsKeyboardProxy.web.js +9 -2
- package/dist/features/grid-interactions/adapters/inbound/GridInteractionsKeyboardProxy.web.js.map +1 -1
- package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.d.ts.map +1 -1
- package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.js +11 -8
- package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.js.map +1 -1
- package/dist/features/matrix-grid/adapters/inbound/useMatrixGridKeyboardFocus.d.ts +1 -1
- package/dist/features/matrix-grid/adapters/inbound/useMatrixGridKeyboardFocus.d.ts.map +1 -1
- package/dist/features/matrix-grid/adapters/inbound/useMatrixGridKeyboardFocus.js +25 -5
- package/dist/features/matrix-grid/adapters/inbound/useMatrixGridKeyboardFocus.js.map +1 -1
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.d.ts +1 -1
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.d.ts.map +1 -1
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.js +5 -2
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.js.map +1 -1
- package/dist/types/matrix-grid.d.ts +2 -0
- package/dist/types/matrix-grid.d.ts.map +1 -1
- package/dist/types/matrix-grid.js.map +1 -1
- package/package.json +2 -2
- package/src/features/grid-interactions/adapters/inbound/GridInteractions.test.tsx +6 -1
- package/src/features/grid-interactions/adapters/inbound/GridInteractions.tsx +0 -1
- package/src/features/grid-interactions/adapters/inbound/GridInteractionsKeyboardProxy.web.tsx +10 -1
- package/src/features/matrix-grid/adapters/inbound/MatrixGrid.protected-focus.test.tsx +252 -0
- package/src/features/matrix-grid/adapters/inbound/MatrixGrid.test.tsx +79 -0
- package/src/features/matrix-grid/adapters/inbound/MatrixGrid.tsx +16 -5
- package/src/features/matrix-grid/adapters/inbound/useMatrixGridKeyboardFocus.ts +31 -4
- package/src/features/matrix-grid/application/resolveMatrixGridSelection.test.ts +20 -13
- package/src/features/matrix-grid/application/resolveMatrixGridSelection.ts +3 -0
- package/src/types/matrix-grid.ts +2 -0
- package/web-dist/components/grid-interactions/GridInteractions.js +9 -2
- package/web-dist/components/matrix-grid/MatrixGrid.js +41 -16
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { expect, test } from 'bun:test';
|
|
5
|
+
import { Window } from 'happy-dom';
|
|
6
|
+
import React, { act } from 'react';
|
|
7
|
+
import { createRoot } from 'react-dom/client';
|
|
8
|
+
|
|
9
|
+
const browserKeys = [
|
|
10
|
+
'window',
|
|
11
|
+
'document',
|
|
12
|
+
'Node',
|
|
13
|
+
'navigator',
|
|
14
|
+
'ShadowRoot',
|
|
15
|
+
'IS_REACT_ACT_ENVIRONMENT',
|
|
16
|
+
] as const;
|
|
17
|
+
const webDistRoot = join(import.meta.dir, '../../../../../web-dist');
|
|
18
|
+
const load = (path: string) => import(pathToFileURL(join(webDistRoot, path)).href);
|
|
19
|
+
|
|
20
|
+
interface MatrixCell {
|
|
21
|
+
readonly columnId: string;
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly rowId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MatrixLayout {
|
|
27
|
+
readonly columns: readonly { readonly id: string; readonly size: number }[];
|
|
28
|
+
readonly rows: readonly { readonly id: string; readonly size: number }[];
|
|
29
|
+
}
|
|
30
|
+
type MatrixGridComponent = React.ComponentType<{
|
|
31
|
+
readonly cells: readonly MatrixCell[];
|
|
32
|
+
readonly height: number;
|
|
33
|
+
readonly layout: MatrixLayout;
|
|
34
|
+
readonly protectedCellIds?: readonly string[];
|
|
35
|
+
readonly renderCell: (cell: MatrixCell) => React.ReactNode;
|
|
36
|
+
readonly testID: string;
|
|
37
|
+
readonly width: number;
|
|
38
|
+
}>;
|
|
39
|
+
type ZoraProviderComponent = React.ComponentType<{ readonly children: React.ReactNode }>;
|
|
40
|
+
|
|
41
|
+
/*** Mount MatrixGrid through React Native Web and restore the ambient DOM after each assertion. */
|
|
42
|
+
async function withMatrixDom(assertion: (host: HTMLElement, browser: Window) => Promise<void>) {
|
|
43
|
+
const browser = new Window();
|
|
44
|
+
const previous = browserKeys.map(
|
|
45
|
+
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
|
46
|
+
);
|
|
47
|
+
Object.assign(globalThis, {
|
|
48
|
+
IS_REACT_ACT_ENVIRONMENT: true,
|
|
49
|
+
window: browser,
|
|
50
|
+
document: browser.document,
|
|
51
|
+
Node: browser.Node,
|
|
52
|
+
navigator: browser.navigator,
|
|
53
|
+
ShadowRoot: browser.ShadowRoot,
|
|
54
|
+
});
|
|
55
|
+
const host = document.createElement('div');
|
|
56
|
+
document.body.appendChild(host);
|
|
57
|
+
try {
|
|
58
|
+
await assertion(host, browser);
|
|
59
|
+
} finally {
|
|
60
|
+
browser.close();
|
|
61
|
+
for (const [key, descriptor] of previous) {
|
|
62
|
+
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
63
|
+
else Reflect.deleteProperty(globalThis, key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/*** Load the compiled MatrixGrid and provider used by React Native Web interaction tests. */
|
|
69
|
+
async function loadMatrixGrid() {
|
|
70
|
+
const { MatrixGrid } = (await load('components/matrix-grid/MatrixGrid.js')) as {
|
|
71
|
+
MatrixGrid: MatrixGridComponent;
|
|
72
|
+
};
|
|
73
|
+
const { ZoraProvider } = (await load('runtime/ZoraProvider.js')) as {
|
|
74
|
+
ZoraProvider: ZoraProviderComponent;
|
|
75
|
+
};
|
|
76
|
+
return { MatrixGrid, ZoraProvider };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('retains focus when protected terminal cells block all four arrow directions', async () => {
|
|
80
|
+
await withMatrixDom(async (host, browser) => {
|
|
81
|
+
const root = createRoot(host);
|
|
82
|
+
const { MatrixGrid, ZoraProvider } = await loadMatrixGrid();
|
|
83
|
+
const layout = {
|
|
84
|
+
columns: Array.from({ length: 3 }, (_, index) => ({ id: `column-${index}`, size: 20 })),
|
|
85
|
+
rows: Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, size: 20 })),
|
|
86
|
+
};
|
|
87
|
+
const cells = [
|
|
88
|
+
{ columnId: 'column-1', id: 'up', rowId: 'row-0' },
|
|
89
|
+
{ columnId: 'column-0', id: 'left', rowId: 'row-1' },
|
|
90
|
+
{ columnId: 'column-1', id: 'origin', rowId: 'row-1' },
|
|
91
|
+
{ columnId: 'column-2', id: 'right', rowId: 'row-1' },
|
|
92
|
+
{ columnId: 'column-1', id: 'down', rowId: 'row-2' },
|
|
93
|
+
];
|
|
94
|
+
try {
|
|
95
|
+
act(() =>
|
|
96
|
+
root.render(
|
|
97
|
+
<ZoraProvider>
|
|
98
|
+
<MatrixGrid
|
|
99
|
+
cells={cells}
|
|
100
|
+
height={60}
|
|
101
|
+
layout={layout}
|
|
102
|
+
protectedCellIds={['left', 'right', 'up', 'down']}
|
|
103
|
+
testID="terminal-protected-matrix"
|
|
104
|
+
width={60}
|
|
105
|
+
renderCell={(cell) => <span>{cell.id}</span>}
|
|
106
|
+
/>
|
|
107
|
+
</ZoraProvider>,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
const origin = host.querySelector<HTMLElement>(
|
|
111
|
+
'[data-testid="terminal-protected-matrix-cell-origin"]',
|
|
112
|
+
);
|
|
113
|
+
if (!origin) throw new Error('Missing terminal-protected focus origin.');
|
|
114
|
+
act(() => origin.focus());
|
|
115
|
+
for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
|
|
116
|
+
act(() => {
|
|
117
|
+
void origin.dispatchEvent(new browser.KeyboardEvent('keydown', { bubbles: true, key }));
|
|
118
|
+
});
|
|
119
|
+
expect(document.activeElement).toBe(origin);
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
act(() => root.unmount());
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('skips consecutive protected cells and retains the origin when a direction has no navigable cell', async () => {
|
|
128
|
+
await withMatrixDom(async (host, browser) => {
|
|
129
|
+
const root = createRoot(host);
|
|
130
|
+
const { MatrixGrid, ZoraProvider } = await loadMatrixGrid();
|
|
131
|
+
const layout = {
|
|
132
|
+
columns: Array.from({ length: 4 }, (_, index) => ({ id: `column-${index}`, size: 20 })),
|
|
133
|
+
rows: [{ id: 'row-0', size: 20 }],
|
|
134
|
+
};
|
|
135
|
+
const cells = ['a', 'b', 'c', 'd'].map((id, index) => ({
|
|
136
|
+
columnId: `column-${index}`,
|
|
137
|
+
id,
|
|
138
|
+
rowId: 'row-0',
|
|
139
|
+
}));
|
|
140
|
+
const render = (protectedCellIds: readonly string[], testID: string) => (
|
|
141
|
+
<ZoraProvider>
|
|
142
|
+
<MatrixGrid
|
|
143
|
+
cells={cells}
|
|
144
|
+
height={20}
|
|
145
|
+
layout={layout}
|
|
146
|
+
protectedCellIds={protectedCellIds}
|
|
147
|
+
testID={testID}
|
|
148
|
+
width={80}
|
|
149
|
+
renderCell={(cell) => <span>{cell.id}</span>}
|
|
150
|
+
/>
|
|
151
|
+
</ZoraProvider>
|
|
152
|
+
);
|
|
153
|
+
try {
|
|
154
|
+
act(() => root.render(render(['b', 'c'], 'consecutive-protected-matrix')));
|
|
155
|
+
const a = host.querySelector<HTMLElement>(
|
|
156
|
+
'[data-testid="consecutive-protected-matrix-cell-a"]',
|
|
157
|
+
);
|
|
158
|
+
const d = host.querySelector<HTMLElement>(
|
|
159
|
+
'[data-testid="consecutive-protected-matrix-cell-d"]',
|
|
160
|
+
);
|
|
161
|
+
if (!a || !d) throw new Error('Missing consecutive-protected cells.');
|
|
162
|
+
act(() => {
|
|
163
|
+
a.focus();
|
|
164
|
+
void a.dispatchEvent(
|
|
165
|
+
new browser.KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
expect(document.activeElement).toBe(d);
|
|
169
|
+
|
|
170
|
+
act(() => root.render(render(['b', 'c', 'd'], 'blocked-direction-matrix')));
|
|
171
|
+
const blockedOrigin = host.querySelector<HTMLElement>(
|
|
172
|
+
'[data-testid="blocked-direction-matrix-cell-a"]',
|
|
173
|
+
);
|
|
174
|
+
if (!blockedOrigin) throw new Error('Missing blocked-direction focus origin.');
|
|
175
|
+
act(() => {
|
|
176
|
+
blockedOrigin.focus();
|
|
177
|
+
void blockedOrigin.dispatchEvent(
|
|
178
|
+
new browser.KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
expect(document.activeElement).toBe(blockedOrigin);
|
|
182
|
+
} finally {
|
|
183
|
+
act(() => root.unmount());
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('reconciles roving focus when protected cells change and leaves fully protected matrices without a tab stop', async () => {
|
|
189
|
+
await withMatrixDom(async (host, browser) => {
|
|
190
|
+
const root = createRoot(host);
|
|
191
|
+
const { MatrixGrid, ZoraProvider } = await loadMatrixGrid();
|
|
192
|
+
const layout = {
|
|
193
|
+
columns: Array.from({ length: 3 }, (_, index) => ({ id: `column-${index}`, size: 20 })),
|
|
194
|
+
rows: [{ id: 'row-0', size: 20 }],
|
|
195
|
+
};
|
|
196
|
+
const cells = ['a', 'b', 'c'].map((id, index) => ({
|
|
197
|
+
columnId: `column-${index}`,
|
|
198
|
+
id,
|
|
199
|
+
rowId: 'row-0',
|
|
200
|
+
}));
|
|
201
|
+
let setProtectedCellIds: React.Dispatch<React.SetStateAction<readonly string[]>> | undefined;
|
|
202
|
+
|
|
203
|
+
function DynamicProtectionMatrix() {
|
|
204
|
+
const [protectedCellIds, setProtected] = React.useState<readonly string[]>([]);
|
|
205
|
+
setProtectedCellIds = setProtected;
|
|
206
|
+
return (
|
|
207
|
+
<ZoraProvider>
|
|
208
|
+
<MatrixGrid
|
|
209
|
+
cells={cells}
|
|
210
|
+
height={20}
|
|
211
|
+
layout={layout}
|
|
212
|
+
protectedCellIds={protectedCellIds}
|
|
213
|
+
testID="dynamic-protected-matrix"
|
|
214
|
+
width={60}
|
|
215
|
+
renderCell={(cell) => <span>{cell.id}</span>}
|
|
216
|
+
/>
|
|
217
|
+
</ZoraProvider>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
act(() => root.render(<DynamicProtectionMatrix />));
|
|
223
|
+
const a = host.querySelector<HTMLElement>('[data-testid="dynamic-protected-matrix-cell-a"]');
|
|
224
|
+
if (!a) throw new Error('Missing dynamic-protection focus origin.');
|
|
225
|
+
act(() => a.focus());
|
|
226
|
+
act(() => setProtectedCellIds?.(['a']));
|
|
227
|
+
const protectedA = host.querySelector<HTMLElement>(
|
|
228
|
+
'[data-testid="dynamic-protected-matrix-cell-a"]',
|
|
229
|
+
);
|
|
230
|
+
const b = host.querySelector<HTMLElement>('[data-testid="dynamic-protected-matrix-cell-b"]');
|
|
231
|
+
if (!protectedA || !b) throw new Error('Missing dynamic-protection cells.');
|
|
232
|
+
expect(protectedA.getAttribute('aria-disabled')).toBe('true');
|
|
233
|
+
expect(protectedA.getAttribute('tabindex')).not.toBe('0');
|
|
234
|
+
expect(b.getAttribute('tabindex')).toBe('0');
|
|
235
|
+
act(() => {
|
|
236
|
+
b.focus();
|
|
237
|
+
void b.dispatchEvent(
|
|
238
|
+
new browser.KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
const c = host.querySelector<HTMLElement>('[data-testid="dynamic-protected-matrix-cell-c"]');
|
|
242
|
+
expect(document.activeElement).toBe(c);
|
|
243
|
+
|
|
244
|
+
act(() => setProtectedCellIds?.(['a', 'b', 'c']));
|
|
245
|
+
expect(
|
|
246
|
+
host.querySelectorAll('[data-testid^="dynamic-protected-matrix-cell-"][tabindex="0"]'),
|
|
247
|
+
).toHaveLength(0);
|
|
248
|
+
} finally {
|
|
249
|
+
act(() => root.unmount());
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -67,6 +67,7 @@ type MatrixGridComponent = React.ComponentType<{
|
|
|
67
67
|
readonly layout: typeof layout;
|
|
68
68
|
readonly onSelectionChange?: (ids: readonly string[]) => void;
|
|
69
69
|
readonly onViewportChange?: (viewport: unknown) => void;
|
|
70
|
+
readonly protectedCellIds?: readonly string[];
|
|
70
71
|
readonly renderCell: (cell: { readonly id: string }) => React.ReactNode;
|
|
71
72
|
readonly readOnly?: boolean;
|
|
72
73
|
readonly selectedCellIds?: readonly string[];
|
|
@@ -320,6 +321,84 @@ test('navigates sparse cells by keyboard and applies range and modifier selectio
|
|
|
320
321
|
});
|
|
321
322
|
});
|
|
322
323
|
|
|
324
|
+
test('keeps protected cells disabled, unselected, and out of keyboard ranges', async () => {
|
|
325
|
+
await withMatrixDom(async (host, browser) => {
|
|
326
|
+
const selections: (readonly string[])[] = [];
|
|
327
|
+
const root = createRoot(host);
|
|
328
|
+
const { MatrixGrid } = (await load('components/matrix-grid/MatrixGrid.js')) as {
|
|
329
|
+
MatrixGrid: MatrixGridComponent;
|
|
330
|
+
};
|
|
331
|
+
const { ZoraProvider } = (await load('runtime/ZoraProvider.js')) as {
|
|
332
|
+
ZoraProvider: ZoraProviderComponent;
|
|
333
|
+
};
|
|
334
|
+
const protectedLayout = {
|
|
335
|
+
columns: [
|
|
336
|
+
{ id: 'column-0', size: 20 },
|
|
337
|
+
{ id: 'column-1', size: 20 },
|
|
338
|
+
{ id: 'column-2', size: 20 },
|
|
339
|
+
],
|
|
340
|
+
rows: [{ id: 'row-0', size: 20 }],
|
|
341
|
+
};
|
|
342
|
+
const protectedCells = [
|
|
343
|
+
{ columnId: 'column-0', id: 'a1', rowId: 'row-0' },
|
|
344
|
+
{ columnId: 'column-1', id: 'b1', rowId: 'row-0' },
|
|
345
|
+
{ columnId: 'column-2', id: 'c1', rowId: 'row-0' },
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
function ProtectedMatrix() {
|
|
349
|
+
const [selectedIds, setSelectedIds] = React.useState<readonly string[]>(['a1']);
|
|
350
|
+
return (
|
|
351
|
+
<ZoraProvider>
|
|
352
|
+
<MatrixGrid
|
|
353
|
+
cells={protectedCells}
|
|
354
|
+
height={40}
|
|
355
|
+
layout={protectedLayout}
|
|
356
|
+
protectedCellIds={['b1']}
|
|
357
|
+
selectedCellIds={selectedIds}
|
|
358
|
+
selectionMode="multi"
|
|
359
|
+
testID="protected-matrix"
|
|
360
|
+
width={60}
|
|
361
|
+
onSelectionChange={(ids) => {
|
|
362
|
+
selections.push(ids);
|
|
363
|
+
setSelectedIds(ids);
|
|
364
|
+
}}
|
|
365
|
+
renderCell={(cell) => <span>{cell.id}</span>}
|
|
366
|
+
/>
|
|
367
|
+
</ZoraProvider>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
act(() => root.render(<ProtectedMatrix />));
|
|
373
|
+
const a1 = host.querySelector<HTMLElement>('[data-testid="protected-matrix-cell-a1"]');
|
|
374
|
+
const b1 = host.querySelector<HTMLElement>('[data-testid="protected-matrix-cell-b1"]');
|
|
375
|
+
if (!a1 || !b1) throw new Error('Missing protected matrix cells.');
|
|
376
|
+
expect(b1.getAttribute('aria-disabled')).toBe('true');
|
|
377
|
+
act(() => {
|
|
378
|
+
void b1.dispatchEvent(new browser.MouseEvent('click', { bubbles: true }));
|
|
379
|
+
void b1.dispatchEvent(
|
|
380
|
+
new browser.KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
|
|
381
|
+
);
|
|
382
|
+
void b1.dispatchEvent(new browser.KeyboardEvent('keydown', { bubbles: true, key: ' ' }));
|
|
383
|
+
a1.focus();
|
|
384
|
+
void a1.dispatchEvent(
|
|
385
|
+
new browser.KeyboardEvent('keydown', {
|
|
386
|
+
bubbles: true,
|
|
387
|
+
key: 'ArrowRight',
|
|
388
|
+
shiftKey: true,
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
const c1 = host.querySelector<HTMLElement>('[data-testid="protected-matrix-cell-c1"]');
|
|
393
|
+
expect(c1).not.toBeNull();
|
|
394
|
+
expect(document.activeElement).toBe(c1);
|
|
395
|
+
expect(selections).toEqual([['a1', 'c1']]);
|
|
396
|
+
} finally {
|
|
397
|
+
act(() => root.unmount());
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
323
402
|
test('does not capture keyboard input from interactive cell content and respects read-only selection', async () => {
|
|
324
403
|
await withMatrixDom(async (host, browser) => {
|
|
325
404
|
const selections: (readonly string[])[] = [];
|
|
@@ -29,6 +29,7 @@ function MatrixGridInner({
|
|
|
29
29
|
onSelectionChange,
|
|
30
30
|
onViewportChange,
|
|
31
31
|
overscanPixels = 160,
|
|
32
|
+
protectedCellIds = [],
|
|
32
33
|
readOnly = false,
|
|
33
34
|
renderCell,
|
|
34
35
|
selectedCellIds = [],
|
|
@@ -57,6 +58,11 @@ function MatrixGridInner({
|
|
|
57
58
|
);
|
|
58
59
|
const [visibleIds, setVisibleIds] = React.useState<readonly string[]>([]);
|
|
59
60
|
const anchorId = React.useRef<string | null>(null);
|
|
61
|
+
const protectedCellIdSet = React.useMemo(() => new Set(protectedCellIds), [protectedCellIds]);
|
|
62
|
+
const isNavigable = React.useCallback(
|
|
63
|
+
(id: string) => !protectedCellIdSet.has(id),
|
|
64
|
+
[protectedCellIdSet],
|
|
65
|
+
);
|
|
60
66
|
|
|
61
67
|
const handleViewportChange = React.useCallback(
|
|
62
68
|
(proposal: GridViewport) => {
|
|
@@ -68,7 +74,7 @@ function MatrixGridInner({
|
|
|
68
74
|
|
|
69
75
|
const activateCell = React.useCallback(
|
|
70
76
|
(cell: GridMatrixCellPlacement, event: unknown) => {
|
|
71
|
-
if (interactionPolicy === 'passive' || readOnly) return;
|
|
77
|
+
if (interactionPolicy === 'passive' || readOnly || protectedCellIdSet.has(cell.id)) return;
|
|
72
78
|
const intent = isRangeSelectionEvent(event)
|
|
73
79
|
? 'range'
|
|
74
80
|
: resolveSelectionEventIntent(event, 'pointer');
|
|
@@ -80,6 +86,7 @@ function MatrixGridInner({
|
|
|
80
86
|
anchorId.current,
|
|
81
87
|
intent,
|
|
82
88
|
selectionMode,
|
|
89
|
+
protectedCellIdSet,
|
|
83
90
|
),
|
|
84
91
|
);
|
|
85
92
|
if (intent !== 'range') anchorId.current = cell.id;
|
|
@@ -91,6 +98,7 @@ function MatrixGridInner({
|
|
|
91
98
|
selectedCellIds,
|
|
92
99
|
selectionMode,
|
|
93
100
|
sparseIndex.entries,
|
|
101
|
+
protectedCellIdSet,
|
|
94
102
|
],
|
|
95
103
|
);
|
|
96
104
|
const handleKeyboardNavigation = React.useCallback(
|
|
@@ -106,6 +114,7 @@ function MatrixGridInner({
|
|
|
106
114
|
sparseIndex,
|
|
107
115
|
interactionPolicy === 'passive',
|
|
108
116
|
visibleIds,
|
|
117
|
+
isNavigable,
|
|
109
118
|
handleKeyboardNavigation,
|
|
110
119
|
);
|
|
111
120
|
const focusedPlacement = React.useMemo(() => {
|
|
@@ -148,6 +157,7 @@ function MatrixGridInner({
|
|
|
148
157
|
activateCell,
|
|
149
158
|
keyboard,
|
|
150
159
|
interactionPolicy === 'passive',
|
|
160
|
+
protectedCellIdSet.has(cell.id),
|
|
151
161
|
readOnly,
|
|
152
162
|
testID,
|
|
153
163
|
);
|
|
@@ -171,6 +181,7 @@ function renderMatrixCell(
|
|
|
171
181
|
activateCell: (cell: GridMatrixCellPlacement, event: unknown) => void,
|
|
172
182
|
keyboard: ReturnType<typeof useMatrixGridKeyboardFocus>,
|
|
173
183
|
passive: boolean,
|
|
184
|
+
protectedCell: boolean,
|
|
174
185
|
readOnly: boolean,
|
|
175
186
|
testID: string | undefined,
|
|
176
187
|
) {
|
|
@@ -181,7 +192,7 @@ function renderMatrixCell(
|
|
|
181
192
|
onKeyDown={(event) => {
|
|
182
193
|
if (keyboard.onKeyDown(cell.id, event.key, event.shiftKey)) return true;
|
|
183
194
|
if (!['Enter', ' '].includes(event.key)) return false;
|
|
184
|
-
if (passive || readOnly) return false;
|
|
195
|
+
if (passive || protectedCell || readOnly) return false;
|
|
185
196
|
activateCell(cell, event);
|
|
186
197
|
return true;
|
|
187
198
|
}}
|
|
@@ -191,9 +202,9 @@ function renderMatrixCell(
|
|
|
191
202
|
accessibilityActions={[{ name: 'activate' }]}
|
|
192
203
|
accessibilityLabel={`Cell ${cell.id}`}
|
|
193
204
|
accessibilityRole="button"
|
|
194
|
-
accessibilityState={{ disabled: passive, selected }}
|
|
195
|
-
disabled={passive}
|
|
196
|
-
focusable={Platform.OS !== 'web' || canInteract}
|
|
205
|
+
accessibilityState={{ disabled: passive || protectedCell, selected }}
|
|
206
|
+
disabled={passive || protectedCell}
|
|
207
|
+
focusable={!protectedCell && (Platform.OS !== 'web' || canInteract)}
|
|
197
208
|
style={[
|
|
198
209
|
styles.cell,
|
|
199
210
|
{
|
|
@@ -8,6 +8,7 @@ export function useMatrixGridKeyboardFocus(
|
|
|
8
8
|
index: MatrixGridSparseIndex,
|
|
9
9
|
disabled: boolean,
|
|
10
10
|
visibleIds: readonly string[],
|
|
11
|
+
isNavigable: (id: string) => boolean,
|
|
11
12
|
onNavigate: (targetId: string, originId: string, shiftKey: boolean) => void,
|
|
12
13
|
) {
|
|
13
14
|
const [focusedId, setFocusedId] = React.useState<string | null>(null);
|
|
@@ -16,9 +17,19 @@ export function useMatrixGridKeyboardFocus(
|
|
|
16
17
|
const cellRefs = React.useRef(new Map<string, FocusableCell>());
|
|
17
18
|
const visibleIdSet = React.useMemo(() => new Set(visibleIds), [visibleIds]);
|
|
18
19
|
const tabStopId =
|
|
19
|
-
focusedId !== null && visibleIdSet.has(focusedId)
|
|
20
|
+
focusedId !== null && visibleIdSet.has(focusedId) && isNavigable(focusedId)
|
|
20
21
|
? focusedId
|
|
21
|
-
: visibleIds.find((id) => index.positionById.has(id));
|
|
22
|
+
: visibleIds.find((id) => index.positionById.has(id) && isNavigable(id));
|
|
23
|
+
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
if (focusedRef.current !== null && !isNavigable(focusedRef.current)) {
|
|
26
|
+
focusedRef.current = null;
|
|
27
|
+
setFocusedId(null);
|
|
28
|
+
}
|
|
29
|
+
if (pendingFocusRef.current !== null && !isNavigable(pendingFocusRef.current)) {
|
|
30
|
+
pendingFocusRef.current = null;
|
|
31
|
+
}
|
|
32
|
+
}, [isNavigable]);
|
|
22
33
|
|
|
23
34
|
const onFocus = React.useCallback((id: string) => {
|
|
24
35
|
pendingFocusRef.current = null;
|
|
@@ -44,7 +55,7 @@ export function useMatrixGridKeyboardFocus(
|
|
|
44
55
|
return false;
|
|
45
56
|
}
|
|
46
57
|
const originId = focusedRef.current ?? id;
|
|
47
|
-
const nextId =
|
|
58
|
+
const nextId = resolveNextNavigableId(index, originId, key, isNavigable);
|
|
48
59
|
if (nextId === null || nextId === originId) return true;
|
|
49
60
|
|
|
50
61
|
focusedRef.current = nextId;
|
|
@@ -59,12 +70,28 @@ export function useMatrixGridKeyboardFocus(
|
|
|
59
70
|
}
|
|
60
71
|
return true;
|
|
61
72
|
},
|
|
62
|
-
[disabled, index, onNavigate],
|
|
73
|
+
[disabled, index, isNavigable, onNavigate],
|
|
63
74
|
);
|
|
64
75
|
|
|
65
76
|
return { focusedId, onFocus, onKeyDown, registerCell, tabStopId };
|
|
66
77
|
}
|
|
67
78
|
|
|
79
|
+
/*** Skip protected sparse cells while retaining the nearest deterministic directional navigation. */
|
|
80
|
+
function resolveNextNavigableId(
|
|
81
|
+
index: MatrixGridSparseIndex,
|
|
82
|
+
originId: string,
|
|
83
|
+
key: string,
|
|
84
|
+
isNavigable: (id: string) => boolean,
|
|
85
|
+
): string | null {
|
|
86
|
+
let candidate = getMatrixGridNextFocusId(index, originId, key);
|
|
87
|
+
while (candidate !== null && candidate !== originId && !isNavigable(candidate)) {
|
|
88
|
+
const nextCandidate = getMatrixGridNextFocusId(index, candidate, key);
|
|
89
|
+
if (nextCandidate === candidate) return originId;
|
|
90
|
+
candidate = nextCandidate;
|
|
91
|
+
}
|
|
92
|
+
return candidate;
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
interface FocusableCell {
|
|
69
96
|
focus: () => void;
|
|
70
97
|
}
|
|
@@ -51,21 +51,28 @@ const cells = [
|
|
|
51
51
|
|
|
52
52
|
describe('resolveMatrixGridSelection', () => {
|
|
53
53
|
test('preserves the canonical single and multi selection intent rules', () => {
|
|
54
|
-
expect(
|
|
55
|
-
'b1',
|
|
56
|
-
]);
|
|
57
|
-
expect(
|
|
58
|
-
'a1',
|
|
59
|
-
|
|
60
|
-
]);
|
|
54
|
+
expect(
|
|
55
|
+
resolveMatrixGridSelection(cells, ['a1'], 'b1', 'a1', 'toggle', 'single', new Set()),
|
|
56
|
+
).toEqual(['b1']);
|
|
57
|
+
expect(
|
|
58
|
+
resolveMatrixGridSelection(cells, ['a1'], 'b1', 'a1', 'toggle', 'multi', new Set()),
|
|
59
|
+
).toEqual(['a1', 'b1']);
|
|
61
60
|
});
|
|
62
61
|
|
|
63
62
|
test('uses the selection contract for a sparse rectangular shift range', () => {
|
|
64
|
-
expect(
|
|
65
|
-
'a1',
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
expect(
|
|
64
|
+
resolveMatrixGridSelection(cells, ['a1'], 'b2', 'a1', 'range', 'multi', new Set()),
|
|
65
|
+
).toEqual(['a1', 'b1', 'a2', 'b2']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('excludes protected cells from direct and range selection proposals', () => {
|
|
69
|
+
const protectedCellIds = new Set(['b1', 'a2']);
|
|
70
|
+
|
|
71
|
+
expect(
|
|
72
|
+
resolveMatrixGridSelection(cells, ['a1'], 'b1', 'a1', 'replace', 'multi', protectedCellIds),
|
|
73
|
+
).toEqual(['a1']);
|
|
74
|
+
expect(
|
|
75
|
+
resolveMatrixGridSelection(cells, ['a1'], 'b2', 'a1', 'range', 'multi', protectedCellIds),
|
|
76
|
+
).toEqual(['a1', 'b2']);
|
|
70
77
|
});
|
|
71
78
|
});
|
|
@@ -12,7 +12,9 @@ export function resolveMatrixGridSelection(
|
|
|
12
12
|
anchorId: string | null,
|
|
13
13
|
intent: MatrixGridSelectionIntent,
|
|
14
14
|
mode: SelectionMode,
|
|
15
|
+
protectedCellIds: ReadonlySet<string>,
|
|
15
16
|
): readonly string[] {
|
|
17
|
+
if (protectedCellIds.has(cellId)) return selectedIds;
|
|
16
18
|
if (mode === 'single') return applySelectionIntent(selectedIds, cellId, 'replace');
|
|
17
19
|
if (intent !== 'range') return applySelectionIntent(selectedIds, cellId, intent);
|
|
18
20
|
|
|
@@ -23,6 +25,7 @@ export function resolveMatrixGridSelection(
|
|
|
23
25
|
return cells
|
|
24
26
|
.filter(
|
|
25
27
|
(cell) =>
|
|
28
|
+
!protectedCellIds.has(cell.id) &&
|
|
26
29
|
cell.rowIndex >= Math.min(anchor.rowIndex, target.rowIndex) &&
|
|
27
30
|
cell.rowIndex <= Math.max(anchor.rowIndex, target.rowIndex) &&
|
|
28
31
|
cell.columnIndex >= Math.min(anchor.columnIndex, target.columnIndex) &&
|
package/src/types/matrix-grid.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface MatrixGridProps extends ZoraBaseProps {
|
|
|
22
22
|
readonly overscanPixels?: number;
|
|
23
23
|
/** Retains focus navigation while suppressing cell-selection mutations. */
|
|
24
24
|
readonly readOnly?: boolean;
|
|
25
|
+
/** Cells that remain visible but cannot receive focus or change the controlled selection. */
|
|
26
|
+
readonly protectedCellIds?: readonly string[];
|
|
25
27
|
readonly selectedCellIds?: readonly string[];
|
|
26
28
|
readonly selectionMode?: SelectionMode;
|
|
27
29
|
readonly onSelectionChange?: (ids: readonly string[]) => void;
|
|
@@ -293,6 +293,7 @@ function GridInteractionsKeyboardProxy({
|
|
|
293
293
|
React.useEffect(() => () => onSpaceKeyChange(false), [onSpaceKeyChange]);
|
|
294
294
|
return /* @__PURE__ */ jsx("div", {
|
|
295
295
|
"aria-label": "Grid interaction keyboard controls",
|
|
296
|
+
role: "group",
|
|
296
297
|
onBlur: () => onSpaceKeyChange(false),
|
|
297
298
|
onKeyDown: (event) => {
|
|
298
299
|
if (!enabled)
|
|
@@ -314,11 +315,18 @@ function GridInteractionsKeyboardProxy({
|
|
|
314
315
|
if (isSpaceKey(event))
|
|
315
316
|
onSpaceKeyChange(false);
|
|
316
317
|
},
|
|
317
|
-
style:
|
|
318
|
+
style: keyboardBoundaryStyle,
|
|
318
319
|
tabIndex: enabled ? 0 : -1,
|
|
319
320
|
children
|
|
320
321
|
});
|
|
321
322
|
}
|
|
323
|
+
var keyboardBoundaryStyle = {
|
|
324
|
+
alignSelf: "stretch",
|
|
325
|
+
display: "flex",
|
|
326
|
+
flex: 1,
|
|
327
|
+
minHeight: 0,
|
|
328
|
+
minWidth: 0
|
|
329
|
+
};
|
|
322
330
|
function isSpaceKey(event) {
|
|
323
331
|
return event.code === "Space" || event.key === " ";
|
|
324
332
|
}
|
|
@@ -392,7 +400,6 @@ function GridInteractions({
|
|
|
392
400
|
return /* @__PURE__ */ jsx2(import_react_native.View, {
|
|
393
401
|
accessible: true,
|
|
394
402
|
accessibilityLabel: "Grid interaction surface",
|
|
395
|
-
accessibilityRole: "adjustable",
|
|
396
403
|
ref: surfaceRef,
|
|
397
404
|
testID,
|
|
398
405
|
onResponderGrant: (event) => {
|