react-native-roster 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +102 -2
  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 +1 -1
  7. package/dist/src/components/roster/body-layout.d.ts.map +1 -1
  8. package/dist/src/components/roster/body-layout.js +7 -1
  9. package/dist/src/components/roster/body-layout.js.map +1 -1
  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 +26 -3
  13. package/dist/src/components/roster/index.js.map +1 -1
  14. package/dist/src/components/roster/lanes/lane.d.ts.map +1 -1
  15. package/dist/src/components/roster/lanes/lane.js +2 -1
  16. package/dist/src/components/roster/lanes/lane.js.map +1 -1
  17. package/dist/src/components/roster/parts/lane-list.d.ts.map +1 -1
  18. package/dist/src/components/roster/parts/lane-list.js +2 -1
  19. package/dist/src/components/roster/parts/lane-list.js.map +1 -1
  20. package/dist/src/components/roster/roster.types.d.ts +29 -1
  21. package/dist/src/components/roster/roster.types.d.ts.map +1 -1
  22. package/dist/src/components/roster/selection/selection-layout.d.ts +4 -0
  23. package/dist/src/components/roster/selection/selection-layout.d.ts.map +1 -0
  24. package/dist/src/components/roster/selection/selection-layout.js +82 -0
  25. package/dist/src/components/roster/selection/selection-layout.js.map +1 -0
  26. package/dist/src/components/roster/selection/selection-layout.types.d.ts +25 -0
  27. package/dist/src/components/roster/selection/selection-layout.types.d.ts.map +1 -0
  28. package/dist/src/components/roster/selection/selection-layout.types.js +3 -0
  29. package/dist/src/components/roster/selection/selection-layout.types.js.map +1 -0
  30. package/dist/src/components/roster/selection/selection-layout.web.d.ts +4 -0
  31. package/dist/src/components/roster/selection/selection-layout.web.d.ts.map +1 -0
  32. package/dist/src/components/roster/selection/selection-layout.web.js +52 -0
  33. package/dist/src/components/roster/selection/selection-layout.web.js.map +1 -0
  34. package/dist/src/components/roster/use-roster-press.d.ts +6 -0
  35. package/dist/src/components/roster/use-roster-press.d.ts.map +1 -0
  36. package/dist/src/components/roster/use-roster-press.js +61 -0
  37. package/dist/src/components/roster/use-roster-press.js.map +1 -0
  38. package/dist/src/components/roster/use-roster.d.ts.map +1 -1
  39. package/dist/src/components/roster/use-roster.js +40 -30
  40. package/dist/src/components/roster/use-roster.js.map +1 -1
  41. package/dist/src/components/schedule/days/parts/column.d.ts.map +1 -1
  42. package/dist/src/components/schedule/days/parts/column.js +2 -1
  43. package/dist/src/components/schedule/days/parts/column.js.map +1 -1
  44. package/dist/src/core/index.d.ts +1 -0
  45. package/dist/src/core/index.d.ts.map +1 -1
  46. package/dist/src/core/index.js +3 -1
  47. package/dist/src/core/index.js.map +1 -1
  48. package/dist/src/core/source.d.ts +4 -0
  49. package/dist/src/core/source.d.ts.map +1 -0
  50. package/dist/src/core/source.js +9 -0
  51. package/dist/src/core/source.js.map +1 -0
  52. package/dist/src/index.d.ts +4 -1
  53. package/dist/src/index.d.ts.map +1 -1
  54. package/dist/src/index.js +6 -1
  55. package/dist/src/index.js.map +1 -1
  56. package/dist/src/nativewind/index.d.ts +4 -1
  57. package/dist/src/nativewind/index.d.ts.map +1 -1
  58. package/llms.txt +67 -2
  59. package/package.json +10 -4
  60. package/src/components/primitives/README.md +13 -2
  61. package/src/components/primitives/portal.tsx +48 -0
  62. package/src/components/roster/README.md +48 -2
  63. package/src/components/roster/body-layout.tsx +7 -1
  64. package/src/components/roster/index.tsx +62 -17
  65. package/src/components/roster/lanes/lane.tsx +2 -4
  66. package/src/components/roster/parts/lane-list.tsx +3 -1
  67. package/src/components/roster/roster.types.ts +30 -1
  68. package/src/components/roster/selection/selection-layout.tsx +90 -0
  69. package/src/components/roster/selection/selection-layout.types.ts +20 -0
  70. package/src/components/roster/selection/selection-layout.web.tsx +88 -0
  71. package/src/components/roster/use-roster-press.ts +74 -0
  72. package/src/components/roster/use-roster.ts +57 -44
  73. package/src/components/schedule/days/parts/column.tsx +2 -4
  74. package/src/core/README.md +4 -0
  75. package/src/core/index.ts +1 -0
  76. package/src/core/source.ts +9 -0
  77. package/src/index.ts +4 -0
