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
@@ -2,17 +2,74 @@
2
2
 
3
3
  This feature is about a roster; its children are lanes.
4
4
 
5
- - Root entry exports: `Roster`, `useRoster`, the `RosterXxx` parts, and every
6
- `XxxInput` zone type from `roster.types.ts`
5
+ - Root entry exports: `Roster`, `useRoster`, `RosterBodyLayout`, `RosterLaneList`, the other `RosterXxx` parts, and every
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 press
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
- - `roster.types.ts`: props, model, and every zone input type
12
+ - `roster.types.ts`: props, model, and every slot input type
13
+ - `body-layout.tsx`: arranges grid and list nodes with horizontal scroll wiring
14
+ - `parts/lane-list.tsx`: `RosterLaneList` owns LegendList and its lane render callback
12
15
  - `parts/`: collection-level parts (body, header, header cell, grid, corner, label column, empty)
13
16
  - `lanes/`: `LaneRow`, the interval-hover platform pair, and lane-local parts
14
17
  - `utils/`: ticks and the body content key
15
18
 
16
- Interval and gap fillers live in `../layers`; press geometry and `regionStyle`
19
+ Interval and gap components live in `../layers`; press geometry and `regionStyle`
17
20
  in `../primitives`. Zone contracts are documented in the README's Roster zones
18
21
  section and in `llms.txt`. Tests: `test/components/roster`.
22
+
23
+ `RosterBody` gates
24
+ measurement and composes the body layout and lane list. The `onRowRender` prop on
25
+ `RosterBody` and `RosterLaneList` is a development-only Profiler hook used by the
26
+ gallery, not a supported customization point. Production Profiler callbacks are disabled.
27
+
28
+ Input-bearing slots accept component types and mount in the data-owning parts.
29
+ The chassis binds defaults once; emptyZone and cornerZone accept nodes. The body
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.
@@ -0,0 +1,38 @@
1
+ import { type ReactNode, useEffect, useState } from 'react';
2
+ import { ScrollView, View } from 'react-native';
3
+ import type { BodyInput } from './roster.types';
4
+
5
+ export function RosterBodyLayout({
6
+ scroll,
7
+ contentWidth,
8
+ viewport,
9
+ gridZone,
10
+ listZone,
11
+ }: Pick<BodyInput, 'scroll' | 'contentWidth' | 'viewport'> & {
12
+ /** Noninteractive grid behind the lane list. */
13
+ gridZone: ReactNode;
14
+ /** Virtualized lane collection within the measured viewport. */
15
+ listZone: ReactNode;
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]);
22
+ return (
23
+ <ScrollView
24
+ testID="roster-horizontal-scroll"
25
+ ref={scroll.bodyRef}
26
+ contentOffset={contentOffset}
27
+ horizontal
28
+ onScroll={scroll.onBodyScroll}
29
+ scrollEventThrottle={16}
30
+ style={{ flex: 1 }}
31
+ >
32
+ <View style={{ width: contentWidth, height: viewport.height }}>
33
+ {gridZone}
34
+ {listZone}
35
+ </View>
36
+ </ScrollView>
37
+ );
38
+ }
@@ -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,10 +12,17 @@ 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
 
