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.
Files changed (122) hide show
  1. package/README.md +209 -32
  2. package/dist/src/components/primitives/portal.d.ts +12 -0
  3. package/dist/src/components/primitives/portal.d.ts.map +1 -0
  4. package/dist/src/components/primitives/portal.js +41 -0
  5. package/dist/src/components/primitives/portal.js.map +1 -0
  6. package/dist/src/components/roster/body-layout.d.ts +9 -0
  7. package/dist/src/components/roster/body-layout.d.ts.map +1 -0
  8. package/dist/src/components/roster/body-layout.js +15 -0
  9. package/dist/src/components/roster/body-layout.js.map +1 -0
  10. package/dist/src/components/roster/index.d.ts +2 -1
  11. package/dist/src/components/roster/index.d.ts.map +1 -1
  12. package/dist/src/components/roster/index.js +34 -27
  13. package/dist/src/components/roster/index.js.map +1 -1
  14. package/dist/src/components/roster/lanes/lane.d.ts +2 -2
  15. package/dist/src/components/roster/lanes/lane.d.ts.map +1 -1
  16. package/dist/src/components/roster/lanes/lane.js +4 -8
  17. package/dist/src/components/roster/lanes/lane.js.map +1 -1
  18. package/dist/src/components/roster/parts/body.d.ts +3 -3
  19. package/dist/src/components/roster/parts/body.d.ts.map +1 -1
  20. package/dist/src/components/roster/parts/body.js +5 -20
  21. package/dist/src/components/roster/parts/body.js.map +1 -1
  22. package/dist/src/components/roster/parts/header-cell.d.ts +2 -4
  23. package/dist/src/components/roster/parts/header-cell.d.ts.map +1 -1
  24. package/dist/src/components/roster/parts/header-cell.js.map +1 -1
  25. package/dist/src/components/roster/parts/header.d.ts +1 -1
  26. package/dist/src/components/roster/parts/header.d.ts.map +1 -1
  27. package/dist/src/components/roster/parts/header.js +2 -2
  28. package/dist/src/components/roster/parts/header.js.map +1 -1
  29. package/dist/src/components/roster/parts/lane-label-column.d.ts +1 -1
  30. package/dist/src/components/roster/parts/lane-label-column.d.ts.map +1 -1
  31. package/dist/src/components/roster/parts/lane-label-column.js +2 -2
  32. package/dist/src/components/roster/parts/lane-label-column.js.map +1 -1
  33. package/dist/src/components/roster/parts/lane-list.d.ts +7 -0
  34. package/dist/src/components/roster/parts/lane-list.d.ts.map +1 -0
  35. package/dist/src/components/roster/parts/lane-list.js +26 -0
  36. package/dist/src/components/roster/parts/lane-list.js.map +1 -0
  37. package/dist/src/components/roster/roster.types.d.ts +48 -21
  38. package/dist/src/components/roster/roster.types.d.ts.map +1 -1
  39. package/dist/src/components/roster/selection/selection-layout.d.ts +4 -0
  40. package/dist/src/components/roster/selection/selection-layout.d.ts.map +1 -0
  41. package/dist/src/components/roster/selection/selection-layout.js +82 -0
  42. package/dist/src/components/roster/selection/selection-layout.js.map +1 -0
  43. package/dist/src/components/roster/selection/selection-layout.types.d.ts +25 -0
  44. package/dist/src/components/roster/selection/selection-layout.types.d.ts.map +1 -0
  45. package/dist/src/components/roster/selection/selection-layout.types.js +3 -0
  46. package/dist/src/components/roster/selection/selection-layout.types.js.map +1 -0
  47. package/dist/src/components/roster/selection/selection-layout.web.d.ts +4 -0
  48. package/dist/src/components/roster/selection/selection-layout.web.d.ts.map +1 -0
  49. package/dist/src/components/roster/selection/selection-layout.web.js +52 -0
  50. package/dist/src/components/roster/selection/selection-layout.web.js.map +1 -0
  51. package/dist/src/components/roster/use-roster-press.d.ts +6 -0
  52. package/dist/src/components/roster/use-roster-press.d.ts.map +1 -0
  53. package/dist/src/components/roster/use-roster-press.js +61 -0
  54. package/dist/src/components/roster/use-roster-press.js.map +1 -0
  55. package/dist/src/components/roster/use-roster.d.ts.map +1 -1
  56. package/dist/src/components/roster/use-roster.js +40 -30
  57. package/dist/src/components/roster/use-roster.js.map +1 -1
  58. package/dist/src/components/roster/utils/body-content-key.d.ts +1 -1
  59. package/dist/src/components/roster/utils/body-content-key.d.ts.map +1 -1
  60. package/dist/src/components/roster/utils/body-content-key.js +10 -10
  61. package/dist/src/components/roster/utils/body-content-key.js.map +1 -1
  62. package/dist/src/components/schedule/days/day.d.ts +2 -2
  63. package/dist/src/components/schedule/days/day.d.ts.map +1 -1
  64. package/dist/src/components/schedule/days/day.js +2 -11
  65. package/dist/src/components/schedule/days/day.js.map +1 -1
  66. package/dist/src/components/schedule/days/header-layout.d.ts +1 -1
  67. package/dist/src/components/schedule/days/header-layout.d.ts.map +1 -1
  68. package/dist/src/components/schedule/days/parts/column.d.ts +1 -1
  69. package/dist/src/components/schedule/days/parts/column.d.ts.map +1 -1
  70. package/dist/src/components/schedule/days/parts/column.js +4 -8
  71. package/dist/src/components/schedule/days/parts/column.js.map +1 -1
  72. package/dist/src/components/schedule/index.d.ts.map +1 -1
  73. package/dist/src/components/schedule/index.js +7 -8
  74. package/dist/src/components/schedule/index.js.map +1 -1
  75. package/dist/src/components/schedule/schedule.types.d.ts +17 -17
  76. package/dist/src/components/schedule/schedule.types.d.ts.map +1 -1
  77. package/dist/src/core/index.d.ts +1 -0
  78. package/dist/src/core/index.d.ts.map +1 -1
  79. package/dist/src/core/index.js +3 -1
  80. package/dist/src/core/index.js.map +1 -1
  81. package/dist/src/core/source.d.ts +4 -0
  82. package/dist/src/core/source.d.ts.map +1 -0
  83. package/dist/src/core/source.js +9 -0
  84. package/dist/src/core/source.js.map +1 -0
  85. package/dist/src/index.d.ts +6 -1
  86. package/dist/src/index.d.ts.map +1 -1
  87. package/dist/src/index.js +10 -1
  88. package/dist/src/index.js.map +1 -1
  89. package/dist/src/nativewind/index.d.ts +28 -27
  90. package/dist/src/nativewind/index.d.ts.map +1 -1
  91. package/docs/migrations.md +61 -0
  92. package/llms.txt +112 -30
  93. package/package.json +12 -5
  94. package/src/components/layers/README.md +7 -3
  95. package/src/components/primitives/README.md +13 -2
  96. package/src/components/primitives/portal.tsx +48 -0
  97. package/src/components/roster/README.md +62 -5
  98. package/src/components/roster/body-layout.tsx +38 -0
  99. package/src/components/roster/index.tsx +109 -46
  100. package/src/components/roster/lanes/lane.tsx +16 -13
  101. package/src/components/roster/parts/body.tsx +17 -84
  102. package/src/components/roster/parts/header-cell.tsx +2 -2
  103. package/src/components/roster/parts/header.tsx +7 -2
  104. package/src/components/roster/parts/lane-label-column.tsx +2 -2
  105. package/src/components/roster/parts/lane-list.tsx +76 -0
  106. package/src/components/roster/roster.types.ts +47 -17
  107. package/src/components/roster/selection/selection-layout.tsx +90 -0
  108. package/src/components/roster/selection/selection-layout.types.ts +20 -0
  109. package/src/components/roster/selection/selection-layout.web.tsx +88 -0
  110. package/src/components/roster/use-roster-press.ts +74 -0
  111. package/src/components/roster/use-roster.ts +57 -44
  112. package/src/components/roster/utils/body-content-key.ts +13 -13
  113. package/src/components/schedule/README.md +8 -3
  114. package/src/components/schedule/days/day.tsx +31 -18
  115. package/src/components/schedule/days/header-layout.tsx +1 -1
  116. package/src/components/schedule/days/parts/column.tsx +10 -12
  117. package/src/components/schedule/index.tsx +20 -21
  118. package/src/components/schedule/schedule.types.ts +13 -13
  119. package/src/core/README.md +4 -0
  120. package/src/core/index.ts +1 -0
  121. package/src/core/source.ts +9 -0
  122. 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
