@ankhorage/zora 25.4.0 → 25.6.0
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 +3 -1
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts +2 -7
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/GridView.js +309 -51
- package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.js +11 -4
- package/dist/features/grid-view/adapters/inbound/TileGrid.js.map +1 -1
- 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/time-grid/adapters/inbound/TimeGrid.d.ts +5 -0
- package/dist/features/time-grid/adapters/inbound/TimeGrid.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGrid.js +107 -0
- package/dist/features/time-grid/adapters/inbound/TimeGrid.js.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.d.ts +9 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.js +5 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.js.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.d.ts +9 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.js +5 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.js.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.d.ts +9 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.js +10 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.js.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.d.ts +4 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.d.ts.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.js +31 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.js.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.d.ts +5 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.d.ts.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.js +6 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.js.map +1 -0
- package/dist/features/time-grid/meta.d.ts +11 -0
- package/dist/features/time-grid/meta.d.ts.map +1 -0
- package/dist/features/time-grid/meta.js +11 -0
- package/dist/features/time-grid/meta.js.map +1 -0
- package/dist/features/time-grid/public.d.ts +5 -0
- package/dist/features/time-grid/public.d.ts.map +1 -0
- package/dist/features/time-grid/public.js +4 -0
- package/dist/features/time-grid/public.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/grid-view.d.ts +18 -1
- package/dist/types/grid-view.d.ts.map +1 -1
- package/dist/types/grid-view.js.map +1 -1
- package/dist/types/time-grid.d.ts +30 -0
- package/dist/types/time-grid.d.ts.map +1 -0
- package/dist/types/time-grid.js +2 -0
- package/dist/types/time-grid.js.map +1 -0
- package/package.json +1 -1
- package/src/features/grid-view/adapters/inbound/GridView.interaction.test.tsx +585 -0
- package/src/features/grid-view/adapters/inbound/GridView.test.tsx +74 -0
- package/src/features/grid-view/adapters/inbound/GridView.tsx +506 -77
- package/src/features/grid-view/adapters/inbound/TileGrid.tsx +26 -3
- package/src/features/registry/ZORA_COMPONENT_REGISTRY.ts +2 -0
- package/src/features/registry/componentMeta.ts +2 -0
- package/src/features/time-grid/adapters/inbound/TimeGrid.test.tsx +158 -0
- package/src/features/time-grid/adapters/inbound/TimeGrid.tsx +197 -0
- package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.tsx +11 -0
- package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.tsx +11 -0
- package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.tsx +20 -0
- package/src/features/time-grid/application/resolveTimeGridNextFocusId.test.ts +46 -0
- package/src/features/time-grid/application/resolveTimeGridNextFocusId.ts +41 -0
- package/src/features/time-grid/application/resolveTimeGridVisibleIntervals.test.ts +33 -0
- package/src/features/time-grid/application/resolveTimeGridVisibleIntervals.ts +13 -0
- package/src/features/time-grid/meta.ts +12 -0
- package/src/features/time-grid/public.ts +4 -0
- package/src/index.ts +2 -0
- package/src/types/grid-view.ts +27 -1
- package/src/types/time-grid.ts +33 -0
- package/web-dist/chunks/{Accordion-r2w86rwv.js → Accordion-ebghcyzj.js} +125 -1
- package/web-dist/chunks/{Accordion-s3d6g59s.js → Accordion-f43jba23.js} +1 -1
- package/web-dist/chunks/Accordion-q8exgc3h.js +515 -0
- package/web-dist/chunks/{Accordion-60wn9xn9.js → Accordion-szar8dt5.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/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.d.ts +3 -0
- package/web-dist/components/time-grid/TimeGrid.js +208 -0
- package/web-dist/components/time-grid/index.d.ts +1 -0
- package/web-dist/components/time-grid/index.js +2 -0
- package/web-dist/components/uploader/Uploader.js +2 -2
- package/web-dist/manifest.json +38 -22
- package/web-dist/chunks/Accordion-nxsda1my.js +0 -194
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GridViewport } from '@ankhorage/grid-view';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { View as NativeView } from 'react-native';
|
|
3
4
|
|
|
@@ -13,20 +14,29 @@ export function TileGrid({
|
|
|
13
14
|
tileSize = 120,
|
|
14
15
|
gap = 12,
|
|
15
16
|
zoom = 1,
|
|
17
|
+
viewport,
|
|
18
|
+
defaultViewport,
|
|
19
|
+
viewportConstraints,
|
|
20
|
+
zoomLimits,
|
|
16
21
|
overscanPixels,
|
|
17
22
|
focusedItemId,
|
|
18
23
|
revealPaddingPixels,
|
|
19
24
|
onColumnsChange,
|
|
25
|
+
onViewportChange,
|
|
20
26
|
onVisibleItemIdsChange,
|
|
21
27
|
renderItem,
|
|
22
28
|
interactionPolicy,
|
|
23
29
|
testID,
|
|
24
30
|
}: TileGridProps) {
|
|
25
31
|
const [measuredWidth, setMeasuredWidth] = React.useState(0);
|
|
32
|
+
const [uncontrolledPixelsPerUnitX, setUncontrolledPixelsPerUnitX] = React.useState(
|
|
33
|
+
() => defaultViewport?.pixelsPerUnitX ?? zoom,
|
|
34
|
+
);
|
|
26
35
|
const resolvedWidth = width ?? measuredWidth;
|
|
36
|
+
const pixelsPerUnitX = viewport?.pixelsPerUnitX ?? uncontrolledPixelsPerUnitX;
|
|
27
37
|
const layout = React.useMemo(
|
|
28
|
-
() => layoutTileGrid(items, resolvedWidth, tileSize, gap,
|
|
29
|
-
[
|
|
38
|
+
() => layoutTileGrid(items, resolvedWidth, tileSize, gap, pixelsPerUnitX),
|
|
39
|
+
[gap, items, pixelsPerUnitX, resolvedWidth, tileSize],
|
|
30
40
|
);
|
|
31
41
|
const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
|
|
32
42
|
|
|
@@ -34,6 +44,14 @@ export function TileGrid({
|
|
|
34
44
|
if (resolvedWidth > 0) onColumnsChange?.(layout.columns);
|
|
35
45
|
}, [layout.columns, onColumnsChange, resolvedWidth]);
|
|
36
46
|
|
|
47
|
+
const handleViewportChange = React.useCallback(
|
|
48
|
+
(nextViewport: GridViewport) => {
|
|
49
|
+
if (viewport === undefined) setUncontrolledPixelsPerUnitX(nextViewport.pixelsPerUnitX);
|
|
50
|
+
onViewportChange?.(nextViewport);
|
|
51
|
+
},
|
|
52
|
+
[onViewportChange, viewport],
|
|
53
|
+
);
|
|
54
|
+
|
|
37
55
|
return (
|
|
38
56
|
<NativeView
|
|
39
57
|
onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}
|
|
@@ -43,12 +61,14 @@ export function TileGrid({
|
|
|
43
61
|
{resolvedWidth > 0 ? (
|
|
44
62
|
<GridView
|
|
45
63
|
contentHeight={layout.height}
|
|
46
|
-
contentWidth={Math.max(resolvedWidth /
|
|
64
|
+
contentWidth={Math.max(resolvedWidth / pixelsPerUnitX, layout.width)}
|
|
65
|
+
defaultViewport={defaultViewport}
|
|
47
66
|
height={height}
|
|
48
67
|
focusedItemId={focusedItemId}
|
|
49
68
|
interactionPolicy={interactionPolicy}
|
|
50
69
|
items={layout.items}
|
|
51
70
|
onVisibleItemIdsChange={onVisibleItemIdsChange}
|
|
71
|
+
onViewportChange={handleViewportChange}
|
|
52
72
|
overscanPixels={overscanPixels}
|
|
53
73
|
renderItem={(item) => {
|
|
54
74
|
const source = itemLookup.get(item.id);
|
|
@@ -56,6 +76,9 @@ export function TileGrid({
|
|
|
56
76
|
}}
|
|
57
77
|
revealPaddingPixels={revealPaddingPixels}
|
|
58
78
|
width={resolvedWidth}
|
|
79
|
+
viewport={viewport}
|
|
80
|
+
viewportConstraints={viewportConstraints}
|
|
81
|
+
zoomLimits={zoomLimits}
|
|
59
82
|
zoom={zoom}
|
|
60
83
|
/>
|
|
61
84
|
) : null}
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
} from '../tabletop/public';
|
|
78
78
|
import { Tab, TabList, TabPanel, Tabs } from '../tabs/public';
|
|
79
79
|
import { ThemeModeToggle } from '../theme/adapters/inbound/ThemeModeToggle';
|
|
80
|
+
import { TimeGrid } from '../time-grid/public';
|
|
80
81
|
import { TimePicker } from '../time-picker/public';
|
|
81
82
|
import { Timeline } from '../timeline/public';
|
|
82
83
|
import { Toast } from '../toast/public';
|
|
@@ -200,6 +201,7 @@ const _ZORA_COMPONENT_REGISTRY = {
|
|
|
200
201
|
GridRuler,
|
|
201
202
|
GridView,
|
|
202
203
|
TileGrid,
|
|
204
|
+
TimeGrid,
|
|
203
205
|
MediaExplorer,
|
|
204
206
|
Hero,
|
|
205
207
|
List,
|
|
@@ -99,6 +99,7 @@ import { tabMeta } from '../tabs/tabMeta';
|
|
|
99
99
|
import { tabPanelMeta } from '../tabs/tabPanelMeta';
|
|
100
100
|
import { tabsMeta } from '../tabs/tabsMeta';
|
|
101
101
|
import { themeModeToggleMeta } from '../theme/ThemeModeToggle.meta';
|
|
102
|
+
import { timeGridMeta } from '../time-grid/meta';
|
|
102
103
|
import { timePickerMeta } from '../time-picker/timePickerMeta';
|
|
103
104
|
import { timelineMeta } from '../timeline/meta';
|
|
104
105
|
import { toastMeta } from '../toast/toastMeta';
|
|
@@ -204,6 +205,7 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = finalizeFeatureMet
|
|
|
204
205
|
GridRuler: gridRulerMeta,
|
|
205
206
|
GridView: gridViewMeta,
|
|
206
207
|
TileGrid: tileGridMeta,
|
|
208
|
+
TimeGrid: timeGridMeta,
|
|
207
209
|
Hero: heroMeta,
|
|
208
210
|
List: listMeta,
|
|
209
211
|
ListItem: listItemMeta,
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
import type { TimeGridProps } from '../../../../types/time-grid';
|
|
10
|
+
|
|
11
|
+
const webDistRoot = join(import.meta.dir, '../../../../../web-dist');
|
|
12
|
+
|
|
13
|
+
function load(path: string) {
|
|
14
|
+
return import(pathToFileURL(join(webDistRoot, path)).href);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function installBrowserGlobals(browser: Window): () => void {
|
|
18
|
+
const keys = [
|
|
19
|
+
'window',
|
|
20
|
+
'document',
|
|
21
|
+
'Node',
|
|
22
|
+
'navigator',
|
|
23
|
+
'ShadowRoot',
|
|
24
|
+
'IS_REACT_ACT_ENVIRONMENT',
|
|
25
|
+
] as const;
|
|
26
|
+
const previous = keys.map(
|
|
27
|
+
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
|
28
|
+
);
|
|
29
|
+
Object.assign(globalThis, {
|
|
30
|
+
IS_REACT_ACT_ENVIRONMENT: true,
|
|
31
|
+
Node: browser.Node,
|
|
32
|
+
ShadowRoot: browser.ShadowRoot,
|
|
33
|
+
document: browser.document,
|
|
34
|
+
navigator: browser.navigator,
|
|
35
|
+
window: browser,
|
|
36
|
+
});
|
|
37
|
+
return () => {
|
|
38
|
+
for (const [key, descriptor] of previous) {
|
|
39
|
+
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
40
|
+
else Reflect.deleteProperty(globalThis, key);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('keeps mounted interval and virtualized variable-height lane labels synchronized with GridView scrolling', async () => {
|
|
46
|
+
const browser = new Window();
|
|
47
|
+
const restore = installBrowserGlobals(browser);
|
|
48
|
+
const host = document.createElement('div');
|
|
49
|
+
document.body.append(host);
|
|
50
|
+
const root = createRoot(host);
|
|
51
|
+
const { TimeGrid } = (await load('components/time-grid/TimeGrid.js')) as {
|
|
52
|
+
TimeGrid: React.ComponentType<TimeGridProps>;
|
|
53
|
+
};
|
|
54
|
+
const focusedIds: string[] = [];
|
|
55
|
+
const viewportOffsets: number[] = [];
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
act(() => {
|
|
59
|
+
root.render(
|
|
60
|
+
<TimeGrid
|
|
61
|
+
contentWidth={100}
|
|
62
|
+
focusedIntervalId="drums"
|
|
63
|
+
height={30}
|
|
64
|
+
intervals={[
|
|
65
|
+
{ extent: 10, id: 'drums', laneId: 'drums', start: 0 },
|
|
66
|
+
{ extent: 10, id: 'strings', laneId: 'strings', start: 0 },
|
|
67
|
+
]}
|
|
68
|
+
lanes={[
|
|
69
|
+
{ height: 20, id: 'drums' },
|
|
70
|
+
{ height: 40, id: 'bass' },
|
|
71
|
+
{ height: 30, id: 'strings' },
|
|
72
|
+
]}
|
|
73
|
+
onFocusedIntervalIdChange={(id) => focusedIds.push(id)}
|
|
74
|
+
onViewportChange={(viewport) => viewportOffsets.push(viewport.offsetY)}
|
|
75
|
+
overscanPixels={0}
|
|
76
|
+
renderInterval={(interval, selected) => (
|
|
77
|
+
<span data-selected={selected}>{interval.id}</span>
|
|
78
|
+
)}
|
|
79
|
+
renderLaneLabel={(lane) => <span>{lane.id}</span>}
|
|
80
|
+
selectedIntervalIds={['drums']}
|
|
81
|
+
testID="time-grid"
|
|
82
|
+
width={100}
|
|
83
|
+
zoom={2}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(host.querySelector('[data-testid="time-grid-interval-drums"]')?.textContent).toBe(
|
|
89
|
+
'drums',
|
|
90
|
+
);
|
|
91
|
+
expect(host.querySelector('[data-testid="time-grid-interval-strings"]')).toBeNull();
|
|
92
|
+
expect(
|
|
93
|
+
host.querySelector('[data-testid="time-grid-lane-label-drums"]')?.getAttribute('style'),
|
|
94
|
+
).toContain('height: 40px');
|
|
95
|
+
expect(host.querySelector('[data-testid="time-grid-lane-label-bass"]')).toBeNull();
|
|
96
|
+
|
|
97
|
+
const interval = host.querySelector<HTMLElement>('[data-testid="time-grid-interval-drums"]');
|
|
98
|
+
if (!interval) throw new Error('Missing mounted interval');
|
|
99
|
+
const arrowDown = new browser.KeyboardEvent('keydown', {
|
|
100
|
+
bubbles: true,
|
|
101
|
+
cancelable: true,
|
|
102
|
+
key: 'ArrowDown',
|
|
103
|
+
});
|
|
104
|
+
void act(() => interval.dispatchEvent(arrowDown));
|
|
105
|
+
expect(arrowDown.defaultPrevented).toBe(true);
|
|
106
|
+
expect(focusedIds).toEqual(['strings']);
|
|
107
|
+
|
|
108
|
+
const verticalScroller = host.querySelector<HTMLElement>(
|
|
109
|
+
'[data-testid="time-grid-viewport-vertical-scroll"]',
|
|
110
|
+
);
|
|
111
|
+
if (!verticalScroller) throw new Error('Missing GridView vertical scroller');
|
|
112
|
+
verticalScroller.scrollTop = 60;
|
|
113
|
+
void act(() => verticalScroller.dispatchEvent(new browser.Event('scroll', { bubbles: true })));
|
|
114
|
+
|
|
115
|
+
expect(viewportOffsets).toEqual([30]);
|
|
116
|
+
expect(host.querySelector('[data-testid="time-grid-lane-label-drums"]')).toBeNull();
|
|
117
|
+
expect(
|
|
118
|
+
host.querySelector('[data-testid="time-grid-lane-label-bass"]')?.getAttribute('style'),
|
|
119
|
+
).toContain('top: -20px');
|
|
120
|
+
|
|
121
|
+
act(() => {
|
|
122
|
+
root.render(
|
|
123
|
+
<TimeGrid
|
|
124
|
+
contentWidth={100}
|
|
125
|
+
focusedIntervalId="strings"
|
|
126
|
+
height={30}
|
|
127
|
+
intervals={[
|
|
128
|
+
{ extent: 10, id: 'drums', laneId: 'drums', start: 0 },
|
|
129
|
+
{ extent: 10, id: 'strings', laneId: 'strings', start: 0 },
|
|
130
|
+
]}
|
|
131
|
+
lanes={[
|
|
132
|
+
{ height: 20, id: 'drums' },
|
|
133
|
+
{ height: 40, id: 'bass' },
|
|
134
|
+
{ height: 30, id: 'strings' },
|
|
135
|
+
]}
|
|
136
|
+
onFocusedIntervalIdChange={(id) => focusedIds.push(id)}
|
|
137
|
+
onViewportChange={(viewport) => viewportOffsets.push(viewport.offsetY)}
|
|
138
|
+
overscanPixels={0}
|
|
139
|
+
renderInterval={(interval, selected) => (
|
|
140
|
+
<span data-selected={selected}>{interval.id}</span>
|
|
141
|
+
)}
|
|
142
|
+
renderLaneLabel={(lane) => <span>{lane.id}</span>}
|
|
143
|
+
selectedIntervalIds={['strings']}
|
|
144
|
+
testID="time-grid"
|
|
145
|
+
width={100}
|
|
146
|
+
zoom={2}
|
|
147
|
+
/>,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
expect(viewportOffsets).toEqual([30, 56]);
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
152
|
+
} finally {
|
|
153
|
+
act(() => root.unmount());
|
|
154
|
+
host.remove();
|
|
155
|
+
browser.close();
|
|
156
|
+
restore();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { getLaneIntervalPlacement, type GridViewport } from '@ankhorage/grid-view';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Pressable, View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import type { TimeGridProps } from '../../../../types/time-grid';
|
|
6
|
+
import { GridView } from '../../../grid-view/public';
|
|
7
|
+
import { resolveTimeGridNextFocusId } from '../../application/resolveTimeGridNextFocusId';
|
|
8
|
+
import { resolveTimeGridVisibleIntervals } from '../../application/resolveTimeGridVisibleIntervals';
|
|
9
|
+
import { TimeGridKeyboardProxy } from './TimeGridKeyboardProxy';
|
|
10
|
+
|
|
11
|
+
/*** Presents virtualized variable-height interval lanes through ZORA's shared GridView renderer. */
|
|
12
|
+
export function TimeGrid({
|
|
13
|
+
contentWidth,
|
|
14
|
+
focusedIntervalId,
|
|
15
|
+
height,
|
|
16
|
+
interactionPolicy,
|
|
17
|
+
intervals,
|
|
18
|
+
lanes,
|
|
19
|
+
onFocusedIntervalIdChange,
|
|
20
|
+
onIntervalPress,
|
|
21
|
+
onViewportChange,
|
|
22
|
+
onVisibleIntervalIdsChange,
|
|
23
|
+
overscanPixels,
|
|
24
|
+
renderInterval,
|
|
25
|
+
renderLaneLabel,
|
|
26
|
+
revealPaddingPixels,
|
|
27
|
+
selectedIntervalIds = [],
|
|
28
|
+
testID,
|
|
29
|
+
width,
|
|
30
|
+
zoom,
|
|
31
|
+
}: TimeGridProps) {
|
|
32
|
+
const scale = Math.max(0.01, zoom ?? 1);
|
|
33
|
+
const [scrollOffsets, setScrollOffsets] = React.useState(() => ({
|
|
34
|
+
offsetX: 0,
|
|
35
|
+
offsetY: 0,
|
|
36
|
+
}));
|
|
37
|
+
const viewport = React.useMemo<GridViewport>(
|
|
38
|
+
() => ({
|
|
39
|
+
...scrollOffsets,
|
|
40
|
+
height,
|
|
41
|
+
pixelsPerUnitX: scale,
|
|
42
|
+
pixelsPerUnitY: scale,
|
|
43
|
+
width,
|
|
44
|
+
}),
|
|
45
|
+
[height, scale, scrollOffsets, width],
|
|
46
|
+
);
|
|
47
|
+
const intervalById = React.useMemo(
|
|
48
|
+
() => new Map(intervals.map((interval) => [interval.id, interval])),
|
|
49
|
+
[intervals],
|
|
50
|
+
);
|
|
51
|
+
const selectedIds = React.useMemo(() => new Set(selectedIntervalIds), [selectedIntervalIds]);
|
|
52
|
+
const contentHeight = React.useMemo(
|
|
53
|
+
() => lanes.reduce((total, lane) => total + lane.height, 0),
|
|
54
|
+
[lanes],
|
|
55
|
+
);
|
|
56
|
+
const visibleIntervals = React.useMemo(
|
|
57
|
+
() => resolveTimeGridVisibleIntervals(lanes, intervals, viewport, overscanPixels),
|
|
58
|
+
[intervals, lanes, overscanPixels, viewport],
|
|
59
|
+
);
|
|
60
|
+
const focusedPlacement = React.useMemo(() => {
|
|
61
|
+
const focusedInterval = intervals.find((interval) => interval.id === focusedIntervalId);
|
|
62
|
+
return focusedInterval ? getLaneIntervalPlacement(lanes, focusedInterval) : undefined;
|
|
63
|
+
}, [focusedIntervalId, intervals, lanes]);
|
|
64
|
+
const gridItems = React.useMemo(
|
|
65
|
+
() =>
|
|
66
|
+
focusedPlacement && !visibleIntervals.some((interval) => interval.id === focusedPlacement.id)
|
|
67
|
+
? [...visibleIntervals, focusedPlacement]
|
|
68
|
+
: visibleIntervals,
|
|
69
|
+
[focusedPlacement, visibleIntervals],
|
|
70
|
+
);
|
|
71
|
+
const labelIntervals = React.useMemo(
|
|
72
|
+
() =>
|
|
73
|
+
lanes.map((lane) => ({
|
|
74
|
+
extent: contentWidth,
|
|
75
|
+
id: `lane-label-${lane.id}`,
|
|
76
|
+
laneId: lane.id,
|
|
77
|
+
start: 0,
|
|
78
|
+
})),
|
|
79
|
+
[contentWidth, lanes],
|
|
80
|
+
);
|
|
81
|
+
const visibleLabels = React.useMemo(
|
|
82
|
+
() => resolveTimeGridVisibleIntervals(lanes, labelIntervals, viewport, overscanPixels),
|
|
83
|
+
[labelIntervals, lanes, overscanPixels, viewport],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const handleViewportChange = React.useCallback(
|
|
87
|
+
(nextViewport: GridViewport) => {
|
|
88
|
+
setScrollOffsets((current) =>
|
|
89
|
+
current.offsetX === nextViewport.offsetX && current.offsetY === nextViewport.offsetY
|
|
90
|
+
? current
|
|
91
|
+
: { offsetX: nextViewport.offsetX, offsetY: nextViewport.offsetY },
|
|
92
|
+
);
|
|
93
|
+
onViewportChange?.(nextViewport);
|
|
94
|
+
},
|
|
95
|
+
[onViewportChange],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const resolveNextFocusId = React.useCallback(
|
|
99
|
+
(intervalId: string, direction: 'down' | 'left' | 'right' | 'up') => {
|
|
100
|
+
const allIntervals = resolveTimeGridVisibleIntervals(lanes, intervals, {
|
|
101
|
+
height: contentHeight,
|
|
102
|
+
offsetX: 0,
|
|
103
|
+
offsetY: 0,
|
|
104
|
+
pixelsPerUnitX: 1,
|
|
105
|
+
pixelsPerUnitY: 1,
|
|
106
|
+
width: contentWidth,
|
|
107
|
+
});
|
|
108
|
+
return resolveTimeGridNextFocusId(allIntervals, intervalId, direction);
|
|
109
|
+
},
|
|
110
|
+
[contentHeight, contentWidth, intervals, lanes],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<View style={{ flexDirection: 'row', height, width }} testID={testID}>
|
|
115
|
+
{renderLaneLabel ? (
|
|
116
|
+
<View
|
|
117
|
+
accessibilityElementsHidden
|
|
118
|
+
pointerEvents="none"
|
|
119
|
+
style={{ height, overflow: 'hidden' }}
|
|
120
|
+
>
|
|
121
|
+
{visibleLabels.map((placement) => {
|
|
122
|
+
const lane = lanes[placement.laneIndex];
|
|
123
|
+
if (!lane) return null;
|
|
124
|
+
return (
|
|
125
|
+
<View
|
|
126
|
+
key={lane.id}
|
|
127
|
+
style={{
|
|
128
|
+
height: placement.height * scale,
|
|
129
|
+
position: 'absolute',
|
|
130
|
+
top: (placement.y - viewport.offsetY) * scale,
|
|
131
|
+
}}
|
|
132
|
+
testID={testID === undefined ? undefined : `${testID}-lane-label-${lane.id}`}
|
|
133
|
+
>
|
|
134
|
+
{renderLaneLabel(lane)}
|
|
135
|
+
</View>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</View>
|
|
139
|
+
) : null}
|
|
140
|
+
<GridView
|
|
141
|
+
contentHeight={contentHeight}
|
|
142
|
+
contentWidth={contentWidth}
|
|
143
|
+
focusedItemId={focusedIntervalId}
|
|
144
|
+
height={height}
|
|
145
|
+
interactionPolicy={interactionPolicy}
|
|
146
|
+
items={gridItems}
|
|
147
|
+
onViewportChange={handleViewportChange}
|
|
148
|
+
onVisibleItemIdsChange={onVisibleIntervalIdsChange}
|
|
149
|
+
overscanPixels={overscanPixels}
|
|
150
|
+
renderItem={(placement) => {
|
|
151
|
+
const interval = intervalById.get(placement.id);
|
|
152
|
+
if (!interval) return null;
|
|
153
|
+
const selected = selectedIds.has(interval.id);
|
|
154
|
+
return (
|
|
155
|
+
<TimeGridKeyboardProxy
|
|
156
|
+
onKeyDown={(key) => {
|
|
157
|
+
const direction = resolveKeyboardDirection(key);
|
|
158
|
+
const nextId =
|
|
159
|
+
direction === undefined ? undefined : resolveNextFocusId(interval.id, direction);
|
|
160
|
+
if (!nextId) return false;
|
|
161
|
+
onFocusedIntervalIdChange?.(nextId);
|
|
162
|
+
return true;
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<Pressable
|
|
166
|
+
accessibilityLabel={`Interval ${interval.id}`}
|
|
167
|
+
accessibilityRole="button"
|
|
168
|
+
disabled={interactionPolicy === 'passive'}
|
|
169
|
+
onPress={() => {
|
|
170
|
+
onFocusedIntervalIdChange?.(interval.id);
|
|
171
|
+
onIntervalPress?.(interval);
|
|
172
|
+
}}
|
|
173
|
+
style={{ flex: 1 }}
|
|
174
|
+
testID={testID === undefined ? undefined : `${testID}-interval-${interval.id}`}
|
|
175
|
+
>
|
|
176
|
+
{renderInterval(interval, selected)}
|
|
177
|
+
</Pressable>
|
|
178
|
+
</TimeGridKeyboardProxy>
|
|
179
|
+
);
|
|
180
|
+
}}
|
|
181
|
+
revealPaddingPixels={revealPaddingPixels}
|
|
182
|
+
testID={testID === undefined ? undefined : `${testID}-viewport`}
|
|
183
|
+
width={width}
|
|
184
|
+
zoom={zoom}
|
|
185
|
+
/>
|
|
186
|
+
</View>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/*** Maps desktop arrow keys to the platform-neutral interval navigation directions. */
|
|
191
|
+
function resolveKeyboardDirection(key: string): 'down' | 'left' | 'right' | 'up' | undefined {
|
|
192
|
+
if (key === 'ArrowDown') return 'down';
|
|
193
|
+
if (key === 'ArrowLeft') return 'left';
|
|
194
|
+
if (key === 'ArrowRight') return 'right';
|
|
195
|
+
if (key === 'ArrowUp') return 'up';
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
/*** Preserves native activation and touch interaction where DOM arrow-key events do not exist. */
|
|
4
|
+
export function TimeGridKeyboardProxy({ children }: TimeGridKeyboardProxyProps) {
|
|
5
|
+
return <>{children}</>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface TimeGridKeyboardProxyProps {
|
|
9
|
+
readonly children: React.ReactNode;
|
|
10
|
+
readonly onKeyDown: (key: string) => boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
/*** Provides the native fallback type surface; React Native Web resolves the DOM adapter at runtime. */
|
|
4
|
+
export function TimeGridKeyboardProxy({ children }: TimeGridKeyboardProxyProps) {
|
|
5
|
+
return <>{children}</>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface TimeGridKeyboardProxyProps {
|
|
9
|
+
readonly children: React.ReactNode;
|
|
10
|
+
readonly onKeyDown: (key: string) => boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
/*** Bridges DOM arrow keys into portable interval focus navigation on React Native Web. */
|
|
4
|
+
export function TimeGridKeyboardProxy({ children, onKeyDown }: TimeGridKeyboardProxyProps) {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
onKeyDown={(event) => {
|
|
8
|
+
if (onKeyDown(event.key)) event.preventDefault();
|
|
9
|
+
}}
|
|
10
|
+
style={{ display: 'flex', flex: 1 }}
|
|
11
|
+
>
|
|
12
|
+
{children}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TimeGridKeyboardProxyProps {
|
|
18
|
+
readonly children: React.ReactNode;
|
|
19
|
+
readonly onKeyDown: (key: string) => boolean;
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resolveTimeGridNextFocusId } from './resolveTimeGridNextFocusId';
|
|
4
|
+
|
|
5
|
+
const placements = [
|
|
6
|
+
{
|
|
7
|
+
extent: 4,
|
|
8
|
+
height: 20,
|
|
9
|
+
id: 'drums-a',
|
|
10
|
+
laneId: 'drums',
|
|
11
|
+
laneIndex: 0,
|
|
12
|
+
start: 2,
|
|
13
|
+
width: 4,
|
|
14
|
+
x: 2,
|
|
15
|
+
y: 0,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
extent: 4,
|
|
19
|
+
height: 20,
|
|
20
|
+
id: 'drums-b',
|
|
21
|
+
laneId: 'drums',
|
|
22
|
+
laneIndex: 0,
|
|
23
|
+
start: 12,
|
|
24
|
+
width: 4,
|
|
25
|
+
x: 12,
|
|
26
|
+
y: 0,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
extent: 4,
|
|
30
|
+
height: 20,
|
|
31
|
+
id: 'bass-a',
|
|
32
|
+
laneId: 'bass',
|
|
33
|
+
laneIndex: 1,
|
|
34
|
+
start: 3,
|
|
35
|
+
width: 4,
|
|
36
|
+
x: 3,
|
|
37
|
+
y: 20,
|
|
38
|
+
},
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
test('moves keyboard focus by interval geometry without calendar or music semantics', () => {
|
|
42
|
+
expect(resolveTimeGridNextFocusId(placements, 'drums-a', 'right')).toBe('drums-b');
|
|
43
|
+
expect(resolveTimeGridNextFocusId(placements, 'drums-b', 'left')).toBe('drums-a');
|
|
44
|
+
expect(resolveTimeGridNextFocusId(placements, 'drums-a', 'down')).toBe('bass-a');
|
|
45
|
+
expect(resolveTimeGridNextFocusId(placements, 'bass-a', 'up')).toBe('drums-a');
|
|
46
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GridLaneIntervalPlacement } from '@ankhorage/grid-view';
|
|
2
|
+
|
|
3
|
+
/*** Resolves a neighboring interval for keyboard movement without attaching domain meaning to lanes. */
|
|
4
|
+
export function resolveTimeGridNextFocusId(
|
|
5
|
+
placements: readonly GridLaneIntervalPlacement[],
|
|
6
|
+
focusedId: string,
|
|
7
|
+
direction: 'down' | 'left' | 'right' | 'up',
|
|
8
|
+
): string | undefined {
|
|
9
|
+
const focused = placements.find((placement) => placement.id === focusedId);
|
|
10
|
+
if (!focused) return undefined;
|
|
11
|
+
const candidates = placements.filter((placement) => {
|
|
12
|
+
if (direction === 'left') return placement.laneId === focused.laneId && placement.x < focused.x;
|
|
13
|
+
if (direction === 'right')
|
|
14
|
+
return placement.laneId === focused.laneId && placement.x > focused.x;
|
|
15
|
+
if (direction === 'up') return placement.laneIndex < focused.laneIndex;
|
|
16
|
+
return placement.laneIndex > focused.laneIndex;
|
|
17
|
+
});
|
|
18
|
+
return candidates
|
|
19
|
+
.sort(
|
|
20
|
+
(first, second) =>
|
|
21
|
+
resolveFocusDistance(first, focused, direction) -
|
|
22
|
+
resolveFocusDistance(second, focused, direction),
|
|
23
|
+
)
|
|
24
|
+
.at(0)?.id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*** Measures nearby candidates by axis first and timeline/lane offset second. */
|
|
28
|
+
function resolveFocusDistance(
|
|
29
|
+
candidate: GridLaneIntervalPlacement,
|
|
30
|
+
focused: GridLaneIntervalPlacement,
|
|
31
|
+
direction: 'down' | 'left' | 'right' | 'up',
|
|
32
|
+
): number {
|
|
33
|
+
const horizontal = direction === 'left' || direction === 'right';
|
|
34
|
+
const primary = horizontal
|
|
35
|
+
? Math.abs(candidate.x - focused.x)
|
|
36
|
+
: Math.abs(candidate.laneIndex - focused.laneIndex);
|
|
37
|
+
const secondary = horizontal
|
|
38
|
+
? Math.abs(candidate.laneIndex - focused.laneIndex)
|
|
39
|
+
: Math.abs(candidate.x - focused.x);
|
|
40
|
+
return primary * 1_000_000 + secondary;
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resolveTimeGridVisibleIntervals } from './resolveTimeGridVisibleIntervals';
|
|
4
|
+
|
|
5
|
+
test('culls sparse intervals across both time and lane axes', () => {
|
|
6
|
+
expect(
|
|
7
|
+
resolveTimeGridVisibleIntervals(
|
|
8
|
+
[
|
|
9
|
+
{ height: 20, id: 'drums' },
|
|
10
|
+
{ height: 40, id: 'bass' },
|
|
11
|
+
{ height: 30, id: 'strings' },
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
{ extent: 4, id: 'intro', laneId: 'drums', start: 2 },
|
|
15
|
+
{ extent: 4, id: 'verse', laneId: 'bass', start: 20 },
|
|
16
|
+
{ extent: 4, id: 'outro', laneId: 'strings', start: 2 },
|
|
17
|
+
],
|
|
18
|
+
{ height: 45, offsetX: 0, offsetY: 0, pixelsPerUnitX: 1, pixelsPerUnitY: 1, width: 12 },
|
|
19
|
+
),
|
|
20
|
+
).toEqual([
|
|
21
|
+
{
|
|
22
|
+
extent: 4,
|
|
23
|
+
height: 20,
|
|
24
|
+
id: 'intro',
|
|
25
|
+
laneId: 'drums',
|
|
26
|
+
laneIndex: 0,
|
|
27
|
+
start: 2,
|
|
28
|
+
width: 4,
|
|
29
|
+
x: 2,
|
|
30
|
+
y: 0,
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getVisibleLaneIntervals, type GridViewport } from '@ankhorage/grid-view';
|
|
2
|
+
|
|
3
|
+
import type { TimeGridInterval, TimeGridLane } from '../../../types/time-grid';
|
|
4
|
+
|
|
5
|
+
/*** Resolves the ordered interval placements visible in both world-space axes. */
|
|
6
|
+
export function resolveTimeGridVisibleIntervals(
|
|
7
|
+
lanes: readonly TimeGridLane[],
|
|
8
|
+
intervals: readonly TimeGridInterval[],
|
|
9
|
+
viewport: GridViewport,
|
|
10
|
+
overscanPixels?: number,
|
|
11
|
+
) {
|
|
12
|
+
return getVisibleLaneIntervals(lanes, intervals, viewport, overscanPixels);
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ZoraComponentMeta } from '../../types/authoring';
|
|
2
|
+
|
|
3
|
+
/** TimeGrid remains code-only because interval and lane presentation require executable renderers. */
|
|
4
|
+
export const timeGridMeta = {
|
|
5
|
+
name: 'TimeGrid',
|
|
6
|
+
category: 'foundation',
|
|
7
|
+
description: 'Virtualized variable-height world-space interval lanes for code-defined timelines.',
|
|
8
|
+
directManifestNode: false,
|
|
9
|
+
note: 'Requires executable interval and optional lane-label renderers; available through code APIs, not a serializable manifest node.',
|
|
10
|
+
allowedChildren: [],
|
|
11
|
+
props: {},
|
|
12
|
+
} as const satisfies ZoraComponentMeta;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { TimeGridInterval, TimeGridLane, TimeGridProps } from '../../types/time-grid';
|
|
2
|
+
export { TimeGrid } from './adapters/inbound/TimeGrid';
|
|
3
|
+
export { resolveTimeGridNextFocusId } from './application/resolveTimeGridNextFocusId';
|
|
4
|
+
export { resolveTimeGridVisibleIntervals } from './application/resolveTimeGridVisibleIntervals';
|
package/src/index.ts
CHANGED
|
@@ -484,6 +484,8 @@ export {
|
|
|
484
484
|
} from './features/theme/colorModel';
|
|
485
485
|
export * from './features/theme/public';
|
|
486
486
|
export * from './features/theme/runtime';
|
|
487
|
+
export type { TimeGridInterval, TimeGridLane, TimeGridProps } from './features/time-grid/public';
|
|
488
|
+
export { resolveTimeGridVisibleIntervals, TimeGrid } from './features/time-grid/public';
|
|
487
489
|
export {
|
|
488
490
|
TimePicker,
|
|
489
491
|
type TimePickerProps,
|