react-native-roster 0.1.0 → 0.2.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/README.md +102 -2
- package/dist/src/components/primitives/portal.d.ts +12 -0
- package/dist/src/components/primitives/portal.d.ts.map +1 -0
- package/dist/src/components/primitives/portal.js +41 -0
- package/dist/src/components/primitives/portal.js.map +1 -0
- package/dist/src/components/roster/body-layout.d.ts +1 -1
- package/dist/src/components/roster/body-layout.d.ts.map +1 -1
- package/dist/src/components/roster/body-layout.js +7 -1
- package/dist/src/components/roster/body-layout.js.map +1 -1
- package/dist/src/components/roster/index.d.ts +2 -1
- package/dist/src/components/roster/index.d.ts.map +1 -1
- package/dist/src/components/roster/index.js +26 -3
- package/dist/src/components/roster/index.js.map +1 -1
- package/dist/src/components/roster/lanes/lane.d.ts.map +1 -1
- package/dist/src/components/roster/lanes/lane.js +2 -1
- package/dist/src/components/roster/lanes/lane.js.map +1 -1
- package/dist/src/components/roster/parts/lane-list.d.ts.map +1 -1
- package/dist/src/components/roster/parts/lane-list.js +2 -1
- package/dist/src/components/roster/parts/lane-list.js.map +1 -1
- package/dist/src/components/roster/roster.types.d.ts +29 -1
- package/dist/src/components/roster/roster.types.d.ts.map +1 -1
- package/dist/src/components/roster/selection/selection-layout.d.ts +4 -0
- package/dist/src/components/roster/selection/selection-layout.d.ts.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.js +82 -0
- package/dist/src/components/roster/selection/selection-layout.js.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.types.d.ts +25 -0
- package/dist/src/components/roster/selection/selection-layout.types.d.ts.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.types.js +3 -0
- package/dist/src/components/roster/selection/selection-layout.types.js.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.web.d.ts +4 -0
- package/dist/src/components/roster/selection/selection-layout.web.d.ts.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.web.js +52 -0
- package/dist/src/components/roster/selection/selection-layout.web.js.map +1 -0
- package/dist/src/components/roster/use-roster-press.d.ts +6 -0
- package/dist/src/components/roster/use-roster-press.d.ts.map +1 -0
- package/dist/src/components/roster/use-roster-press.js +61 -0
- package/dist/src/components/roster/use-roster-press.js.map +1 -0
- package/dist/src/components/roster/use-roster.d.ts.map +1 -1
- package/dist/src/components/roster/use-roster.js +40 -30
- package/dist/src/components/roster/use-roster.js.map +1 -1
- package/dist/src/components/schedule/days/parts/column.d.ts.map +1 -1
- package/dist/src/components/schedule/days/parts/column.js +2 -1
- package/dist/src/components/schedule/days/parts/column.js.map +1 -1
- package/dist/src/core/index.d.ts +1 -0
- package/dist/src/core/index.d.ts.map +1 -1
- package/dist/src/core/index.js +3 -1
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/core/source.d.ts +4 -0
- package/dist/src/core/source.d.ts.map +1 -0
- package/dist/src/core/source.js +9 -0
- package/dist/src/core/source.js.map +1 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/nativewind/index.d.ts +4 -1
- package/dist/src/nativewind/index.d.ts.map +1 -1
- package/llms.txt +67 -2
- package/package.json +10 -4
- package/src/components/primitives/README.md +13 -2
- package/src/components/primitives/portal.tsx +48 -0
- package/src/components/roster/README.md +48 -2
- package/src/components/roster/body-layout.tsx +7 -1
- package/src/components/roster/index.tsx +62 -17
- package/src/components/roster/lanes/lane.tsx +2 -4
- package/src/components/roster/parts/lane-list.tsx +3 -1
- package/src/components/roster/roster.types.ts +30 -1
- package/src/components/roster/selection/selection-layout.tsx +90 -0
- package/src/components/roster/selection/selection-layout.types.ts +20 -0
- package/src/components/roster/selection/selection-layout.web.tsx +88 -0
- package/src/components/roster/use-roster-press.ts +74 -0
- package/src/components/roster/use-roster.ts +57 -44
- package/src/components/schedule/days/parts/column.tsx +2 -4
- package/src/core/README.md +4 -0
- package/src/core/index.ts +1 -0
- package/src/core/source.ts +9 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-roster",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Layered intervals with provenance across lanes and a shared time axis for React Native.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"types": "./dist/src/index.d.ts",
|
|
32
32
|
"browser": {
|
|
33
33
|
"./dist/src/components/primitives/press-point.js": "./dist/src/components/primitives/press-point.web.js",
|
|
34
|
-
"./dist/src/components/roster/lanes/interval-hover.js": "./dist/src/components/roster/lanes/interval-hover.web.js"
|
|
34
|
+
"./dist/src/components/roster/lanes/interval-hover.js": "./dist/src/components/roster/lanes/interval-hover.web.js",
|
|
35
|
+
"./dist/src/components/roster/selection/selection-layout.js": "./dist/src/components/roster/selection/selection-layout.web.js"
|
|
35
36
|
},
|
|
36
37
|
"exports": {
|
|
37
38
|
".": {
|
|
@@ -101,7 +102,8 @@
|
|
|
101
102
|
"nativewind": ">=4.1.0",
|
|
102
103
|
"react": ">=18.2.0",
|
|
103
104
|
"react-native": ">=0.74.0",
|
|
104
|
-
"react-native-reanimated": ">=3.19.0"
|
|
105
|
+
"react-native-reanimated": ">=3.19.0",
|
|
106
|
+
"@radix-ui/react-popover": "^1.1.23"
|
|
105
107
|
},
|
|
106
108
|
"peerDependenciesMeta": {
|
|
107
109
|
"expo": {
|
|
@@ -112,6 +114,9 @@
|
|
|
112
114
|
},
|
|
113
115
|
"react-native-reanimated": {
|
|
114
116
|
"optional": true
|
|
117
|
+
},
|
|
118
|
+
"@radix-ui/react-popover": {
|
|
119
|
+
"optional": true
|
|
115
120
|
}
|
|
116
121
|
},
|
|
117
122
|
"dependencies": {
|
|
@@ -150,7 +155,8 @@
|
|
|
150
155
|
"semantic-release": "^25.0.3",
|
|
151
156
|
"semver": "^7.7.0",
|
|
152
157
|
"size-limit": "^13.0.3",
|
|
153
|
-
"temporal-spec": "1.0.1"
|
|
158
|
+
"temporal-spec": "1.0.1",
|
|
159
|
+
"@radix-ui/react-popover": "1.1.23"
|
|
154
160
|
},
|
|
155
161
|
"publishConfig": {
|
|
156
162
|
"access": "public",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# primitives
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
public export.
|
|
3
|
+
Building blocks shared by the projections, plus the native portal store for Roster selection.
|
|
5
4
|
|
|
6
5
|
- `press-point.tsx`, `press-point.web.tsx`, `press-point.types.ts`: pointer
|
|
7
6
|
coordinates relative to the pressed surface; the web file is selected by the
|
|
@@ -10,3 +9,15 @@ public export.
|
|
|
10
9
|
default paint when the override carries a NativeWind class entry
|
|
11
10
|
|
|
12
11
|
Tests: `test/components/primitives`.
|
|
12
|
+
|
|
13
|
+
- `portal.tsx`: root-exported `PortalHost({name})` and
|
|
14
|
+
`Portal({hostName,name,children})`; a module-level Map of hosts to portal nodes,
|
|
15
|
+
subscribed through useSyncExternalStore, registered and removed through lifecycle effects
|
|
16
|
+
|
|
17
|
+
Mount one host per destination name. The default Roster selection layout owns its
|
|
18
|
+
per-instance host; custom layouts can target an ancestor host they own. Caller
|
|
19
|
+
context does not cross the store; provide it above the host or inside the node.
|
|
20
|
+
Schedule does not yet support selection.
|
|
21
|
+
|
|
22
|
+
As a primitive, `Portal` takes `children` rather than a named zone; named zones belong
|
|
23
|
+
to the machine and feature tiers.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Fragment, type ReactNode, useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
const hosts = new Map<string, Map<string, ReactNode>>();
|
|
4
|
+
const listeners = new Set<() => void>();
|
|
5
|
+
const empty = new Map<string, ReactNode>();
|
|
6
|
+
|
|
7
|
+
function subscribe(listener: () => void) {
|
|
8
|
+
listeners.add(listener);
|
|
9
|
+
return () => listeners.delete(listener);
|
|
10
|
+
}
|
|
11
|
+
function publish(hostName: string, nodes: Map<string, ReactNode>) {
|
|
12
|
+
if (nodes.size) hosts.set(hostName, nodes);
|
|
13
|
+
else hosts.delete(hostName);
|
|
14
|
+
for (const listener of listeners) listener();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Mount once at the destination for a named native portal. */
|
|
18
|
+
export function PortalHost({ name }: { name: string }) {
|
|
19
|
+
const nodes = useSyncExternalStore(
|
|
20
|
+
subscribe,
|
|
21
|
+
() => hosts.get(name) ?? empty,
|
|
22
|
+
() => empty,
|
|
23
|
+
);
|
|
24
|
+
return Array.from(nodes, ([key, node]) => <Fragment key={key}>{node}</Fragment>);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Register content at a named native host; caller context does not cross the store. */
|
|
28
|
+
export function Portal({
|
|
29
|
+
hostName,
|
|
30
|
+
name,
|
|
31
|
+
children,
|
|
32
|
+
}: {
|
|
33
|
+
hostName: string;
|
|
34
|
+
name: string;
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}) {
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const nodes = new Map(hosts.get(hostName));
|
|
39
|
+
nodes.set(name, children);
|
|
40
|
+
publish(hostName, nodes);
|
|
41
|
+
return () => {
|
|
42
|
+
const nodes = new Map(hosts.get(hostName));
|
|
43
|
+
nodes.delete(name);
|
|
44
|
+
publish(hostName, nodes);
|
|
45
|
+
};
|
|
46
|
+
}, [hostName, name, children]);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
@@ -6,7 +6,8 @@ This feature is about a roster; its children are lanes.
|
|
|
6
6
|
`XxxInput` slot input type from `roster.types.ts`
|
|
7
7
|
|
|
8
8
|
- `index.tsx`: the chassis; calls `useRoster`, branches on `status`, composes zones
|
|
9
|
-
- `use-roster.ts`: the hook; owns window, projection, scroll, geometry, and
|
|
9
|
+
- `use-roster.ts`: the hook; owns window, projection, scroll, geometry, and selection
|
|
10
|
+
- `use-roster-press.ts`: isolates the stable press ref so React Compiler can retain derived body inputs
|
|
10
11
|
- `layout.tsx`: arranges corner, header, label column, and body regions only
|
|
11
12
|
- `roster.types.ts`: props, model, and every slot input type
|
|
12
13
|
- `body-layout.tsx`: arranges grid and list nodes with horizontal scroll wiring
|
|
@@ -19,7 +20,7 @@ Interval and gap components live in `../layers`; press geometry and `regionStyle
|
|
|
19
20
|
in `../primitives`. Zone contracts are documented in the README's Roster zones
|
|
20
21
|
section and in `llms.txt`. Tests: `test/components/roster`.
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
`RosterBody` gates
|
|
23
24
|
measurement and composes the body layout and lane list. The `onRowRender` prop on
|
|
24
25
|
`RosterBody` and `RosterLaneList` is a development-only Profiler hook used by the
|
|
25
26
|
gallery, not a supported customization point. Production Profiler callbacks are disabled.
|
|
@@ -27,3 +28,48 @@ gallery, not a supported customization point. Production Profiler callbacks are
|
|
|
27
28
|
Input-bearing slots accept component types and mount in the data-owning parts.
|
|
28
29
|
The chassis binds defaults once; emptyZone and cornerZone accept nodes. The body
|
|
29
30
|
content key tracks interval and gap component identity, including class components.
|
|
31
|
+
|
|
32
|
+
`selection/` holds the runtime-swappable presentation strategies (native popover, web popover) for one `SelectionLayoutProps` contract.
|
|
33
|
+
|
|
34
|
+
- `selection/selection-layout.types.ts`: exported `SelectionLayoutProps`, with body
|
|
35
|
+
and detail nodes, targetBounds, open, dismissal, host name, and shared scroll
|
|
36
|
+
limited to x, y, headerStyle, and labelStyle
|
|
37
|
+
- `selection/selection-layout.tsx`: exported native `RosterSelectionPopover`, local
|
|
38
|
+
portal host, body press passthrough, hardware back, measured viewport clamping, and shared scroll positioning
|
|
39
|
+
- `selection/selection-layout.web.tsx`: Radix presentation with a browser remap
|
|
40
|
+
|
|
41
|
+
`intervalDetailComponent` receives `IntervalDetailInput`, the interval input plus absolute
|
|
42
|
+
`start`, `end`, and `viewTimezone`; presses still invoke onIntervalPress.
|
|
43
|
+
`selectable?: boolean` belongs only to the hook input, `RosterInput`, and defaults to false.
|
|
44
|
+
It is excluded from `RosterProps`.
|
|
45
|
+
`intervalDetailComponent`, `selectionLayout`, and `portalHost` belong to `RosterProps`
|
|
46
|
+
because the chassis mounts the content and layout. The chassis passes
|
|
47
|
+
`selectable: Boolean(intervalDetailComponent)` to `useRoster`.
|
|
48
|
+
`selectionLayout` defaults once in the chassis and wraps the body
|
|
49
|
+
before RosterLayout receives bodyZone. The selection never enters the body content
|
|
50
|
+
key. Missing lane/layer/bounds clear selection; valid selections use current data.
|
|
51
|
+
Pressing the selected interval again dismisses it; pressing another interval
|
|
52
|
+
switches selection. Cell and gap presses dismiss selection while still firing
|
|
53
|
+
`onCellPress` and `onGapPress`. These rules live in the hook and apply on native
|
|
54
|
+
and web; web outside press and Escape still dismiss.
|
|
55
|
+
On web, the layout captures document.activeElement before opening autofocus and
|
|
56
|
+
recaptures the focused lane when targetBounds changes while open. It uses onCloseAutoFocus to return focus after Escape only, leaving outside pointer focus intact. The browser's focus-visible ring styling is left to the host page.
|
|
57
|
+
`portalHost` defaults to a per-roster useId name. The interval-detail fixture switches
|
|
58
|
+
to an inspector column with the same node contract. Schedule does not yet support selection.
|
|
59
|
+
|
|
60
|
+
The private reconcileSelection helper resolves the stored absolute bounds against the
|
|
61
|
+
current lanes and window each render, matching source identity sets and choosing the
|
|
62
|
+
nearest bounds with a total difference of at most 1 ms for projection roundoff. Target bounds
|
|
63
|
+
include the lane offset and interval inset. Native details clamp horizontally and use the
|
|
64
|
+
top edge when neither below nor above fits. The browser gate isolates selection updates from Pressable state,
|
|
65
|
+
and separately exercises complete pointer presses and outside dismissal.
|
|
66
|
+
|
|
67
|
+
Native has no intercepting dismissal overlay; body presses reach the hook, and the detail
|
|
68
|
+
card captures its own presses. Web excludes the body wrapper from Radix outside
|
|
69
|
+
dismissal, leaving body presses to the hook while preserving true outside presses and Escape.
|
|
70
|
+
The hook toggles the selected interval closed, switches to another interval, and dismisses
|
|
71
|
+
on cell or gap presses while preserving their callbacks. The body restores horizontal
|
|
72
|
+
and vertical offsets from shared values on remount when selectionLayout changes.
|
|
73
|
+
A mount effect calls the horizontal ScrollView ref's scrollTo without animation;
|
|
74
|
+
native also retains contentOffset. LegendList restores initialScrollOffset through
|
|
75
|
+
its own web mount effect and native initial offset.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode, useEffect, useState } from 'react';
|
|
2
2
|
import { ScrollView, View } from 'react-native';
|
|
3
3
|
import type { BodyInput } from './roster.types';
|
|
4
4
|
|
|
@@ -14,10 +14,16 @@ export function RosterBodyLayout({
|
|
|
14
14
|
/** Virtualized lane collection within the measured viewport. */
|
|
15
15
|
listZone: ReactNode;
|
|
16
16
|
}) {
|
|
17
|
+
const [contentOffset] = useState(() => ({ x: scroll.x.get(), y: 0 }));
|
|
18
|
+
const bodyRef = scroll.bodyRef;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
bodyRef.current?.scrollTo({ x: contentOffset.x, animated: false });
|
|
21
|
+
}, [bodyRef, contentOffset]);
|
|
17
22
|
return (
|
|
18
23
|
<ScrollView
|
|
19
24
|
testID="roster-horizontal-scroll"
|
|
20
25
|
ref={scroll.bodyRef}
|
|
26
|
+
contentOffset={contentOffset}
|
|
21
27
|
horizontal
|
|
22
28
|
onScroll={scroll.onBodyScroll}
|
|
23
29
|
scrollEventThrottle={16}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ReactNode, useId } from 'react';
|
|
2
|
+
import { hasSource } from '../../core';
|
|
1
3
|
import { RosterGap } from '../layers/parts/gap';
|
|
2
4
|
import { RosterInterval } from '../layers/parts/interval';
|
|
3
5
|
import { RosterLaneLabel } from './lanes/parts/lane-label';
|
|
@@ -10,6 +12,8 @@ import { RosterHeader } from './parts/header';
|
|
|
10
12
|
import { RosterHeaderCell } from './parts/header-cell';
|
|
11
13
|
import { RosterLaneLabelColumn } from './parts/lane-label-column';
|
|
12
14
|
import type { RosterProps } from './roster.types';
|
|
15
|
+
import { RosterSelectionPopover } from './selection/selection-layout';
|
|
16
|
+
import type { SelectionLayoutProps } from './selection/selection-layout.types';
|
|
13
17
|
import { useRoster } from './use-roster';
|
|
14
18
|
|
|
15
19
|
const defaultEmptyZone = <RosterEmpty />;
|
|
@@ -17,6 +21,8 @@ const defaultCornerZone = <RosterCorner />;
|
|
|
17
21
|
|
|
18
22
|
export function Roster(props: RosterProps) {
|
|
19
23
|
const {
|
|
24
|
+
selection,
|
|
25
|
+
dismissSelection,
|
|
20
26
|
status,
|
|
21
27
|
orderedLanes,
|
|
22
28
|
laneState,
|
|
@@ -29,8 +35,10 @@ export function Roster(props: RosterProps) {
|
|
|
29
35
|
contentWidth,
|
|
30
36
|
viewport,
|
|
31
37
|
onLayout,
|
|
32
|
-
} = useRoster(props);
|
|
38
|
+
} = useRoster({ ...props, selectable: Boolean(props.intervalDetailComponent) });
|
|
33
39
|
const {
|
|
40
|
+
intervalDetailComponent: IntervalDetailComponent,
|
|
41
|
+
selectionLayout: SelectionLayout = RosterSelectionPopover,
|
|
34
42
|
emptyZone = defaultEmptyZone,
|
|
35
43
|
cornerZone = defaultCornerZone,
|
|
36
44
|
headerComponent: HeaderComponent = RosterHeader,
|
|
@@ -44,8 +52,30 @@ export function Roster(props: RosterProps) {
|
|
|
44
52
|
incompleteLabel = 'Availability may be incomplete',
|
|
45
53
|
neverSetLabel = 'No availability set',
|
|
46
54
|
} = props;
|
|
55
|
+
const hostId = useId();
|
|
47
56
|
if (status === 'empty') return emptyZone;
|
|
48
57
|
if (window.start === window.end) return null;
|
|
58
|
+
let targetBounds: SelectionLayoutProps['targetBounds'] = null;
|
|
59
|
+
if (selection) {
|
|
60
|
+
targetBounds = {
|
|
61
|
+
x: selection.rect.x,
|
|
62
|
+
y:
|
|
63
|
+
orderedLanes.findIndex((lane) => lane.id === selection.lane.id) * projection.rowHeight +
|
|
64
|
+
selection.rect.y,
|
|
65
|
+
width: selection.rect.width,
|
|
66
|
+
height: selection.rect.height,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
let contentZone: ReactNode = null;
|
|
70
|
+
if (selection && IntervalDetailComponent) {
|
|
71
|
+
contentZone = (
|
|
72
|
+
<IntervalDetailComponent
|
|
73
|
+
{...selection}
|
|
74
|
+
viewTimezone={projection.viewTimezone}
|
|
75
|
+
highlighted={hasSource(selection.rect.sources, props.highlightSource)}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
49
79
|
return (
|
|
50
80
|
<RosterLayout
|
|
51
81
|
style={props.style}
|
|
@@ -85,22 +115,37 @@ export function Roster(props: RosterProps) {
|
|
|
85
115
|
/>
|
|
86
116
|
}
|
|
87
117
|
bodyZone={
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
<SelectionLayout
|
|
119
|
+
portalHost={props.portalHost ?? `roster-${hostId}`}
|
|
120
|
+
open={selection !== null}
|
|
121
|
+
onDismiss={dismissSelection}
|
|
122
|
+
scroll={{
|
|
123
|
+
x: scroll.x,
|
|
124
|
+
y: scroll.y,
|
|
125
|
+
headerStyle: scroll.headerStyle,
|
|
126
|
+
labelStyle: scroll.labelStyle,
|
|
127
|
+
}}
|
|
128
|
+
targetBounds={targetBounds}
|
|
129
|
+
contentZone={contentZone}
|
|
130
|
+
anchorZone={
|
|
131
|
+
<BodyComponent
|
|
132
|
+
lanes={orderedLanes}
|
|
133
|
+
window={window}
|
|
134
|
+
geometryFor={geometryFor}
|
|
135
|
+
projection={projection}
|
|
136
|
+
scroll={scroll}
|
|
137
|
+
press={press}
|
|
138
|
+
ticks={ticks}
|
|
139
|
+
contentWidth={contentWidth}
|
|
140
|
+
viewport={viewport}
|
|
141
|
+
intervalComponent={intervalComponent}
|
|
142
|
+
gapComponent={gapComponent}
|
|
143
|
+
gridComponent={gridComponent}
|
|
144
|
+
highlightSource={props.highlightSource}
|
|
145
|
+
onIntervalHover={props.onIntervalHover}
|
|
146
|
+
incompleteLabel={incompleteLabel}
|
|
147
|
+
/>
|
|
148
|
+
}
|
|
104
149
|
/>
|
|
105
150
|
}
|
|
106
151
|
/>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment } from 'react';
|
|
2
2
|
import { Pressable } from 'react-native';
|
|
3
3
|
import type { Lane, LaneGeometry } from '../../../core';
|
|
4
|
+
import { hasSource } from '../../../core';
|
|
4
5
|
import { pressPoint } from '../../primitives/press-point';
|
|
5
6
|
import type { BodyInput } from '../roster.types';
|
|
6
7
|
import { intervalHoverProps } from './interval-hover';
|
|
@@ -56,10 +57,7 @@ export function LaneRow({
|
|
|
56
57
|
rect={rect}
|
|
57
58
|
layer={layer}
|
|
58
59
|
lane={lane}
|
|
59
|
-
highlighted={rect.sources
|
|
60
|
-
(source) =>
|
|
61
|
-
source.kind === highlightSource?.kind && source.id === highlightSource.id,
|
|
62
|
-
)}
|
|
60
|
+
highlighted={hasSource(rect.sources, highlightSource)}
|
|
63
61
|
/>
|
|
64
62
|
</Fragment>
|
|
65
63
|
))}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LegendList } from '@legendapp/list';
|
|
2
|
-
import { Profiler, type ProfilerOnRenderCallback } from 'react';
|
|
2
|
+
import { Profiler, type ProfilerOnRenderCallback, useState } from 'react';
|
|
3
3
|
import { LaneRow } from '../lanes/lane';
|
|
4
4
|
import type { BodyInput } from '../roster.types';
|
|
5
5
|
import { bodyContentKey } from '../utils/body-content-key';
|
|
@@ -23,9 +23,11 @@ export function RosterLaneList({
|
|
|
23
23
|
/** Development-only Profiler hook used by the gallery; not a supported customization point. */
|
|
24
24
|
onRowRender?: ProfilerOnRenderCallback;
|
|
25
25
|
}) {
|
|
26
|
+
const [initialScrollOffset] = useState(() => scroll.y.get());
|
|
26
27
|
return (
|
|
27
28
|
<LegendList
|
|
28
29
|
testID="roster-vertical-scroll"
|
|
30
|
+
initialScrollOffset={initialScrollOffset}
|
|
29
31
|
data={lanes}
|
|
30
32
|
extraData={bodyContentKey({
|
|
31
33
|
window,
|
|
@@ -21,6 +21,14 @@ import type {
|
|
|
21
21
|
WindowSpec,
|
|
22
22
|
} from '../../core';
|
|
23
23
|
import type { GapInput, IntervalInput } from '../layers/layers.types';
|
|
24
|
+
import type { SelectionLayoutProps } from './selection/selection-layout.types';
|
|
25
|
+
|
|
26
|
+
/** A selected interval: the rect input plus its absolute bounds and the view zone for formatting. */
|
|
27
|
+
export type IntervalDetailInput = IntervalInput & {
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
viewTimezone: string;
|
|
31
|
+
};
|
|
24
32
|
|
|
25
33
|
export type RosterProjection = Extract<Projection, { orientation: 'horizontal' }>;
|
|
26
34
|
export type RosterTick = { time: number; x: number; label: string; kind: 'day' | 'time' };
|
|
@@ -35,6 +43,8 @@ export type RosterScroll = {
|
|
|
35
43
|
labelStyle: StyleProp<ViewStyle>;
|
|
36
44
|
};
|
|
37
45
|
export type RosterInput = {
|
|
46
|
+
/** Retain interval selection on press, default false. Lives on RosterInput for hook consumers. */
|
|
47
|
+
selectable?: boolean;
|
|
38
48
|
lanes: Lane[];
|
|
39
49
|
windowSpec: WindowSpec;
|
|
40
50
|
minuteStep?: number;
|
|
@@ -52,6 +62,10 @@ export type RosterInput = {
|
|
|
52
62
|
pxPerMinute?: number;
|
|
53
63
|
};
|
|
54
64
|
export type RosterModel = {
|
|
65
|
+
/** Current interval with fresh lane, layer, and rect references and its absolute bounds, or null. */
|
|
66
|
+
selection: Omit<IntervalDetailInput, 'highlighted' | 'viewTimezone'> | null;
|
|
67
|
+
/** Close the selected interval details. */
|
|
68
|
+
dismissSelection: () => void;
|
|
55
69
|
window: Window;
|
|
56
70
|
projection: RosterProjection;
|
|
57
71
|
orderedLanes: Lane[];
|
|
@@ -129,8 +143,23 @@ export type RosterStyleProps = {
|
|
|
129
143
|
/** Width of the corner and lane label column, default 180. */
|
|
130
144
|
laneLabelWidth?: number;
|
|
131
145
|
};
|
|
132
|
-
export type RosterProps = RosterInput &
|
|
146
|
+
export type RosterProps = Omit<RosterInput, 'selectable'> &
|
|
133
147
|
RosterStyleProps & {
|
|
148
|
+
/**
|
|
149
|
+
* Selected interval content; absent means interval presses retain no selection.
|
|
150
|
+
* Lives on RosterProps because the chassis mounts content and enables useRoster selection.
|
|
151
|
+
*/
|
|
152
|
+
intervalDetailComponent?: ComponentType<IntervalDetailInput>;
|
|
153
|
+
/**
|
|
154
|
+
* Presentation strategy for the body and selected details; defaults to RosterSelectionPopover.
|
|
155
|
+
* Lives on RosterProps because only the chassis mounts the layout, not useRoster.
|
|
156
|
+
*/
|
|
157
|
+
selectionLayout?: ComponentType<SelectionLayoutProps>;
|
|
158
|
+
/**
|
|
159
|
+
* Native portal host name; defaults to a unique useId name for this roster.
|
|
160
|
+
* Lives on RosterProps because only the chassis mounts the layout, not useRoster.
|
|
161
|
+
*/
|
|
162
|
+
portalHost?: string;
|
|
134
163
|
incompleteLabel?: string;
|
|
135
164
|
neverSetLabel?: string;
|
|
136
165
|
/** Label, differing lane zone, effective flag, and completeness notice. */
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useId, useState } from 'react';
|
|
2
|
+
import { BackHandler, ScrollView, View } from 'react-native';
|
|
3
|
+
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
|
|
4
|
+
import { Portal, PortalHost } from '../../primitives/portal';
|
|
5
|
+
import type { SelectionLayoutProps } from './selection-layout.types';
|
|
6
|
+
|
|
7
|
+
const overlay = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } as const;
|
|
8
|
+
|
|
9
|
+
/** Native selection presentation with a local portal destination and shared scroll translation. */
|
|
10
|
+
export function RosterSelectionPopover({
|
|
11
|
+
anchorZone,
|
|
12
|
+
contentZone,
|
|
13
|
+
targetBounds,
|
|
14
|
+
open,
|
|
15
|
+
onDismiss,
|
|
16
|
+
portalHost,
|
|
17
|
+
scroll,
|
|
18
|
+
}: SelectionLayoutProps) {
|
|
19
|
+
const name = useId();
|
|
20
|
+
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
|
21
|
+
const [content, setContent] = useState({ width: 0, height: 0 });
|
|
22
|
+
const maxWidth = Math.max(0, viewport.width - 8);
|
|
23
|
+
const maxHeight = Math.max(0, viewport.height - 8);
|
|
24
|
+
const size = { maxWidth, maxHeight, flexShrink: 1 };
|
|
25
|
+
const { x, y } = scroll;
|
|
26
|
+
const position = useAnimatedStyle(() => {
|
|
27
|
+
const below = targetBounds ? targetBounds.y + targetBounds.height + 4 - y.get() : 0;
|
|
28
|
+
const above = targetBounds ? targetBounds.y - content.height - 4 - y.get() : 0;
|
|
29
|
+
const maxTop = Math.max(0, viewport.height - content.height);
|
|
30
|
+
return {
|
|
31
|
+
left: Math.max(0, Math.min((targetBounds?.x ?? 0) - x.get(), viewport.width - content.width)),
|
|
32
|
+
top: below >= 0 && below <= maxTop ? below : above >= 0 && above <= maxTop ? above : 0,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!open) return;
|
|
37
|
+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
38
|
+
onDismiss();
|
|
39
|
+
return true;
|
|
40
|
+
});
|
|
41
|
+
return () => subscription.remove();
|
|
42
|
+
}, [open, onDismiss]);
|
|
43
|
+
let detailZone: ReactNode = null;
|
|
44
|
+
if (open && targetBounds && maxWidth > 0 && maxHeight > 0) {
|
|
45
|
+
detailZone = (
|
|
46
|
+
<Portal hostName={portalHost} name={name}>
|
|
47
|
+
<Animated.View style={[{ position: 'absolute', maxWidth, maxHeight }, position]}>
|
|
48
|
+
<View
|
|
49
|
+
accessibilityRole="summary"
|
|
50
|
+
onStartShouldSetResponder={() => true}
|
|
51
|
+
style={size}
|
|
52
|
+
onLayout={({ nativeEvent }) =>
|
|
53
|
+
setContent({
|
|
54
|
+
width: nativeEvent.layout.width,
|
|
55
|
+
height: nativeEvent.layout.height,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
<ScrollView style={size} nestedScrollEnabled>
|
|
60
|
+
<ScrollView
|
|
61
|
+
horizontal
|
|
62
|
+
style={{ maxWidth, flexGrow: 0, flexShrink: 0 }}
|
|
63
|
+
nestedScrollEnabled
|
|
64
|
+
>
|
|
65
|
+
{contentZone}
|
|
66
|
+
</ScrollView>
|
|
67
|
+
</ScrollView>
|
|
68
|
+
</View>
|
|
69
|
+
</Animated.View>
|
|
70
|
+
</Portal>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<View
|
|
75
|
+
style={{ flex: 1, minHeight: 0 }}
|
|
76
|
+
onLayout={({ nativeEvent }) =>
|
|
77
|
+
setViewport({
|
|
78
|
+
width: nativeEvent.layout.width,
|
|
79
|
+
height: nativeEvent.layout.height,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
{anchorZone}
|
|
84
|
+
<View pointerEvents="box-none" style={overlay}>
|
|
85
|
+
<PortalHost name={portalHost} />
|
|
86
|
+
</View>
|
|
87
|
+
{detailZone}
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { RosterScroll } from '../roster.types';
|
|
3
|
+
|
|
4
|
+
/** Presentation-only contract shared by native, web, and consumer strategies. */
|
|
5
|
+
export type SelectionLayoutProps = {
|
|
6
|
+
/** Mounted body node, rendered once beneath or beside the details. */
|
|
7
|
+
anchorZone: ReactNode;
|
|
8
|
+
/** Mounted detail node, or null when closed. */
|
|
9
|
+
contentZone: ReactNode;
|
|
10
|
+
/** Interval bounds in body-content coordinates, before scroll translation. */
|
|
11
|
+
targetBounds: { x: number; y: number; width: number; height: number } | null;
|
|
12
|
+
/** Whether the detail presentation is open. */
|
|
13
|
+
open: boolean;
|
|
14
|
+
/** Report outside press, hardware back, Escape, or a presentation close action. */
|
|
15
|
+
onDismiss: () => void;
|
|
16
|
+
/** Unique native destination name owned by this selection layout. */
|
|
17
|
+
portalHost: string;
|
|
18
|
+
/** Shared offsets follow body scrolling without updating React state. */
|
|
19
|
+
scroll: Pick<RosterScroll, 'x' | 'y' | 'headerStyle' | 'labelStyle'>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Anchor, Content, Portal, Root } from '@radix-ui/react-popover';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import Animated from 'react-native-reanimated';
|
|
4
|
+
import type { SelectionLayoutProps } from './selection-layout.types';
|
|
5
|
+
|
|
6
|
+
/** Web selection presentation; Radix owns outside press, Escape, and collisions. */
|
|
7
|
+
export function RosterSelectionPopover({
|
|
8
|
+
anchorZone,
|
|
9
|
+
contentZone,
|
|
10
|
+
targetBounds,
|
|
11
|
+
open,
|
|
12
|
+
onDismiss,
|
|
13
|
+
scroll,
|
|
14
|
+
}: SelectionLayoutProps) {
|
|
15
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
16
|
+
const returnFocusRef = useRef<Element | null>(null);
|
|
17
|
+
const keyboardDismissRef = useRef(false);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (open && targetBounds && bodyRef.current?.contains(document.activeElement)) {
|
|
20
|
+
returnFocusRef.current = document.activeElement;
|
|
21
|
+
}
|
|
22
|
+
}, [open, targetBounds]);
|
|
23
|
+
return (
|
|
24
|
+
<Root
|
|
25
|
+
open={open}
|
|
26
|
+
onOpenChange={(next) => {
|
|
27
|
+
if (!next) onDismiss();
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<div
|
|
31
|
+
ref={bodyRef}
|
|
32
|
+
style={{
|
|
33
|
+
position: 'relative',
|
|
34
|
+
display: 'flex',
|
|
35
|
+
flexDirection: 'column',
|
|
36
|
+
flex: 1,
|
|
37
|
+
minHeight: 0,
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{anchorZone}
|
|
41
|
+
<Animated.View style={[{ position: 'absolute', top: 0, left: 0 }, scroll.headerStyle]}>
|
|
42
|
+
<Animated.View style={scroll.labelStyle}>
|
|
43
|
+
<Anchor asChild>
|
|
44
|
+
<div
|
|
45
|
+
data-testid="roster-selection-target"
|
|
46
|
+
style={{
|
|
47
|
+
position: 'absolute',
|
|
48
|
+
left: targetBounds?.x ?? 0,
|
|
49
|
+
top: targetBounds ? targetBounds.y + targetBounds.height : 0,
|
|
50
|
+
width: 0,
|
|
51
|
+
height: 0,
|
|
52
|
+
pointerEvents: 'none',
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
</Anchor>
|
|
56
|
+
</Animated.View>
|
|
57
|
+
</Animated.View>
|
|
58
|
+
</div>
|
|
59
|
+
<Portal>
|
|
60
|
+
<Content
|
|
61
|
+
onOpenAutoFocus={() => {
|
|
62
|
+
returnFocusRef.current = document.activeElement;
|
|
63
|
+
keyboardDismissRef.current = false;
|
|
64
|
+
}}
|
|
65
|
+
onEscapeKeyDown={() => {
|
|
66
|
+
keyboardDismissRef.current = true;
|
|
67
|
+
}}
|
|
68
|
+
onCloseAutoFocus={(event) => {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
if (keyboardDismissRef.current && returnFocusRef.current instanceof HTMLElement) {
|
|
71
|
+
returnFocusRef.current.focus({ preventScroll: true });
|
|
72
|
+
}
|
|
73
|
+
}}
|
|
74
|
+
onInteractOutside={(event) => {
|
|
75
|
+
if (bodyRef.current?.contains(event.target as Node)) event.preventDefault();
|
|
76
|
+
}}
|
|
77
|
+
sideOffset={4}
|
|
78
|
+
align="start"
|
|
79
|
+
updatePositionStrategy="always"
|
|
80
|
+
aria-label="Interval details"
|
|
81
|
+
style={{ zIndex: 1000 }}
|
|
82
|
+
>
|
|
83
|
+
{contentZone}
|
|
84
|
+
</Content>
|
|
85
|
+
</Portal>
|
|
86
|
+
</Root>
|
|
87
|
+
);
|
|
88
|
+
}
|