- headerCellZone: (input: { tick: RosterTick }) => ReactNode;
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
- laneLabelZone: (input: LaneLabelInput) => ReactNode;
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
- intervalZone: (input: IntervalInput) => ReactNode;
119
+ intervalComponent: ComponentType<IntervalInput>;
105
120
  /** Removed rect filler, placed inside a pressable by LaneRow. */
106
- gapZone: (input: GapInput) => ReactNode;
121
+ gapComponent: ComponentType<GapInput>;
107
122
  /** Noninteractive tick lines behind every lane, sized to the content width. */
108
- gridZone: (input: GridInput) => ReactNode;
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
- laneLabelZone?: (input: LaneLabelInput) => ReactNode;
166
+ laneLabelComponent?: ComponentType<LaneLabelInput>;
137
167
  /** Time label within one positioned header tick. */
138
- headerCellZone?: (input: { tick: RosterTick }) => ReactNode;
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
- intervalZone?: (input: IntervalInput) => ReactNode;
170
+ intervalComponent?: ComponentType<IntervalInput>;
141
171
  /** Removed rect content; LaneRow supplies the invisible pressable. */
142
- gapZone?: (input: GapInput) => ReactNode;
172
+ gapComponent?: ComponentType<GapInput>;
143
173
  /** Tick lines behind the lanes; RosterGrid draws one hairline per tick. */
