react-native-roster 0.0.1 → 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 +209 -32
- 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 +9 -0
- package/dist/src/components/roster/body-layout.d.ts.map +1 -0
- package/dist/src/components/roster/body-layout.js +15 -0
- package/dist/src/components/roster/body-layout.js.map +1 -0
- 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 +34 -27
- package/dist/src/components/roster/index.js.map +1 -1
- package/dist/src/components/roster/lanes/lane.d.ts +2 -2
- package/dist/src/components/roster/lanes/lane.d.ts.map +1 -1
- package/dist/src/components/roster/lanes/lane.js +4 -8
- package/dist/src/components/roster/lanes/lane.js.map +1 -1
- package/dist/src/components/roster/parts/body.d.ts +3 -3
- package/dist/src/components/roster/parts/body.d.ts.map +1 -1
- package/dist/src/components/roster/parts/body.js +5 -20
- package/dist/src/components/roster/parts/body.js.map +1 -1
- package/dist/src/components/roster/parts/header-cell.d.ts +2 -4
- package/dist/src/components/roster/parts/header-cell.d.ts.map +1 -1
- package/dist/src/components/roster/parts/header-cell.js.map +1 -1
- package/dist/src/components/roster/parts/header.d.ts +1 -1
- package/dist/src/components/roster/parts/header.d.ts.map +1 -1
- package/dist/src/components/roster/parts/header.js +2 -2
- package/dist/src/components/roster/parts/header.js.map +1 -1
- package/dist/src/components/roster/parts/lane-label-column.d.ts +1 -1
- package/dist/src/components/roster/parts/lane-label-column.d.ts.map +1 -1
- package/dist/src/components/roster/parts/lane-label-column.js +2 -2
- package/dist/src/components/roster/parts/lane-label-column.js.map +1 -1
- package/dist/src/components/roster/parts/lane-list.d.ts +7 -0
- package/dist/src/components/roster/parts/lane-list.d.ts.map +1 -0
- package/dist/src/components/roster/parts/lane-list.js +26 -0
- package/dist/src/components/roster/parts/lane-list.js.map +1 -0
- package/dist/src/components/roster/roster.types.d.ts +48 -21
- 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/roster/utils/body-content-key.d.ts +1 -1
- package/dist/src/components/roster/utils/body-content-key.d.ts.map +1 -1
- package/dist/src/components/roster/utils/body-content-key.js +10 -10
- package/dist/src/components/roster/utils/body-content-key.js.map +1 -1
- package/dist/src/components/schedule/days/day.d.ts +2 -2
- package/dist/src/components/schedule/days/day.d.ts.map +1 -1
- package/dist/src/components/schedule/days/day.js +2 -11
- package/dist/src/components/schedule/days/day.js.map +1 -1
- package/dist/src/components/schedule/days/header-layout.d.ts +1 -1
- package/dist/src/components/schedule/days/header-layout.d.ts.map +1 -1
- package/dist/src/components/schedule/days/parts/column.d.ts +1 -1
- package/dist/src/components/schedule/days/parts/column.d.ts.map +1 -1
- package/dist/src/components/schedule/days/parts/column.js +4 -8
- package/dist/src/components/schedule/days/parts/column.js.map +1 -1
- package/dist/src/components/schedule/index.d.ts.map +1 -1
- package/dist/src/components/schedule/index.js +7 -8
- package/dist/src/components/schedule/index.js.map +1 -1
- package/dist/src/components/schedule/schedule.types.d.ts +17 -17
- package/dist/src/components/schedule/schedule.types.d.ts.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 +6 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +10 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/nativewind/index.d.ts +28 -27
- package/dist/src/nativewind/index.d.ts.map +1 -1
- package/docs/migrations.md +61 -0
- package/llms.txt +112 -30
- package/package.json +12 -5
- package/src/components/layers/README.md +7 -3
- package/src/components/primitives/README.md +13 -2
- package/src/components/primitives/portal.tsx +48 -0
- package/src/components/roster/README.md +62 -5
- package/src/components/roster/body-layout.tsx +38 -0
- package/src/components/roster/index.tsx +109 -46
- package/src/components/roster/lanes/lane.tsx +16 -13
- package/src/components/roster/parts/body.tsx +17 -84
- package/src/components/roster/parts/header-cell.tsx +2 -2
- package/src/components/roster/parts/header.tsx +7 -2
- package/src/components/roster/parts/lane-label-column.tsx +2 -2
- package/src/components/roster/parts/lane-list.tsx +76 -0
- package/src/components/roster/roster.types.ts +47 -17
- 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/roster/utils/body-content-key.ts +13 -13
- package/src/components/schedule/README.md +8 -3
- package/src/components/schedule/days/day.tsx +31 -18
- package/src/components/schedule/days/header-layout.tsx +1 -1
- package/src/components/schedule/days/parts/column.tsx +10 -12
- package/src/components/schedule/index.tsx +20 -21
- package/src/components/schedule/schedule.types.ts +13 -13
- 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 +7 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode, RefObject } from 'react';
|
|
1
|
+
import type { ComponentType, ReactNode, RefObject } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
LayoutChangeEvent,
|
|
4
4
|
NativeScrollEvent,
|
|
@@ -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[];
|
|
@@ -75,15 +89,16 @@ export type LaneLabelInput = {
|
|
|
75
89
|
incompleteLabel: string;
|
|
76
90
|
neverSetLabel: string;
|
|
77
91
|
};
|
|
92
|
+
export type HeaderCellInput = { tick: RosterTick };
|
|
78
93
|
export type GridInput = { ticks: RosterTick[]; contentWidth: number };
|
|
79
94
|
export type HeaderInput = Pick<RosterModel, 'ticks' | 'projection' | 'scroll' | 'contentWidth'> & {
|
|
80
95
|
/** Time label filler, positioned by the header. */
|
|
81
|
-
|
|
96
|
+
headerCellComponent: ComponentType<HeaderCellInput>;
|
|
82
97
|
};
|
|
83
98
|
export type LabelColumnInput = Pick<RosterModel, 'projection' | 'scroll'> & {
|
|
84
99
|
labels: LaneLabelInput[];
|
|
85
100
|
/** Lane label filler, positioned at the fixed row height. */
|
|
86
|
-
|
|
101
|
+
laneLabelComponent: ComponentType<LaneLabelInput>;
|
|
87
102
|
};
|
|
88
103
|
export type BodyInput = Pick<
|
|
89
104
|
RosterModel,
|
|
@@ -101,11 +116,11 @@ export type BodyInput = Pick<
|
|
|
101
116
|
onIntervalHover?: (rect: Rect, lane: Lane) => void;
|
|
102
117
|
incompleteLabel?: string;
|
|
103
118
|
/** Covered rect view; position it using the final rect bounds, as RosterInterval does. */
|
|
104
|
-
|
|
119
|
+
intervalComponent: ComponentType<IntervalInput>;
|
|
105
120
|
/** Removed rect filler, placed inside a pressable by LaneRow. */
|
|
106
|
-
|
|
121
|
+
gapComponent: ComponentType<GapInput>;
|
|
107
122
|
/** Noninteractive tick lines behind every lane, sized to the content width. */
|
|
108
|
-
|
|
123
|
+
gridComponent: ComponentType<GridInput>;
|
|
109
124
|
};
|
|
110
125
|
/**
|
|
111
126
|
* Chrome style props and their NativeWind class twins. Class props resolve only
|
|
@@ -128,28 +143,43 @@ export type RosterStyleProps = {
|
|
|
128
143
|
/** Width of the corner and lane label column, default 180. */
|
|
129
144
|
laneLabelWidth?: number;
|
|
130
145
|
};
|
|
131
|
-
export type RosterProps = RosterInput &
|
|
146
|
+
export type RosterProps = Omit<RosterInput, 'selectable'> &
|
|
132
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;
|
|
133
163
|
incompleteLabel?: string;
|
|
134
164
|
neverSetLabel?: string;
|
|
135
165
|
/** Label, differing lane zone, effective flag, and completeness notice. */
|
|
136
|
-
|
|
166
|
+
laneLabelComponent?: ComponentType<LaneLabelInput>;
|
|
137
167
|
/** Time label within one positioned header tick. */
|
|
138
|
-
|
|
168
|
+
headerCellComponent?: ComponentType<HeaderCellInput>;
|
|
139
169
|
/** Covered rect view; use rect bounds for absolute position, rect.z for stacking, and pointerEvents="none" for row hit testing. */
|
|
140
|
-
|
|
170
|
+
intervalComponent?: ComponentType<IntervalInput>;
|
|
141
171
|
/** Removed rect content; LaneRow supplies the invisible pressable. */
|
|
142
|
-
|
|
172
|
+
gapComponent?: ComponentType<GapInput>;
|
|
143
173
|
/** Tick lines behind the lanes; RosterGrid draws one hairline per tick. */
|
|
144
|
-
|
|
174
|
+
gridComponent?: ComponentType<GridInput>;
|
|
145
175
|
/** Empty roster content, conventionally the text No lanes. */
|
|
146
|
-
emptyZone?:
|
|
176
|
+
emptyZone?: ReactNode;
|
|
147
177
|
/** Top-left cell above the lane labels; RosterCorner renders nothing. */
|
|
148
|
-
cornerZone?:
|
|
178
|
+
cornerZone?: ReactNode;
|
|
149
179
|
/** Frozen time header, supplied with ticks, scale, and shared scrolling. */
|
|
150
|
-
|
|
180
|
+
headerComponent?: ComponentType<HeaderInput>;
|
|
151
181
|
/** Frozen labels, supplied with ordered lanes and per-lane flag and completeness. */
|
|
152
|
-
|
|
182
|
+
laneLabelColumnComponent?: ComponentType<LabelColumnInput>;
|
|
153
183
|
/** Virtualized lane body, supplied with geometry, projection, scrolling, and press resolution. */
|
|
154
|
-
|
|
184
|
+
bodyComponent?: ComponentType<BodyInput>;
|
|
155
185
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import type { Lane, Window } from '../../core';
|
|
4
|
+
import { layoutLane, snapToStep, timeAtX } from '../../core';
|
|
5
|
+
import { hitTest } from '../../core/hit-test';
|
|
6
|
+
import type { RosterInput, RosterModel, RosterProjection } from './roster.types';
|
|
7
|
+
|
|
8
|
+
export type SelectedInterval = NonNullable<RosterModel['selection']>;
|
|
9
|
+
|
|
10
|
+
// Isolate the stable press ref so it does not prevent compilation of derived roster values.
|
|
11
|
+
export function useRosterPress(
|
|
12
|
+
input: RosterInput,
|
|
13
|
+
window: Window,
|
|
14
|
+
projection: RosterProjection,
|
|
15
|
+
width: number,
|
|
16
|
+
selection: RosterModel['selection'],
|
|
17
|
+
setSelected: Dispatch<SetStateAction<SelectedInterval | null>>,
|
|
18
|
+
) {
|
|
19
|
+
const currentPress = { input, window, projection, width, selection };
|
|
20
|
+
const pressInput = useRef(currentPress);
|
|
21
|
+
pressInput.current = currentPress;
|
|
22
|
+
const [press] = useState(() => {
|
|
23
|
+
return function press(lane: Lane, pointX: number, pointY: number): void {
|
|
24
|
+
const { input, window, projection, width, selection } = pressInput.current;
|
|
25
|
+
const { minuteStep = 60, onIntervalPress, onGapPress, onCellPress } = input;
|
|
26
|
+
const { rowHeight, viewTimezone } = projection;
|
|
27
|
+
if (
|
|
28
|
+
!Number.isFinite(pointX) ||
|
|
29
|
+
!Number.isFinite(pointY) ||
|
|
30
|
+
pointX < 0 ||
|
|
31
|
+
pointX >= width ||
|
|
32
|
+
pointY < 0 ||
|
|
33
|
+
pointY >= rowHeight
|
|
34
|
+
)
|
|
35
|
+
return;
|
|
36
|
+
const hit = hitTest(lane, layoutLane(lane, window, projection), pointX, pointY);
|
|
37
|
+
if (hit?.kind === 'interval') {
|
|
38
|
+
if (input.selectable) {
|
|
39
|
+
const layer = lane.layers.find((layer) => layer.id === hit.rect.layerId);
|
|
40
|
+
// layoutLane supplied the hit rect's layerId, so this should succeed; guard future refactors.
|
|
41
|
+
if (layer === undefined) return;
|
|
42
|
+
// Compare reconciled geometry so resizing and cache eviction preserve toggle identity.
|
|
43
|
+
setSelected(
|
|
44
|
+
selection?.lane.id === lane.id &&
|
|
45
|
+
selection.rect.layerId === hit.rect.layerId &&
|
|
46
|
+
selection.rect.x === hit.rect.x &&
|
|
47
|
+
selection.rect.width === hit.rect.width
|
|
48
|
+
? null
|
|
49
|
+
: {
|
|
50
|
+
rect: hit.rect,
|
|
51
|
+
layer,
|
|
52
|
+
lane,
|
|
53
|
+
start: timeAtX(projection, window, hit.rect.x),
|
|
54
|
+
end: timeAtX(projection, window, hit.rect.x + hit.rect.width),
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
onIntervalPress?.(hit.rect, lane);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (selection) setSelected(null);
|
|
62
|
+
if (hit?.kind === 'gap') {
|
|
63
|
+
onGapPress?.(hit.rect, lane);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const time = Math.max(
|
|
67
|
+
window.start,
|
|
68
|
+
snapToStep(timeAtX(projection, window, pointX), minuteStep, viewTimezone),
|
|
69
|
+
);
|
|
70
|
+
if (time < window.end) onCellPress?.(lane, time);
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
return press;
|
|
74
|
+
}
|
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
import { useRef, useState } from 'react';
|
|
2
2
|
import type { ScrollView } from 'react-native';
|
|
3
3
|
import { makeMutable, useAnimatedStyle } from 'react-native-reanimated';
|
|
4
|
-
import type { Lane, LaneGeometry } from '../../core';
|
|
5
|
-
import {
|
|
6
|
-
byLabel,
|
|
7
|
-
coverageFor,
|
|
8
|
-
flagFor,
|
|
9
|
-
layoutLane,
|
|
10
|
-
snapToStep,
|
|
11
|
-
timeAtX,
|
|
12
|
-
windowFor,
|
|
13
|
-
} from '../../core';
|
|
14
|
-
import { hitTest } from '../../core/hit-test';
|
|
4
|
+
import type { Lane, LaneGeometry, Rect, Window } from '../../core';
|
|
5
|
+
import { byLabel, coverageFor, flagFor, layoutLane, timeAtX, windowFor } from '../../core';
|
|
15
6
|
import type { RosterInput, RosterModel, RosterProjection } from './roster.types';
|
|
7
|
+
import { type SelectedInterval, useRosterPress } from './use-roster-press';
|
|
16
8
|
import { ticksFor } from './utils/ticks';
|
|
17
9
|
|
|
18
10
|
export function useRoster(input: RosterInput): RosterModel {
|
|
@@ -25,6 +17,10 @@ export function useRoster(input: RosterInput): RosterModel {
|
|
|
25
17
|
pxPerMinute = 0.5,
|
|
26
18
|
onNavigate,
|
|
27
19
|
} = input;
|
|
20
|
+
const [selected, setSelected] = useState<SelectedInterval | null>(null);
|
|
21
|
+
function dismissSelection() {
|
|
22
|
+
setSelected(null);
|
|
23
|
+
}
|
|
28
24
|
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
|
29
25
|
// The pinned compiler lint cannot resolve useSharedValue's built-in type.
|
|
30
26
|
// These shared values only track offsets, so no animation needs cancellation.
|
|
@@ -60,40 +56,19 @@ export function useRoster(input: RosterInput): RosterModel {
|
|
|
60
56
|
function geometryFor(lane: Lane): LaneGeometry {
|
|
61
57
|
return layoutLane(lane, window, projection);
|
|
62
58
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
!Number.isFinite(pointY) ||
|
|
74
|
-
pointX < 0 ||
|
|
75
|
-
pointX >= width ||
|
|
76
|
-
pointY < 0 ||
|
|
77
|
-
pointY >= rowHeight
|
|
78
|
-
)
|
|
79
|
-
return;
|
|
80
|
-
const hit = hitTest(lane, layoutLane(lane, window, projection), pointX, pointY);
|
|
81
|
-
if (hit?.kind === 'interval') {
|
|
82
|
-
onIntervalPress?.(hit.rect, lane);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
if (hit?.kind === 'gap') {
|
|
86
|
-
onGapPress?.(hit.rect, lane);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const time = Math.max(
|
|
90
|
-
window.start,
|
|
91
|
-
snapToStep(timeAtX(projection, window, pointX), minuteStep, viewTimezone),
|
|
92
|
-
);
|
|
93
|
-
if (time < window.end) onCellPress?.(lane, time);
|
|
94
|
-
};
|
|
95
|
-
});
|
|
59
|
+
const selection = reconcileSelection(
|
|
60
|
+
selected,
|
|
61
|
+
lanes,
|
|
62
|
+
window,
|
|
63
|
+
projection,
|
|
64
|
+
input.selectable ?? false,
|
|
65
|
+
);
|
|
66
|
+
// Discard invalid selection during reconciliation, before rendering any stale detail.
|
|
67
|
+
if (selected && !selection) setSelected(null);
|
|
68
|
+
const press = useRosterPress(input, window, projection, contentWidth, selection, setSelected);
|
|
96
69
|
return {
|
|
70
|
+
selection,
|
|
71
|
+
dismissSelection,
|
|
97
72
|
window,
|
|
98
73
|
projection,
|
|
99
74
|
orderedLanes,
|
|
@@ -131,3 +106,41 @@ export function useRoster(input: RosterInput): RosterModel {
|
|
|
131
106
|
},
|
|
132
107
|
};
|
|
133
108
|
}
|
|
109
|
+
|
|
110
|
+
// Match provenance and nearest bounds while preserving the stored display values.
|
|
111
|
+
function reconcileSelection(
|
|
112
|
+
selected: SelectedInterval | null,
|
|
113
|
+
lanes: Lane[],
|
|
114
|
+
window: Window,
|
|
115
|
+
projection: RosterProjection,
|
|
116
|
+
enabled: boolean,
|
|
117
|
+
): RosterModel['selection'] {
|
|
118
|
+
if (selected && enabled) {
|
|
119
|
+
const lane = lanes.find((lane) => lane.id === selected.lane.id);
|
|
120
|
+
const layer = lane?.layers.find((layer) => layer.id === selected.layer.id);
|
|
121
|
+
if (lane && layer) {
|
|
122
|
+
const sources = new Set(
|
|
123
|
+
selected.rect.sources.map((source) => JSON.stringify([source.kind, source.id])),
|
|
124
|
+
);
|
|
125
|
+
let rect: Rect | undefined;
|
|
126
|
+
let nearest = 1;
|
|
127
|
+
for (const candidate of layoutLane(lane, window, projection).rects) {
|
|
128
|
+
if (candidate.layerId !== layer.id) continue;
|
|
129
|
+
const identities = new Set(
|
|
130
|
+
candidate.sources.map((source) => JSON.stringify([source.kind, source.id])),
|
|
131
|
+
);
|
|
132
|
+
if (identities.size !== sources.size || ![...identities].every((id) => sources.has(id)))
|
|
133
|
+
continue;
|
|
134
|
+
const difference =
|
|
135
|
+
Math.abs(timeAtX(projection, window, candidate.x) - selected.start) +
|
|
136
|
+
Math.abs(timeAtX(projection, window, candidate.x + candidate.width) - selected.end);
|
|
137
|
+
if (difference <= nearest && (!rect || difference < nearest)) {
|
|
138
|
+
nearest = difference;
|
|
139
|
+
rect = candidate;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (rect) return { rect, layer, lane, start: selected.start, end: selected.end };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { BodyInput } from '../roster.types';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
let
|
|
3
|
+
const identityIds = new WeakMap<object, number>();
|
|
4
|
+
let nextIdentityId = 0;
|
|
5
5
|
|
|
6
|
-
function
|
|
7
|
-
let id =
|
|
6
|
+
function identityId(identity: object): number {
|
|
7
|
+
let id = identityIds.get(identity);
|
|
8
8
|
if (id === undefined) {
|
|
9
|
-
id =
|
|
10
|
-
|
|
9
|
+
id = nextIdentityId++;
|
|
10
|
+
identityIds.set(identity, id);
|
|
11
11
|
}
|
|
12
12
|
return id;
|
|
13
13
|
}
|
|
@@ -16,8 +16,8 @@ export function bodyContentKey({
|
|
|
16
16
|
window,
|
|
17
17
|
projection,
|
|
18
18
|
highlightSource,
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
intervalComponent,
|
|
20
|
+
gapComponent,
|
|
21
21
|
onIntervalHover,
|
|
22
22
|
incompleteLabel,
|
|
23
23
|
}: Pick<
|
|
@@ -25,8 +25,8 @@ export function bodyContentKey({
|
|
|
25
25
|
| 'window'
|
|
26
26
|
| 'projection'
|
|
27
27
|
| 'highlightSource'
|
|
28
|
-
| '
|
|
29
|
-
| '
|
|
28
|
+
| 'intervalComponent'
|
|
29
|
+
| 'gapComponent'
|
|
30
30
|
| 'onIntervalHover'
|
|
31
31
|
| 'incompleteLabel'
|
|
32
32
|
>): string {
|
|
@@ -38,9 +38,9 @@ export function bodyContentKey({
|
|
|
38
38
|
projection.pxPerMinute,
|
|
39
39
|
highlightSource?.kind,
|
|
40
40
|
highlightSource?.id,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
onIntervalHover &&
|
|
41
|
+
identityId(intervalComponent),
|
|
42
|
+
identityId(gapComponent),
|
|
43
|
+
onIntervalHover && identityId(onIntervalHover),
|
|
44
44
|
incompleteLabel,
|
|
45
45
|
]);
|
|
46
46
|
}
|
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
This feature is about a schedule; its children are days.
|
|
4
4
|
|
|
5
5
|
- Root entry exports: `Schedule`, `useSchedule`, the `ScheduleXxx` parts, and
|
|
6
|
-
every `ScheduleXxxInput`
|
|
6
|
+
every `ScheduleXxxInput` slot input type from `schedule.types.ts`
|
|
7
7
|
|
|
8
8
|
- `index.tsx`: the chassis; measures the viewport, calls `useSchedule`, composes zones
|
|
9
9
|
- `use-schedule.ts`, `use-schedule-viewport.ts`: the hook and the width context
|
|
10
10
|
- `layout.tsx`: arranges header, gutter, and days regions only
|
|
11
|
-
- `schedule.types.ts`: props, model, and every
|
|
11
|
+
- `schedule.types.ts`: props, model, and every slot input type
|
|
12
12
|
- `parts/`: collection-level parts (gutter, incomplete)
|
|
13
13
|
- `days/`: `ScheduleDay`, the day and day-header layouts, and day-local parts
|
|
14
14
|
- `utils/days.ts`: header dates, now position, and transition bounds
|
|
15
15
|
|
|
16
|
-
Interval and gap
|
|
16
|
+
Interval and gap components live in `../layers`; press geometry and `regionStyle`
|
|
17
17
|
in `../primitives`. Zone contracts are documented in the README's Schedule
|
|
18
18
|
section and in `llms.txt`. Tests: `test/components/schedule`.
|
|
19
|
+
|
|
20
|
+
The chassis binds default component types once; ScheduleDay mounts components
|
|
21
|
+
with day data and passes nodes into ScheduleDayLayout. There is no black-box day
|
|
22
|
+
list to split: days, transitions, and rects already map in their owning parts.
|
|
23
|
+
Schedule has ten input-bearing component slots and no zero-argument singleton slots.
|