19
+ const defaultEmptyZone = <RosterEmpty />;
20
+ const defaultCornerZone = <RosterCorner />;
21
+
15
22
  export function Roster(props: RosterProps) {
16
23
  const {
24
+ selection,
25
+ dismissSelection,
17
26
  status,
18
27
  orderedLanes,
19
28
  laneState,
@@ -26,23 +35,47 @@ export function Roster(props: RosterProps) {
26
35
  contentWidth,
27
36
  viewport,
28
37
  onLayout,
29
- } = useRoster(props);
38
+ } = useRoster({ ...props, selectable: Boolean(props.intervalDetailComponent) });
30
39
  const {
31
- emptyZone = RosterEmpty,
32
- cornerZone = RosterCorner,
33
- headerZone = RosterHeader,
34
- laneLabelColumnZone = RosterLaneLabelColumn,
35
- bodyZone = RosterBody,
36
- headerCellZone = RosterHeaderCell,
37
- laneLabelZone = RosterLaneLabel,
38
- intervalZone = RosterInterval,
39
- gapZone = RosterGap,
40
- gridZone = RosterGrid,
40
+ intervalDetailComponent: IntervalDetailComponent,
41
+ selectionLayout: SelectionLayout = RosterSelectionPopover,
42
+ emptyZone = defaultEmptyZone,
43
+ cornerZone = defaultCornerZone,
44
+ headerComponent: HeaderComponent = RosterHeader,
45
+ laneLabelColumnComponent: LaneLabelColumnComponent = RosterLaneLabelColumn,
46
+ bodyComponent: BodyComponent = RosterBody,
47
+ headerCellComponent = RosterHeaderCell,
48
+ laneLabelComponent = RosterLaneLabel,
49
+ intervalComponent = RosterInterval,
50
+ gapComponent = RosterGap,
51
+ gridComponent = RosterGrid,
41
52
  incompleteLabel = 'Availability may be incomplete',
42
53
  neverSetLabel = 'No availability set',
43
54
  } = props;
44
- if (status === 'empty') return emptyZone();
55
+ const hostId = useId();
56
+ if (status === 'empty') return emptyZone;
45
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
+ }
46
79
  return (
47
80
  <RosterLayout
48
81
  style={props.style}
@@ -51,40 +84,70 @@ export function Roster(props: RosterProps) {
51
84
  bodyStyle={props.bodyStyle}
52
85
  laneLabelWidth={props.laneLabelWidth}
53
86
  onLayout={onLayout}
54
- cornerZone={cornerZone()}
55
- headerZone={headerZone({ ticks, projection, scroll, contentWidth, headerCellZone })}
56
- laneLabelColumnZone={laneLabelColumnZone({
57
- labels: orderedLanes.map((lane) => {
58
- const state = laneState.get(lane.id) ?? { flag: 'none', complete: true };
59
- return {
60
- lane,
61
- ...state,
62
- viewTimezone: projection.viewTimezone,
63
- incompleteLabel,
64
- neverSetLabel,
65
- };
66
- }),
67
- projection,
68
- scroll,
69
- laneLabelZone,
70
- })}
71
- bodyZone={bodyZone({
72
- lanes: orderedLanes,
73
- window,
74
- geometryFor,
75
- projection,
76
- scroll,
77
- press,
78
- ticks,
79
- contentWidth,
80
- viewport,
81
- intervalZone,
82
- gapZone,
83
- gridZone,
84
- highlightSource: props.highlightSource,
85
- onIntervalHover: props.onIntervalHover,
86
- incompleteLabel,
87
- })}
87
+ cornerZone={cornerZone}
88
+ headerZone={
89
+ <HeaderComponent
90
+ ticks={ticks}
91
+ projection={projection}
92
+ scroll={scroll}
93
+ contentWidth={contentWidth}
94
+ headerCellComponent={headerCellComponent}
95
+ />
96
+ }
97
+ laneLabelColumnZone={
98
+ <LaneLabelColumnComponent
99
+ labels={orderedLanes.map((lane) => {
100
+ const state = laneState.get(lane.id) ?? {
101
+ flag: 'none',
102
+ complete: true,
103
+ };
104
+ return {
105
+ lane,
106
+ ...state,
107
+ viewTimezone: projection.viewTimezone,
108
+ incompleteLabel,
109
+ neverSetLabel,
110
+ };
111
+ })}
112
+ projection={projection}
113
+ scroll={scroll}
114
+ laneLabelComponent={laneLabelComponent}
115
+ />
116
+ }
117
+ bodyZone={
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
+ }
149
+ />
150
+ }
88
151
  />
89
152
  );
90
153
  }
@@ -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';
@@ -8,7 +9,12 @@ import { RosterIncomplete } from './parts/incomplete';
8
9
 
9
10
  export type LaneRowProps = Pick<
10
11
  BodyInput,