144
- gridZone?: (input: GridInput) => ReactNode;
174
+ gridComponent?: ComponentType<GridInput>;
145
175
  /** Empty roster content, conventionally the text No lanes. */
146
- emptyZone?: () => ReactNode;
176
+ emptyZone?: ReactNode;
147
177
  /** Top-left cell above the lane labels; RosterCorner renders nothing. */
148
- cornerZone?: () => ReactNode;
178
+ cornerZone?: ReactNode;
149
179
  /** Frozen time header, supplied with ticks, scale, and shared scrolling. */
150
- headerZone?: (input: HeaderInput) => ReactNode;
180
+ headerComponent?: ComponentType<HeaderInput>;
151
181
  /** Frozen labels, supplied with ordered lanes and per-lane flag and completeness. */
152
- laneLabelColumnZone?: (input: LabelColumnInput) => ReactNode;
182
+ laneLabelColumnComponent?: ComponentType<LabelColumnInput>;
153
183
  /** Virtualized lane body, supplied with geometry, projection, scrolling, and press resolution. */
154
- bodyZone?: (input: BodyInput) => ReactNode;
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 currentPress = { input, window, projection, width: contentWidth };
64
- const pressInput = useRef(currentPress);
65
- pressInput.current = currentPress;
66
- const [press] = useState(() => {
67
- return function press(lane: Lane, pointX: number, pointY: number): void {
68
- const { input, window, projection, width } = pressInput.current;
69
- const { minuteStep = 60, onIntervalPress, onGapPress, onCellPress } = input;
70
- const { rowHeight, viewTimezone } = projection;
71
- if (
72
- !Number.isFinite(pointX) ||
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 zoneIds = new WeakMap<object, number>();
4
- let nextZoneId = 0;
3
+ const identityIds = new WeakMap<object, number>();
4
+ let nextIdentityId = 0;
5
5
 
6
- function zoneId(zone: object): number {
7
- let id = zoneIds.get(zone);
6
+ function identityId(identity: object): number {
7
+ let id = identityIds.get(identity);
8
8
  if (id === undefined) {
9
- id = nextZoneId++;
10
- zoneIds.set(zone, id);
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
- intervalZone,
20
- gapZone,
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
- | 'intervalZone'
29
- | 'gapZone'
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
- zoneId(intervalZone),
42
- zoneId(gapZone),
43
- onIntervalHover && zoneId(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` zone type from `schedule.types.ts`
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 zone input type
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 fillers live in `../layers`; press geometry and `regionStyle`
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.