@@ -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,5 +1,6 @@
1
1
  import { Fragment } from 'react';
2
2
  import { Pressable } from 'react-native';
3
+ import { hasSource } from '../../../../core';
3
4
  import { pressPoint } from '../../../primitives/press-point';
4
5
  import type { ScheduleColumnInput } from '../../schedule.types';
5
6
 
@@ -24,10 +25,7 @@ export function ScheduleColumn({
24
25
  rect={rect}
25
26
  layer={layer}
26
27
  lane={lane}
27
- highlighted={rect.sources.some(
28
- (source) =>
29
- source.kind === highlightSource?.kind && source.id === highlightSource.id,
30
- )}
28
+ highlighted={hasSource(rect.sources, highlightSource)}
31
29
  />
32
30
  </Fragment>
33
31
  ))}
@@ -13,6 +13,7 @@ no React Native, no dependencies.
13
13
  | `axis.ts` | window specs, spans, and navigation (`windowFor`, `next`, `prev`, `today`) |
14
14
  | `layout.ts` | rect geometry for one lane in either projection |
15
15
  | `coverage.ts` | covered and removed minutes per lane |
16
+ | `source.ts` | `hasSource(sources, source)` matches kind and id, ignoring labels |
16
17
  | `spans.ts` | the provenance sweep: exact sources per merged span |
17
18
  | `columns.ts` | day columns, transitions, and skipped dates for the columns projection |
18
19
  | `zone.ts` | Intl-only timezone math (offsets, first instants, transitions) |
@@ -23,3 +24,6 @@ no React Native, no dependencies.
23
24
 
24
25
  Reference: [design](../../docs/design.md), [timezones](../../docs/timezones.md),
25
26
  [caches](../../docs/caches.md). Tests: `test/core`.
27
+
28
+ The public entry binds immutable function aliases directly to preserve implementation
29
+ identity while avoiding repeated CommonJS getter wrappers in consumer bundles.
package/src/core/index.ts CHANGED
@@ -14,6 +14,7 @@ export { layoutLane } from './layout';
14
14
  export type { LaneComparator } from './order';
15
15
  export { byCoverage, byLabel } from './order';
16
16
  export { snapToStep, timeAtX, timeAtY } from './snap';
17
+ export { hasSource } from './source';
17
18
  export type {
18
19
  Coverage,
19
20
  DayColumn,
@@ -0,0 +1,9 @@
1
+ import type { Source } from './types';
2
+
3
+ /** Match source identity by kind and id, ignoring display metadata. */
4
+ export function hasSource(sources: readonly Source[], source: Source | undefined): boolean {
5
+ return (
6
+ source !== undefined &&
7
+ sources.some((item) => item.kind === source.kind && item.id === source.id)
8
+ );
9
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { GapInput, IntervalInput } from './components/layers/layers.types';
2
2
  export { RosterGap } from './components/layers/parts/gap';
3
3
  export { RosterInterval } from './components/layers/parts/interval';
4
+ export { Portal, PortalHost } from './components/primitives/portal';
4
5
  export { Roster } from './components/roster';
5
6
  export { RosterBodyLayout } from './components/roster/body-layout';
6
7
  export { RosterLaneLabel } from './components/roster/lanes/parts/lane-label';
@@ -17,6 +18,7 @@ export type {
17
18
  GridInput,
18
19
  HeaderCellInput,
19
20
  HeaderInput,
21
+ IntervalDetailInput,
20
22
  LabelColumnInput,
21
23
  LaneLabelInput,
22
24
  RosterInput,
@@ -27,6 +29,8 @@ export type {
27
29
  RosterStyleProps,
28
30
  RosterTick,
29
31
  } from './components/roster/roster.types';
32
+ export { RosterSelectionPopover } from './components/roster/selection/selection-layout';
33
+ export type { SelectionLayoutProps } from './components/roster/selection/selection-layout.types';
30
34
  export { useRoster } from './components/roster/use-roster';
31
35
  export { Schedule } from './components/schedule';
32
36
  export { ScheduleColumn } from './components/schedule/days/parts/column';