11
- 'press' | 'intervalZone' | 'gapZone' | 'highlightSource' | 'onIntervalHover' | 'incompleteLabel'
12
+ | 'press'
13
+ | 'intervalComponent'
14
+ | 'gapComponent'
15
+ | 'highlightSource'
16
+ | 'onIntervalHover'
17
+ | 'incompleteLabel'
12
18
  > & {
13
19
  lane: Lane;
14
20
  geometry: LaneGeometry;
@@ -22,8 +28,8 @@ export function LaneRow({
22
28
  width,
23
29
  rowHeight,
24
30
  press,
25
- intervalZone,
26
- gapZone,
31
+ intervalComponent: IntervalComponent,
32
+ gapComponent: GapComponent,
27
33
  highlightSource,
28
34
  onIntervalHover,
29
35
  incompleteLabel = 'Availability may be incomplete',
@@ -47,15 +53,12 @@ export function LaneRow({
47
53
  .filter((rect) => rect.layerId === layer.id)
48
54
  .map((rect) => (
49
55
  <Fragment key={`interval-${rect.x}:${rect.width}`}>
50
- {intervalZone({
51
- rect,
52
- layer,
53
- lane,
54
- highlighted: rect.sources.some(
55
- (source) =>
56
- source.kind === highlightSource?.kind && source.id === highlightSource.id,
57
- ),
58
- })}
56
+ <IntervalComponent
57
+ rect={rect}
58
+ layer={layer}
59
+ lane={lane}
60
+ highlighted={hasSource(rect.sources, highlightSource)}
61
+ />
59
62
  </Fragment>
60
63
  ))}
61
64
  {geometry.gapRects
@@ -78,7 +81,7 @@ export function LaneRow({
78
81
  zIndex: rect.z,
79
82
  }}
80
83
  >
81
- {gapZone({ rect, layer, lane })}
84
+ <GapComponent rect={rect} layer={layer} lane={lane} />
82
85
  </Pressable>
83
86
  ))}
84
87
  </Fragment>
@@ -1,91 +1,24 @@
1
- import { LegendList } from '@legendapp/list';
2
- import { Profiler, type ProfilerOnRenderCallback } from 'react';
3
- import { ScrollView, View } from 'react-native';
4
- import { LaneRow } from '../lanes/lane';
1
+ import type { ProfilerOnRenderCallback } from 'react';
2
+ import { RosterBodyLayout } from '../body-layout';
5
3
  import type { BodyInput } from '../roster.types';
6
- import { bodyContentKey } from '../utils/body-content-key';
4
+ import { RosterLaneList } from './lane-list';
7
5
 
8
- export function RosterBody({
9
- lanes,
10
- geometryFor,
11
- projection,
12
- scroll,
13
- press,
14
- ticks,
15
- contentWidth,
16
- viewport,
17
- window,
18
- intervalZone,
19
- gapZone,
20
- gridZone,
21
- highlightSource,
22
- onIntervalHover,
23
- incompleteLabel,
24
- onRowRender,
25
- }: BodyInput & {
26
- /** Optional per-lane commit observer; used by the development gallery's Profiler gate. */
27
- onRowRender?: ProfilerOnRenderCallback;
28
- }) {
6
+ export function RosterBody(
7
+ props: BodyInput & {
8
+ /** Development-only Profiler hook used by the gallery; not a supported customization point. */
9
+ onRowRender?: ProfilerOnRenderCallback;
10
+ },
11
+ ) {
12
+ const { viewport, scroll, contentWidth, ticks, gridComponent: GridComponent } = props;
29
13
  // LegendList requires a measured viewport and does not support static rendering.
30
14
  if (viewport.width <= 0 || viewport.height <= 0) return null;
31
15
  return (
32
- <ScrollView
33
- testID="roster-horizontal-scroll"
34
- ref={scroll.bodyRef}
35
- horizontal
36
- onScroll={scroll.onBodyScroll}
37
- scrollEventThrottle={16}
38
- style={{ flex: 1 }}
39
- >
40
- <View style={{ width: contentWidth, height: viewport.height }}>
41
- {gridZone({ ticks, contentWidth })}
42
- <LegendList
43
- testID="roster-vertical-scroll"
44
- data={lanes}
45
- extraData={bodyContentKey({
46
- window,
47
- projection,
48
- highlightSource,
49
- intervalZone,
50
- gapZone,
51
- onIntervalHover,
52
- incompleteLabel,
53
- })}
54
- keyExtractor={(lane) => lane.id}
55
- estimatedListSize={{ width: contentWidth, height: viewport.height }}
56
- estimatedItemSize={projection.rowHeight}
57
- getFixedItemSize={() => projection.rowHeight}
58
- drawDistance={0}
59
- recycleItems={false}
60
- maintainVisibleContentPosition={false}
61
- onScroll={scroll.onVerticalScroll}
62
- scrollEventThrottle={16}
63
- style={{ flex: 1 }}
64
- renderItem={({ item }) => {
65
- const row = (
66
- <LaneRow
67
- lane={item}
68
- geometry={geometryFor(item)}
69
- width={contentWidth}
70
- rowHeight={projection.rowHeight}
71
- press={press}
72
- intervalZone={intervalZone}
73
- gapZone={gapZone}
74
- highlightSource={highlightSource}
75
- onIntervalHover={onIntervalHover}
76
- incompleteLabel={incompleteLabel}
77
- />
78
- );
79
- if (onRowRender)
80
- return (
81
- <Profiler id={item.id} onRender={onRowRender}>
82
- {row}
83
- </Profiler>
84
- );
85
- return row;
86
- }}
87
- />
88
- </View>
89
- </ScrollView>
16
+ <RosterBodyLayout
17
+ scroll={scroll}
18
+ contentWidth={contentWidth}
19
+ viewport={viewport}
20
+ gridZone={<GridComponent ticks={ticks} contentWidth={contentWidth} />}
21
+ listZone={<RosterLaneList {...props} />}
22
+ />
90
23
  );
91
24
  }
@@ -1,7 +1,7 @@
1
1
  import { Text } from 'react-native';
2
- import type { RosterTick } from '../roster.types';
2
+ import type { HeaderCellInput } from '../roster.types';
3
3
 
4
- export function RosterHeaderCell({ tick }: { tick: RosterTick }) {
4
+ export function RosterHeaderCell({ tick }: HeaderCellInput) {
5
5
  return (
6
6
  <Text numberOfLines={1} style={{ color: '#475569', fontSize: 11, padding: 4 }}>
7
7
  {tick.label}
@@ -2,7 +2,12 @@ import { View } from 'react-native';
2
2
  import Animated from 'react-native-reanimated';
3
3
  import type { HeaderInput } from '../roster.types';
4
4
 
5
- export function RosterHeader({ ticks, contentWidth, scroll, headerCellZone }: HeaderInput) {
5
+ export function RosterHeader({
6
+ ticks,
7
+ contentWidth,
8
+ scroll,
9
+ headerCellComponent: HeaderCellComponent,
10
+ }: HeaderInput) {
6
11
  return (
7
12
  <Animated.View
8
13
  testID="roster-header"
@@ -10,7 +15,7 @@ export function RosterHeader({ ticks, contentWidth, scroll, headerCellZone }: He
10
15
  >
11
16
  {ticks.map((tick) => (
12
17
  <View key={tick.time} style={{ position: 'absolute', left: tick.x, top: 0 }}>
13
- {headerCellZone({ tick })}
18
+ <HeaderCellComponent tick={tick} />
14
19
  </View>
15
20
  ))}
16
21
  </Animated.View>
@@ -6,7 +6,7 @@ export function RosterLaneLabelColumn({
6
6
  labels,
7
7
  projection,
8
8
  scroll,
9
- laneLabelZone,
9
+ laneLabelComponent: LaneLabelComponent,
10
10
  }: LabelColumnInput) {
11
11
  return (
12
12
  <Animated.View
@@ -23,7 +23,7 @@ export function RosterLaneLabelColumn({
23
23
  width: '100%',
24
24
  }}
25
25
  >
26
- {laneLabelZone(label)}
26
+ <LaneLabelComponent {...label} />
27
27
  </View>
28
28
  ))}
29
29
  </Animated.View>
@@ -0,0 +1,76 @@
1
+ import { LegendList } from '@legendapp/list';
2
+ import { Profiler, type ProfilerOnRenderCallback, useState } from 'react';
3
+ import { LaneRow } from '../lanes/lane';
4
+ import type { BodyInput } from '../roster.types';
5
+ import { bodyContentKey } from '../utils/body-content-key';
6
+
7
+ export function RosterLaneList({
8
+ lanes,
9
+ geometryFor,
10
+ projection,
11
+ scroll,
12
+ press,
13
+ contentWidth,
14
+ viewport,
15
+ window,
16
+ intervalComponent,
17
+ gapComponent,
18
+ highlightSource,
19
+ onIntervalHover,
20
+ incompleteLabel,
21
+ onRowRender,
22
+ }: Omit<BodyInput, 'ticks' | 'gridComponent'> & {
23
+ /** Development-only Profiler hook used by the gallery; not a supported customization point. */
24
+ onRowRender?: ProfilerOnRenderCallback;
25
+ }) {
26
+ const [initialScrollOffset] = useState(() => scroll.y.get());
27
+ return (
28
+ <LegendList
29
+ testID="roster-vertical-scroll"
30
+ initialScrollOffset={initialScrollOffset}
31
+ data={lanes}
32
+ extraData={bodyContentKey({
33
+ window,
34
+ projection,
35
+ highlightSource,
36
+ intervalComponent,
37
+ gapComponent,
38
+ onIntervalHover,
39
+ incompleteLabel,
40
+ })}
41
+ keyExtractor={(lane) => lane.id}
42
+ estimatedListSize={{ width: contentWidth, height: viewport.height }}
43
+ estimatedItemSize={projection.rowHeight}
44
+ getFixedItemSize={() => projection.rowHeight}
45
+ drawDistance={0}
46
+ recycleItems={false}
47
+ maintainVisibleContentPosition={false}
48
+ onScroll={scroll.onVerticalScroll}
49
+ scrollEventThrottle={16}
50
+ style={{ flex: 1 }}
51
+ renderItem={({ item }) => {
52
+ const row = (
53
+ <LaneRow
54
+ lane={item}
55
+ geometry={geometryFor(item)}
56
+ width={contentWidth}
57
+ rowHeight={projection.rowHeight}
58
+ press={press}
59
+ intervalComponent={intervalComponent}
60
+ gapComponent={gapComponent}
61
+ highlightSource={highlightSource}
62
+ onIntervalHover={onIntervalHover}
63
+ incompleteLabel={incompleteLabel}
64
+ />
65
+ );
66
+ if (onRowRender)
67
+ return (
68
+ <Profiler id={item.id} onRender={onRowRender}>
69
+ {row}
70
+ </Profiler>
71
+ );
72
+ return row;
73
+ }}
74
+ />
75
+ );
76
+ }