@ankhorage/zora 25.6.0 → 25.6.2
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 +24 -0
- package/README.md +1 -1
- package/dist/capabilities/index.d.ts +2 -74
- package/dist/capabilities/index.d.ts.map +1 -1
- package/dist/capabilities/index.js +6158 -59
- package/dist/capabilities/index.js.map +118 -1
- package/dist/cli/index.d.ts +2 -74
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.d.ts +5 -0
- package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.d.ts.map +1 -0
- package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.js +59 -0
- package/dist/features/matrix-grid/adapters/inbound/MatrixGrid.js.map +1 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.d.ts +7 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.d.ts.map +1 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.js +24 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridContentSize.js.map +1 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.d.ts +5 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.d.ts.map +1 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.js +8 -0
- package/dist/features/matrix-grid/application/resolveMatrixGridSelection.js.map +1 -0
- package/dist/features/matrix-grid/meta.d.ts +11 -0
- package/dist/features/matrix-grid/meta.d.ts.map +1 -0
- package/dist/features/matrix-grid/meta.js +11 -0
- package/dist/features/matrix-grid/meta.js.map +1 -0
- package/dist/features/matrix-grid/public.d.ts +4 -0
- package/dist/features/matrix-grid/public.d.ts.map +1 -0
- package/dist/features/matrix-grid/public.js +3 -0
- package/dist/features/matrix-grid/public.js.map +1 -0
- package/dist/features/registry/ZORA_COMPONENT_REGISTRY.d.ts.map +1 -1
- package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js +2 -0
- package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js.map +1 -1
- package/dist/features/registry/componentMeta.d.ts.map +1 -1
- package/dist/features/registry/componentMeta.js +2 -0
- package/dist/features/registry/componentMeta.js.map +1 -1
- package/dist/features/registry/createEventCapabilities.d.ts +4 -0
- package/dist/features/registry/createEventCapabilities.d.ts.map +1 -0
- package/dist/features/registry/createEventCapabilities.js +76 -0
- package/dist/features/registry/createEventCapabilities.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types/matrix-grid.d.ts +23 -0
- package/dist/types/matrix-grid.d.ts.map +1 -0
- package/dist/types/matrix-grid.js +2 -0
- package/dist/types/matrix-grid.js.map +1 -0
- package/package.json +1822 -4
- package/src/capabilities/index.test.ts +87 -15
- package/src/capabilities/index.ts +5 -2
- package/src/cli/index.test.ts +5 -3
- package/src/cli/index.ts +5 -2
- package/src/features/matrix-grid/adapters/inbound/MatrixGrid.test.tsx +232 -0
- package/src/features/matrix-grid/adapters/inbound/MatrixGrid.tsx +142 -0
- package/src/features/matrix-grid/application/resolveMatrixGridContentSize.test.ts +32 -0
- package/src/features/matrix-grid/application/resolveMatrixGridContentSize.ts +28 -0
- package/src/features/matrix-grid/application/resolveMatrixGridSelection.test.ts +10 -0
- package/src/features/matrix-grid/application/resolveMatrixGridSelection.ts +15 -0
- package/src/features/matrix-grid/application/resolveMatrixGridVisibleCells.test.ts +35 -0
- package/src/features/matrix-grid/meta.ts +12 -0
- package/src/features/matrix-grid/public.ts +3 -0
- package/src/features/registry/ZORA_COMPONENT_REGISTRY.ts +2 -0
- package/src/features/registry/componentMeta.ts +2 -0
- package/src/features/registry/createEventCapabilities.ts +108 -0
- package/src/index.ts +2 -0
- package/src/structureOwnership.test.ts +5 -0
- package/src/types/matrix-grid.ts +29 -0
- package/web-dist/chunks/{Accordion-f43jba23.js → Accordion-2r0gqk3x.js} +1 -1
- package/web-dist/chunks/{Accordion-q8exgc3h.js → Accordion-a6551mh4.js} +1 -1
- package/web-dist/chunks/{Accordion-ebghcyzj.js → Accordion-bz04casa.js} +92 -3
- package/web-dist/chunks/{Accordion-szar8dt5.js → Accordion-jh72azm2.js} +1 -1
- package/web-dist/components/explorer/Explorer.js +2 -2
- package/web-dist/components/file-explorer/FileExplorer.js +2 -2
- package/web-dist/components/grid-line-overlay/GridLineOverlay.js +1 -1
- package/web-dist/components/grid-ruler/GridRuler.js +1 -1
- package/web-dist/components/grid-view/GridView.js +1 -1
- package/web-dist/components/matrix-grid/MatrixGrid.d.ts +3 -0
- package/web-dist/components/matrix-grid/MatrixGrid.js +153 -0
- package/web-dist/components/matrix-grid/index.d.ts +1 -0
- package/web-dist/components/matrix-grid/index.js +2 -0
- package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
- package/web-dist/components/reader/Reader.js +1 -1
- package/web-dist/components/spatial-grid/SpatialGrid.js +1 -1
- package/web-dist/components/tile-grid/TileGrid.js +1 -1
- package/web-dist/components/time-grid/TimeGrid.js +2 -2
- package/web-dist/components/uploader/Uploader.js +2 -2
- package/web-dist/manifest.json +53 -24
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
-
import { isCapability } from '@ankhorage/
|
|
3
|
+
import { isCapability, isCapabilityId } from '@ankhorage/capability';
|
|
4
|
+
import type { Capability } from '@ankhorage/contracts/capability';
|
|
4
5
|
import { isRecord } from '@ankhorage/utility/object';
|
|
5
6
|
import { expect, test } from 'bun:test';
|
|
6
7
|
|
|
8
|
+
import { ZORA_COMPONENT_META } from '../features/registry';
|
|
7
9
|
import { CAPABILITIES } from './index';
|
|
8
10
|
|
|
9
11
|
const packageJson: unknown = JSON.parse(
|
|
@@ -13,11 +15,66 @@ const packageCapabilities =
|
|
|
13
15
|
isRecord(packageJson) && isRecord(packageJson.ankh) ? packageJson.ankh.capabilities : undefined;
|
|
14
16
|
|
|
15
17
|
test('publishes valid, uniquely identified canonical ZORA capabilities', () => {
|
|
16
|
-
expect(CAPABILITIES).toHaveLength(2);
|
|
17
18
|
expect(CAPABILITIES.every(isCapability)).toBe(true);
|
|
18
19
|
expect(new Set(CAPABILITIES.map((capability) => capability.id)).size).toBe(CAPABILITIES.length);
|
|
19
20
|
});
|
|
20
21
|
|
|
22
|
+
test('projects each direct-manifest event metadata entry into one canonical event capability', () => {
|
|
23
|
+
const metadataEvents = Object.values(ZORA_COMPONENT_META)
|
|
24
|
+
.filter((component) => component.directManifestNode)
|
|
25
|
+
.flatMap((component) => Object.values(component.events ?? {}));
|
|
26
|
+
const eventCapabilities = CAPABILITIES.filter((capability) => capability.access.includes('emit'));
|
|
27
|
+
|
|
28
|
+
expect(eventCapabilities).toHaveLength(
|
|
29
|
+
new Set(metadataEvents.map((event) => event.eventType)).size,
|
|
30
|
+
);
|
|
31
|
+
const eventCapabilitiesById = new Map<Capability['id'], Capability>(
|
|
32
|
+
eventCapabilities.map((capability) => [capability.id, capability]),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
for (const event of metadataEvents) {
|
|
36
|
+
if (!isCapabilityId(event.eventType)) throw new Error(`Invalid event id: ${event.eventType}`);
|
|
37
|
+
const capability = eventCapabilitiesById.get(event.eventType);
|
|
38
|
+
expect(capability?.owner).toBe('@ankhorage/zora');
|
|
39
|
+
expect(capability?.access).toEqual(['emit']);
|
|
40
|
+
expect(capability?.binding).toEqual({ kind: 'event', bindableAs: ['source'] });
|
|
41
|
+
expect(capability?.label).toBe(event.label);
|
|
42
|
+
expect(capability?.description).toBe(event.description);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('projects event payload metadata into matching output schemas', () => {
|
|
47
|
+
const eventCapabilities = new Map<Capability['id'], Capability>(
|
|
48
|
+
CAPABILITIES.filter((capability) => capability.access.includes('emit')).map((capability) => [
|
|
49
|
+
capability.id,
|
|
50
|
+
capability,
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
for (const component of Object.values(ZORA_COMPONENT_META)) {
|
|
55
|
+
if (!component.directManifestNode) continue;
|
|
56
|
+
for (const event of Object.values(component.events ?? {})) {
|
|
57
|
+
if (!isCapabilityId(event.eventType)) throw new Error(`Invalid event id: ${event.eventType}`);
|
|
58
|
+
const capability = eventCapabilities.get(event.eventType);
|
|
59
|
+
const schema = capability?.output?.schema;
|
|
60
|
+
expect(schema?.type).toBe('object');
|
|
61
|
+
|
|
62
|
+
for (const field of event.payloadFields ?? []) {
|
|
63
|
+
if (field.path.includes('.')) continue;
|
|
64
|
+
const fieldSchema = Object.entries(schema?.properties ?? {}).find(
|
|
65
|
+
([fieldName]) => fieldName === field.path,
|
|
66
|
+
)?.[1];
|
|
67
|
+
expect(fieldSchema?.type).toBe(
|
|
68
|
+
field.type === 'record' ? 'object' : field.type === 'unknown' ? undefined : field.type,
|
|
69
|
+
);
|
|
70
|
+
expect(fieldSchema?.additionalProperties).toBe(field.type === 'record' ? true : undefined);
|
|
71
|
+
expect(fieldSchema?.title).toBe(field.label);
|
|
72
|
+
expect(fieldSchema?.description).toBe(field.description);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
21
78
|
test('keeps Ankh package metadata identical to the canonical catalog', () => {
|
|
22
79
|
expect(Array.isArray(packageCapabilities)).toBe(true);
|
|
23
80
|
if (!Array.isArray(packageCapabilities)) {
|
|
@@ -37,18 +94,33 @@ test('exports the catalog from the public capabilities subpath', async () => {
|
|
|
37
94
|
});
|
|
38
95
|
|
|
39
96
|
test('preserves the executable action semantics of ZORA provider capabilities', () => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
97
|
+
const actions = CAPABILITIES.filter((capability) => capability.access.includes('invoke'));
|
|
98
|
+
expect(actions.map((capability) => capability.id)).toEqual(['zora.create', 'zora.sync']);
|
|
99
|
+
expect(actions.every((capability) => capability.binding.kind === 'action')).toBe(true);
|
|
100
|
+
expect(actions.every((capability) => capability.binding.bindableAs.includes('target'))).toBe(
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('built public capability entrypoint imports under Node ESM', async () => {
|
|
106
|
+
const subprocess = Bun.spawn(
|
|
107
|
+
[
|
|
108
|
+
'node',
|
|
109
|
+
'--input-type=module',
|
|
110
|
+
'-e',
|
|
111
|
+
"import('./dist/capabilities/index.js').then(({ CAPABILITIES }) => { if (!Array.isArray(CAPABILITIES)) process.exit(1); });",
|
|
112
|
+
],
|
|
113
|
+
{
|
|
114
|
+
cwd: process.cwd(),
|
|
115
|
+
stderr: 'pipe',
|
|
116
|
+
stdout: 'pipe',
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
const [exitCode, stderr] = await Promise.all([
|
|
120
|
+
subprocess.exited,
|
|
121
|
+
new Response(subprocess.stderr).text(),
|
|
53
122
|
]);
|
|
123
|
+
|
|
124
|
+
expect(stderr).toBe('');
|
|
125
|
+
expect(exitCode).toBe(0);
|
|
54
126
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { Capability } from '@ankhorage/contracts/
|
|
1
|
+
import type { Capability } from '@ankhorage/contracts/capability';
|
|
2
|
+
|
|
3
|
+
import { createEventCapabilities } from '../features/registry/createEventCapabilities.js';
|
|
2
4
|
|
|
3
5
|
export const CAPABILITIES = [
|
|
4
6
|
{
|
|
@@ -60,4 +62,5 @@ export const CAPABILITIES = [
|
|
|
60
62
|
},
|
|
61
63
|
},
|
|
62
64
|
},
|
|
63
|
-
|
|
65
|
+
...createEventCapabilities(),
|
|
66
|
+
] satisfies readonly Capability[];
|
package/src/cli/index.test.ts
CHANGED
|
@@ -6,7 +6,9 @@ import provider from './index';
|
|
|
6
6
|
test('publishes ZORA create and sync through the Ankh provider', () => {
|
|
7
7
|
expect(provider.id).toBe('@ankhorage/zora');
|
|
8
8
|
expect(provider.category).toBe('zora');
|
|
9
|
-
expect(provider.capabilities).
|
|
9
|
+
expect(provider.capabilities).toEqual(
|
|
10
|
+
CAPABILITIES.filter((capability) => capability.access.includes('invoke')),
|
|
11
|
+
);
|
|
10
12
|
expect(provider.commands).toEqual([
|
|
11
13
|
{
|
|
12
14
|
path: ['create'],
|
|
@@ -24,9 +26,9 @@ test('publishes ZORA create and sync through the Ankh provider', () => {
|
|
|
24
26
|
expect(provider.handlers[1]?.path).toEqual(['sync']);
|
|
25
27
|
});
|
|
26
28
|
|
|
27
|
-
test('maps every provider command to one canonical capability independent of catalog order', () => {
|
|
29
|
+
test('maps every provider command to one executable canonical capability independent of catalog order', () => {
|
|
28
30
|
const commandCapabilityIds = provider.commands.map((command) => command.capability);
|
|
29
|
-
const capabilityIds =
|
|
31
|
+
const capabilityIds = provider.capabilities.map((capability) => capability.id);
|
|
30
32
|
|
|
31
33
|
expect(new Set(commandCapabilityIds).size).toBe(commandCapabilityIds.length);
|
|
32
34
|
expect(new Set(commandCapabilityIds)).toEqual(new Set(capabilityIds));
|
package/src/cli/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
5
|
import type { AnkhRuntimeCommandProvider } from '@ankhorage/ankh';
|
|
6
|
-
import type { Capability } from '@ankhorage/contracts/
|
|
6
|
+
import type { Capability } from '@ankhorage/contracts/capability';
|
|
7
7
|
|
|
8
8
|
import { CAPABILITIES } from '../capabilities';
|
|
9
9
|
import { create } from './commands/create';
|
|
@@ -19,12 +19,15 @@ const SYNC_COMMAND = {
|
|
|
19
19
|
capability: 'zora.sync' satisfies Capability['id'],
|
|
20
20
|
summary: 'Regenerate the declared ZORA web materialization.',
|
|
21
21
|
} as const;
|
|
22
|
+
const COMMAND_CAPABILITIES = CAPABILITIES.filter((capability) =>
|
|
23
|
+
capability.access.includes('invoke'),
|
|
24
|
+
);
|
|
22
25
|
|
|
23
26
|
const provider = {
|
|
24
27
|
id: '@ankhorage/zora',
|
|
25
28
|
category: 'zora',
|
|
26
29
|
version: readPackageVersion(),
|
|
27
|
-
capabilities:
|
|
30
|
+
capabilities: COMMAND_CAPABILITIES,
|
|
28
31
|
commands: [CREATE_COMMAND, SYNC_COMMAND],
|
|
29
32
|
handlers: [
|
|
30
33
|
{
|
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
/*** Mount MatrixGrid through React Native Web and restore the ambient DOM after each assertion. */
|
|
21
|
+
async function withMatrixDom(assertion: (host: HTMLElement, browser: Window) => Promise<void>) {
|
|
22
|
+
const browser = new Window();
|
|
23
|
+
const previous = browserKeys.map(
|
|
24
|
+
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
|
25
|
+
);
|
|
26
|
+
Object.assign(globalThis, {
|
|
27
|
+
IS_REACT_ACT_ENVIRONMENT: true,
|
|
28
|
+
window: browser,
|
|
29
|
+
document: browser.document,
|
|
30
|
+
Node: browser.Node,
|
|
31
|
+
navigator: browser.navigator,
|
|
32
|
+
ShadowRoot: browser.ShadowRoot,
|
|
33
|
+
});
|
|
34
|
+
const host = document.createElement('div');
|
|
35
|
+
document.body.appendChild(host);
|
|
36
|
+
try {
|
|
37
|
+
await assertion(host, browser);
|
|
38
|
+
} finally {
|
|
39
|
+
browser.close();
|
|
40
|
+
for (const [key, descriptor] of previous) {
|
|
41
|
+
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
42
|
+
else Reflect.deleteProperty(globalThis, key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const layout = {
|
|
48
|
+
columns: Array.from({ length: 100 }, (_, index) => ({
|
|
49
|
+
id: `column-${index}`,
|
|
50
|
+
size: 10 + (index % 3),
|
|
51
|
+
})),
|
|
52
|
+
rows: Array.from({ length: 100 }, (_, index) => ({ id: `row-${index}`, size: 8 + (index % 4) })),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const cells = layout.rows.flatMap((row) =>
|
|
56
|
+
layout.columns.map((column) => ({
|
|
57
|
+
id: `${row.id}-${column.id}`,
|
|
58
|
+
rowId: row.id,
|
|
59
|
+
columnId: column.id,
|
|
60
|
+
})),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
type MatrixGridComponent = React.ComponentType<{
|
|
64
|
+
readonly cells: typeof cells;
|
|
65
|
+
readonly height: number;
|
|
66
|
+
readonly layout: typeof layout;
|
|
67
|
+
readonly onSelectionChange?: (ids: readonly string[]) => void;
|
|
68
|
+
readonly onViewportChange?: (viewport: unknown) => void;
|
|
69
|
+
readonly renderCell: (cell: { readonly id: string }) => React.ReactNode;
|
|
70
|
+
readonly selectedCellIds?: readonly string[];
|
|
71
|
+
readonly selectionMode?: 'multi';
|
|
72
|
+
readonly testID: string;
|
|
73
|
+
readonly viewport?: {
|
|
74
|
+
readonly height: number;
|
|
75
|
+
readonly offsetX: number;
|
|
76
|
+
readonly offsetY: number;
|
|
77
|
+
readonly pixelsPerUnitX: number;
|
|
78
|
+
readonly pixelsPerUnitY: number;
|
|
79
|
+
readonly width: number;
|
|
80
|
+
};
|
|
81
|
+
readonly width: number;
|
|
82
|
+
readonly zoomX?: number;
|
|
83
|
+
readonly zoomY?: number;
|
|
84
|
+
}>;
|
|
85
|
+
|
|
86
|
+
type ZoraProviderComponent = React.ComponentType<{ readonly children: React.ReactNode }>;
|
|
87
|
+
|
|
88
|
+
test('mounts only visible sparse cells with independent axis zoom and preserves native-web selection', async () => {
|
|
89
|
+
await withMatrixDom(async (host, browser) => {
|
|
90
|
+
const selected: (readonly string[])[] = [];
|
|
91
|
+
const root = createRoot(host);
|
|
92
|
+
const { MatrixGrid } = (await load('components/matrix-grid/MatrixGrid.js')) as {
|
|
93
|
+
MatrixGrid: MatrixGridComponent;
|
|
94
|
+
};
|
|
95
|
+
const { ZoraProvider } = (await load('runtime/ZoraProvider.js')) as {
|
|
96
|
+
ZoraProvider: ZoraProviderComponent;
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
act(() =>
|
|
100
|
+
root.render(
|
|
101
|
+
<ZoraProvider>
|
|
102
|
+
<MatrixGrid
|
|
103
|
+
cells={cells}
|
|
104
|
+
height={96}
|
|
105
|
+
layout={layout}
|
|
106
|
+
selectedCellIds={[]}
|
|
107
|
+
selectionMode="multi"
|
|
108
|
+
testID="matrix"
|
|
109
|
+
viewport={{
|
|
110
|
+
height: 96,
|
|
111
|
+
offsetX: 0,
|
|
112
|
+
offsetY: 0,
|
|
113
|
+
pixelsPerUnitX: 2,
|
|
114
|
+
pixelsPerUnitY: 3,
|
|
115
|
+
width: 120,
|
|
116
|
+
}}
|
|
117
|
+
width={120}
|
|
118
|
+
onSelectionChange={(ids) => selected.push(ids)}
|
|
119
|
+
renderCell={(cell) => <span>{cell.id}</span>}
|
|
120
|
+
/>
|
|
121
|
+
</ZoraProvider>,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
const mountedCells = host.querySelectorAll('[data-testid^="matrix-cell-"]');
|
|
125
|
+
expect(cells).toHaveLength(10_000);
|
|
126
|
+
expect(mountedCells.length).toBeGreaterThan(1);
|
|
127
|
+
expect(mountedCells.length).toBeLessThan(100);
|
|
128
|
+
const target = host.querySelector('[data-testid="matrix-cell-row-1-column-1"]');
|
|
129
|
+
expect(target).not.toBeNull();
|
|
130
|
+
expect(target?.parentElement?.getAttribute('style')).toContain('left: 20px');
|
|
131
|
+
expect(target?.parentElement?.getAttribute('style')).toContain('top: 24px');
|
|
132
|
+
void act(() => target?.dispatchEvent(new browser.MouseEvent('click', { bubbles: true })));
|
|
133
|
+
expect(selected).toEqual([['row-1-column-1']]);
|
|
134
|
+
} finally {
|
|
135
|
+
act(() => root.unmount());
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('does not propose controlled viewports on mount or fresh parent callback identities', async () => {
|
|
141
|
+
await withMatrixDom(async (host) => {
|
|
142
|
+
const proposals: unknown[] = [];
|
|
143
|
+
const root = createRoot(host);
|
|
144
|
+
const { MatrixGrid } = (await load('components/matrix-grid/MatrixGrid.js')) as {
|
|
145
|
+
MatrixGrid: MatrixGridComponent;
|
|
146
|
+
};
|
|
147
|
+
const { ZoraProvider } = (await load('runtime/ZoraProvider.js')) as {
|
|
148
|
+
ZoraProvider: ZoraProviderComponent;
|
|
149
|
+
};
|
|
150
|
+
const render = () => (
|
|
151
|
+
<ZoraProvider>
|
|
152
|
+
<MatrixGrid
|
|
153
|
+
cells={cells}
|
|
154
|
+
height={96}
|
|
155
|
+
layout={layout}
|
|
156
|
+
testID="controlled-matrix"
|
|
157
|
+
viewport={{
|
|
158
|
+
height: 96,
|
|
159
|
+
offsetX: 0,
|
|
160
|
+
offsetY: 0,
|
|
161
|
+
pixelsPerUnitX: 2,
|
|
162
|
+
pixelsPerUnitY: 3,
|
|
163
|
+
width: 120,
|
|
164
|
+
}}
|
|
165
|
+
width={120}
|
|
166
|
+
onViewportChange={(proposal) => proposals.push(proposal)}
|
|
167
|
+
renderCell={(cell) => <span>{cell.id}</span>}
|
|
168
|
+
/>
|
|
169
|
+
</ZoraProvider>
|
|
170
|
+
);
|
|
171
|
+
try {
|
|
172
|
+
act(() => root.render(render()));
|
|
173
|
+
act(() => root.render(render()));
|
|
174
|
+
expect(proposals).toEqual([]);
|
|
175
|
+
} finally {
|
|
176
|
+
act(() => root.unmount());
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('proposes an uncontrolled horizontal pan in world units while retaining independent axis zoom', async () => {
|
|
182
|
+
await withMatrixDom(async (host, browser) => {
|
|
183
|
+
const proposals: unknown[] = [];
|
|
184
|
+
const root = createRoot(host);
|
|
185
|
+
const { MatrixGrid } = (await load('components/matrix-grid/MatrixGrid.js')) as {
|
|
186
|
+
MatrixGrid: MatrixGridComponent;
|
|
187
|
+
};
|
|
188
|
+
const { ZoraProvider } = (await load('runtime/ZoraProvider.js')) as {
|
|
189
|
+
ZoraProvider: ZoraProviderComponent;
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
act(() =>
|
|
193
|
+
root.render(
|
|
194
|
+
<ZoraProvider>
|
|
195
|
+
<MatrixGrid
|
|
196
|
+
cells={cells}
|
|
197
|
+
height={96}
|
|
198
|
+
layout={layout}
|
|
199
|
+
testID="uncontrolled-matrix"
|
|
200
|
+
width={120}
|
|
201
|
+
zoomX={2}
|
|
202
|
+
zoomY={3}
|
|
203
|
+
onViewportChange={(proposal) => proposals.push(proposal)}
|
|
204
|
+
renderCell={(cell) => <span>{cell.id}</span>}
|
|
205
|
+
/>
|
|
206
|
+
</ZoraProvider>,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
const horizontalScroll = host.querySelector(
|
|
210
|
+
'[data-testid="uncontrolled-matrix-horizontal-scroll"]',
|
|
211
|
+
);
|
|
212
|
+
if (!horizontalScroll) throw new Error('Missing MatrixGrid horizontal scroll viewport.');
|
|
213
|
+
horizontalScroll.scrollLeft = 40;
|
|
214
|
+
await act(async () => {
|
|
215
|
+
horizontalScroll.dispatchEvent(new browser.Event('scroll', { bubbles: true }));
|
|
216
|
+
await new Promise<void>((resolve) => browser.setTimeout(resolve, 200));
|
|
217
|
+
});
|
|
218
|
+
expect(proposals).toEqual([
|
|
219
|
+
{
|
|
220
|
+
height: 96,
|
|
221
|
+
offsetX: 20,
|
|
222
|
+
offsetY: 0,
|
|
223
|
+
pixelsPerUnitX: 2,
|
|
224
|
+
pixelsPerUnitY: 3,
|
|
225
|
+
width: 120,
|
|
226
|
+
},
|
|
227
|
+
]);
|
|
228
|
+
} finally {
|
|
229
|
+
act(() => root.unmount());
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getVisibleMatrixCells,
|
|
3
|
+
type GridMatrixCellPlacement,
|
|
4
|
+
type GridViewport,
|
|
5
|
+
} from '@ankhorage/grid-view';
|
|
6
|
+
import { Pressable } from '@ankhorage/surface';
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { StyleSheet, View } from 'react-native';
|
|
9
|
+
|
|
10
|
+
import type { MatrixGridProps } from '../../../../types/matrix-grid';
|
|
11
|
+
import { GridView } from '../../../grid-view/public';
|
|
12
|
+
import { resolveSelectionEventIntent } from '../../../selection/public';
|
|
13
|
+
import { withZoraThemeScope } from '../../../theme/adapters/inbound/withZoraThemeScope';
|
|
14
|
+
import { useZoraTheme } from '../../../theme/composition/useZoraTheme';
|
|
15
|
+
import { resolveMatrixGridContentSize } from '../../application/resolveMatrixGridContentSize';
|
|
16
|
+
import { resolveMatrixGridSelection } from '../../application/resolveMatrixGridSelection';
|
|
17
|
+
|
|
18
|
+
/*** Compose the canonical GridView around released sparse matrix placement and culling geometry. */
|
|
19
|
+
function MatrixGridInner({
|
|
20
|
+
accessibilityLabel = 'Matrix grid',
|
|
21
|
+
cells,
|
|
22
|
+
height,
|
|
23
|
+
interactionPolicy,
|
|
24
|
+
layout,
|
|
25
|
+
onSelectionChange,
|
|
26
|
+
onViewportChange,
|
|
27
|
+
overscanPixels = 160,
|
|
28
|
+
renderCell,
|
|
29
|
+
selectedCellIds = [],
|
|
30
|
+
selectionMode = 'single',
|
|
31
|
+
testID,
|
|
32
|
+
viewport: controlledViewport,
|
|
33
|
+
width,
|
|
34
|
+
zoomX = 1,
|
|
35
|
+
zoomY = 1,
|
|
36
|
+
}: MatrixGridProps) {
|
|
37
|
+
const { theme } = useZoraTheme();
|
|
38
|
+
const contentSize = React.useMemo(() => resolveMatrixGridContentSize(layout), [layout]);
|
|
39
|
+
const initialViewport = React.useMemo<GridViewport>(
|
|
40
|
+
() => ({ height, offsetX: 0, offsetY: 0, pixelsPerUnitX: zoomX, pixelsPerUnitY: zoomY, width }),
|
|
41
|
+
[height, width, zoomX, zoomY],
|
|
42
|
+
);
|
|
43
|
+
const [uncontrolledViewport, setUncontrolledViewport] = React.useState(initialViewport);
|
|
44
|
+
const viewport = controlledViewport ?? uncontrolledViewport;
|
|
45
|
+
const visibleCells = React.useMemo(
|
|
46
|
+
() => getVisibleMatrixCells(layout, cells, viewport, overscanPixels),
|
|
47
|
+
[cells, layout, overscanPixels, viewport],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const visibleCellById = React.useMemo(
|
|
51
|
+
() => new Map(visibleCells.map((cell) => [cell.id, cell])),
|
|
52
|
+
[visibleCells],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const handleViewportChange = React.useCallback(
|
|
56
|
+
(proposal: GridViewport) => {
|
|
57
|
+
if (controlledViewport === undefined) setUncontrolledViewport(proposal);
|
|
58
|
+
onViewportChange?.(proposal);
|
|
59
|
+
},
|
|
60
|
+
[controlledViewport, onViewportChange],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const activateCell = React.useCallback(
|
|
64
|
+
(cell: GridMatrixCellPlacement, event: unknown) => {
|
|
65
|
+
if (interactionPolicy === 'passive') return;
|
|
66
|
+
const intent = resolveSelectionEventIntent(event, 'pointer');
|
|
67
|
+
onSelectionChange?.(
|
|
68
|
+
resolveMatrixGridSelection(selectedCellIds, cell.id, intent, selectionMode),
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
[interactionPolicy, onSelectionChange, selectedCellIds, selectionMode],
|
|
72
|
+
);
|
|
73
|
+
return (
|
|
74
|
+
<View accessibilityLabel={accessibilityLabel}>
|
|
75
|
+
<GridView
|
|
76
|
+
contentHeight={contentSize.height}
|
|
77
|
+
contentWidth={contentSize.width}
|
|
78
|
+
height={height}
|
|
79
|
+
interactionPolicy={interactionPolicy}
|
|
80
|
+
items={visibleCells}
|
|
81
|
+
overscanPixels={0}
|
|
82
|
+
renderItem={(item) => {
|
|
83
|
+
const cell = visibleCellById.get(item.id);
|
|
84
|
+
if (!cell) return null;
|
|
85
|
+
return renderMatrixCell(
|
|
86
|
+
cell,
|
|
87
|
+
selectedCellIds,
|
|
88
|
+
theme.semantics.neutral.divider,
|
|
89
|
+
theme.colors.primary,
|
|
90
|
+
renderCell,
|
|
91
|
+
activateCell,
|
|
92
|
+
testID,
|
|
93
|
+
);
|
|
94
|
+
}}
|
|
95
|
+
testID={testID}
|
|
96
|
+
viewport={viewport}
|
|
97
|
+
width={width}
|
|
98
|
+
onViewportChange={handleViewportChange}
|
|
99
|
+
/>
|
|
100
|
+
</View>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/*** Render one positioned sparse cell with native/web accessible selection semantics. */
|
|
105
|
+
function renderMatrixCell(
|
|
106
|
+
cell: GridMatrixCellPlacement,
|
|
107
|
+
selectedCellIds: readonly string[],
|
|
108
|
+
dividerColor: string,
|
|
109
|
+
selectedColor: string,
|
|
110
|
+
renderCell: MatrixGridProps['renderCell'],
|
|
111
|
+
activateCell: (cell: GridMatrixCellPlacement, event: unknown) => void,
|
|
112
|
+
testID: string | undefined,
|
|
113
|
+
) {
|
|
114
|
+
const selected = selectedCellIds.includes(cell.id);
|
|
115
|
+
return (
|
|
116
|
+
<Pressable
|
|
117
|
+
key={cell.id}
|
|
118
|
+
accessibilityLabel={`Cell ${cell.id}`}
|
|
119
|
+
accessibilityRole="button"
|
|
120
|
+
accessibilityState={{ selected }}
|
|
121
|
+
style={[
|
|
122
|
+
styles.cell,
|
|
123
|
+
{
|
|
124
|
+
borderColor: selected ? selectedColor : dividerColor,
|
|
125
|
+
height: '100%',
|
|
126
|
+
width: '100%',
|
|
127
|
+
},
|
|
128
|
+
]}
|
|
129
|
+
testID={testID === undefined ? undefined : `${testID}-cell-${cell.id}`}
|
|
130
|
+
onPress={(event) => activateCell(cell, event)}
|
|
131
|
+
>
|
|
132
|
+
{renderCell(cell)}
|
|
133
|
+
</Pressable>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const styles = StyleSheet.create({
|
|
138
|
+
cell: { borderWidth: 1, height: '100%', overflow: 'hidden', width: '100%' },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/*** A code-first generic sparse matrix grid; formulas and domain vocabulary remain external. */
|
|
142
|
+
export const MatrixGrid = withZoraThemeScope(MatrixGridInner);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resolveMatrixGridContentSize } from './resolveMatrixGridContentSize';
|
|
4
|
+
|
|
5
|
+
describe('resolveMatrixGridContentSize', () => {
|
|
6
|
+
test('sums variable matrix axes without materializing logical cells', () => {
|
|
7
|
+
expect(
|
|
8
|
+
resolveMatrixGridContentSize({
|
|
9
|
+
columns: [
|
|
10
|
+
{ id: 'beat', size: 40 },
|
|
11
|
+
{ id: 'bar', size: 60 },
|
|
12
|
+
],
|
|
13
|
+
rows: [
|
|
14
|
+
{ id: 'bass', size: 24 },
|
|
15
|
+
{ id: 'lead', size: 36 },
|
|
16
|
+
],
|
|
17
|
+
}),
|
|
18
|
+
).toEqual({ height: 60, width: 100 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('rejects non-finite cumulative axis geometry through the published grid-view contract', () => {
|
|
22
|
+
expect(() =>
|
|
23
|
+
resolveMatrixGridContentSize({
|
|
24
|
+
columns: [
|
|
25
|
+
{ id: 'left', size: Number.MAX_VALUE },
|
|
26
|
+
{ id: 'right', size: Number.MAX_VALUE },
|
|
27
|
+
],
|
|
28
|
+
rows: [{ id: 'row', size: 1 }],
|
|
29
|
+
}),
|
|
30
|
+
).toThrow('Layout axis cumulative world coordinates must be finite.');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getMatrixCellPlacement,
|
|
3
|
+
getVisibleMatrixCells,
|
|
4
|
+
type GridMatrixLayout,
|
|
5
|
+
} from '@ankhorage/grid-view';
|
|
6
|
+
|
|
7
|
+
/*** Resolve total sparse-matrix world extents without expanding its logical cells. */
|
|
8
|
+
export function resolveMatrixGridContentSize(layout: GridMatrixLayout) {
|
|
9
|
+
const finalRow = layout.rows.at(-1);
|
|
10
|
+
const finalColumn = layout.columns.at(-1);
|
|
11
|
+
if (!finalRow || !finalColumn) {
|
|
12
|
+
getVisibleMatrixCells(layout, [], {
|
|
13
|
+
height: 1,
|
|
14
|
+
offsetX: 0,
|
|
15
|
+
offsetY: 0,
|
|
16
|
+
pixelsPerUnitX: 1,
|
|
17
|
+
pixelsPerUnitY: 1,
|
|
18
|
+
width: 1,
|
|
19
|
+
});
|
|
20
|
+
return { height: 0, width: 0 };
|
|
21
|
+
}
|
|
22
|
+
const finalCell = getMatrixCellPlacement(layout, {
|
|
23
|
+
id: 'matrix-grid-content-boundary',
|
|
24
|
+
rowId: finalRow.id,
|
|
25
|
+
columnId: finalColumn.id,
|
|
26
|
+
});
|
|
27
|
+
return { height: finalCell.y + finalCell.height, width: finalCell.x + finalCell.width };
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resolveMatrixGridSelection } from './resolveMatrixGridSelection';
|
|
4
|
+
|
|
5
|
+
describe('resolveMatrixGridSelection', () => {
|
|
6
|
+
test('preserves the canonical single and multi selection intent rules', () => {
|
|
7
|
+
expect(resolveMatrixGridSelection(['a'], 'b', 'toggle', 'single')).toEqual(['b']);
|
|
8
|
+
expect(resolveMatrixGridSelection(['a'], 'b', 'toggle', 'multi')).toEqual(['a', 'b']);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { applySelectionIntent, type SelectionIntent } from '@ankhorage/utility/selection';
|
|
2
|
+
|
|
3
|
+
import type { SelectionMode } from '../../../types/selection';
|
|
4
|
+
|
|
5
|
+
/*** Apply ZORA's canonical selection intent while retaining the configured selection mode. */
|
|
6
|
+
export function resolveMatrixGridSelection(
|
|
7
|
+
selectedIds: readonly string[],
|
|
8
|
+
cellId: string,
|
|
9
|
+
intent: SelectionIntent,
|
|
10
|
+
mode: SelectionMode,
|
|
11
|
+
): readonly string[] {
|
|
12
|
+
return mode === 'single'
|
|
13
|
+
? applySelectionIntent(selectedIds, cellId, 'replace')
|
|
14
|
+
: applySelectionIntent(selectedIds, cellId, intent);
|
|
15
|
+
}
|