react-native-roster 0.1.0 → 0.3.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 +148 -3
- package/dist/src/components/primitives/portal.d.ts +12 -0
- package/dist/src/components/primitives/portal.d.ts.map +1 -0
- package/dist/src/components/primitives/portal.js +41 -0
- package/dist/src/components/primitives/portal.js.map +1 -0
- package/dist/src/components/roster/body-layout.d.ts +1 -1
- package/dist/src/components/roster/body-layout.d.ts.map +1 -1
- package/dist/src/components/roster/body-layout.js +7 -1
- package/dist/src/components/roster/body-layout.js.map +1 -1
- package/dist/src/components/roster/index.d.ts +2 -1
- package/dist/src/components/roster/index.d.ts.map +1 -1
- package/dist/src/components/roster/index.js +26 -3
- package/dist/src/components/roster/index.js.map +1 -1
- package/dist/src/components/roster/lanes/lane.d.ts.map +1 -1
- package/dist/src/components/roster/lanes/lane.js +2 -1
- package/dist/src/components/roster/lanes/lane.js.map +1 -1
- package/dist/src/components/roster/parts/lane-list.d.ts.map +1 -1
- package/dist/src/components/roster/parts/lane-list.js +2 -1
- package/dist/src/components/roster/parts/lane-list.js.map +1 -1
- package/dist/src/components/roster/roster.types.d.ts +29 -1
- package/dist/src/components/roster/roster.types.d.ts.map +1 -1
- package/dist/src/components/roster/selection/selection-layout.d.ts +4 -0
- package/dist/src/components/roster/selection/selection-layout.d.ts.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.js +82 -0
- package/dist/src/components/roster/selection/selection-layout.js.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.types.d.ts +25 -0
- package/dist/src/components/roster/selection/selection-layout.types.d.ts.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.types.js +3 -0
- package/dist/src/components/roster/selection/selection-layout.types.js.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.web.d.ts +4 -0
- package/dist/src/components/roster/selection/selection-layout.web.d.ts.map +1 -0
- package/dist/src/components/roster/selection/selection-layout.web.js +52 -0
- package/dist/src/components/roster/selection/selection-layout.web.js.map +1 -0
- package/dist/src/components/roster/use-roster-press.d.ts +6 -0
- package/dist/src/components/roster/use-roster-press.d.ts.map +1 -0
- package/dist/src/components/roster/use-roster-press.js +61 -0
- package/dist/src/components/roster/use-roster-press.js.map +1 -0
- package/dist/src/components/roster/use-roster.d.ts.map +1 -1
- package/dist/src/components/roster/use-roster.js +40 -30
- package/dist/src/components/roster/use-roster.js.map +1 -1
- package/dist/src/components/schedule/days/parts/column.d.ts.map +1 -1
- package/dist/src/components/schedule/days/parts/column.js +2 -1
- package/dist/src/components/schedule/days/parts/column.js.map +1 -1
- package/dist/src/core/index.d.ts +2 -0
- package/dist/src/core/index.d.ts.map +1 -1
- package/dist/src/core/index.js +7 -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/core/spans.d.ts +19 -0
- package/dist/src/core/spans.d.ts.map +1 -1
- package/dist/src/core/spans.js +64 -0
- package/dist/src/core/spans.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/nativewind/index.d.ts +4 -1
- package/dist/src/nativewind/index.d.ts.map +1 -1
- package/llms.txt +85 -3
- package/package.json +10 -4
- package/src/components/primitives/README.md +13 -2
- package/src/components/primitives/portal.tsx +48 -0
- package/src/components/roster/README.md +48 -2
- package/src/components/roster/body-layout.tsx +7 -1
- package/src/components/roster/index.tsx +62 -17
- package/src/components/roster/lanes/lane.tsx +2 -4
- package/src/components/roster/parts/lane-list.tsx +3 -1
- package/src/components/roster/roster.types.ts +30 -1
- package/src/components/roster/selection/selection-layout.tsx +90 -0
- package/src/components/roster/selection/selection-layout.types.ts +20 -0
- package/src/components/roster/selection/selection-layout.web.tsx +88 -0
- package/src/components/roster/use-roster-press.ts +74 -0
- package/src/components/roster/use-roster.ts +57 -44
- package/src/components/schedule/days/parts/column.tsx +2 -4
- package/src/core/README.md +18 -1
- package/src/core/index.ts +2 -0
- package/src/core/source.ts +9 -0
- package/src/core/spans.ts +66 -2
- package/src/index.ts +4 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment } from 'react';
|
|
2
2
|
import { Pressable } from 'react-native';
|
|
3
3
|
import type { Lane, LaneGeometry } from '../../../core';
|
|
4
|
+
import { hasSource } from '../../../core';
|
|
4
5
|
import { pressPoint } from '../../primitives/press-point';
|
|
5
6
|
import type { BodyInput } from '../roster.types';
|
|
6
7
|
import { intervalHoverProps } from './interval-hover';
|
|
@@ -56,10 +57,7 @@ export function LaneRow({
|
|
|
56
57
|
rect={rect}
|
|
57
58
|
layer={layer}
|
|
58
59
|
lane={lane}
|
|
59
|
-
highlighted={rect.sources
|
|
60
|
-
(source) =>
|
|
61
|
-
source.kind === highlightSource?.kind && source.id === highlightSource.id,
|
|
62
|
-
)}
|
|
60
|
+
highlighted={hasSource(rect.sources, highlightSource)}
|
|
63
61
|
/>
|
|
64
62
|
</Fragment>
|
|
65
63
|
))}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LegendList } from '@legendapp/list';
|
|
2
|
-
import { Profiler, type ProfilerOnRenderCallback } from 'react';
|
|
2
|
+
import { Profiler, type ProfilerOnRenderCallback, useState } from 'react';
|
|
3
3
|
import { LaneRow } from '../lanes/lane';
|
|
4
4
|
import type { BodyInput } from '../roster.types';
|
|
5
5
|
import { bodyContentKey } from '../utils/body-content-key';
|
|
@@ -23,9 +23,11 @@ export function RosterLaneList({
|
|
|
23
23
|
/** Development-only Profiler hook used by the gallery; not a supported customization point. */
|
|
24
24
|
onRowRender?: ProfilerOnRenderCallback;
|
|
25
25
|
}) {
|
|
26
|
+
const [initialScrollOffset] = useState(() => scroll.y.get());
|
|
26
27
|
return (
|
|
27
28
|
<LegendList
|
|
28
29
|
testID="roster-vertical-scroll"
|
|
30
|
+
initialScrollOffset={initialScrollOffset}
|
|
29
31
|
data={lanes}
|
|
30
32
|
extraData={bodyContentKey({
|
|
31
33
|
window,
|
|
@@ -21,6 +21,14 @@ import type {
|
|
|
21
21
|
WindowSpec,
|
|
22
22
|
} from '../../core';
|
|
23
23
|
import type { GapInput, IntervalInput } from '../layers/layers.types';
|
|
24
|
+
import type { SelectionLayoutProps } from './selection/selection-layout.types';
|
|
25
|
+
|
|
26
|
+
/** A selected interval: the rect input plus its absolute bounds and the view zone for formatting. */
|
|
27
|
+
export type IntervalDetailInput = IntervalInput & {
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
viewTimezone: string;
|
|
31
|
+
};
|
|
24
32
|
|
|
25
33
|
export type RosterProjection = Extract<Projection, { orientation: 'horizontal' }>;
|
|
26
34
|
export type RosterTick = { time: number; x: number; label: string; kind: 'day' | 'time' };
|
|
@@ -35,6 +43,8 @@ export type RosterScroll = {
|
|
|
35
43
|
labelStyle: StyleProp<ViewStyle>;
|
|
36
44
|
};
|
|
37
45
|
export type RosterInput = {
|
|
46
|
+
/** Retain interval selection on press, default false. Lives on RosterInput for hook consumers. */
|
|
47
|
+
selectable?: boolean;
|
|
38
48
|
lanes: Lane[];
|
|
39
49
|
windowSpec: WindowSpec;
|
|
40
50
|
minuteStep?: number;
|
|
@@ -52,6 +62,10 @@ export type RosterInput = {
|
|
|
52
62
|
pxPerMinute?: number;
|
|
53
63
|
};
|
|
54
64
|
export type RosterModel = {
|
|
65
|
+
/** Current interval with fresh lane, layer, and rect references and its absolute bounds, or null. */
|
|
66
|
+
selection: Omit<IntervalDetailInput, 'highlighted' | 'viewTimezone'> | null;
|
|
67
|
+
/** Close the selected interval details. */
|
|
68
|
+
dismissSelection: () => void;
|
|
55
69
|
window: Window;
|
|
56
70
|
projection: RosterProjection;
|
|
57
71
|
orderedLanes: Lane[];
|
|
@@ -129,8 +143,23 @@ export type RosterStyleProps = {
|
|
|
129
143
|
/** Width of the corner and lane label column, default 180. */
|
|
130
144
|
laneLabelWidth?: number;
|
|
131
145
|
};
|
|
132
|
-
export type RosterProps = RosterInput &
|
|
146
|
+
export type RosterProps = Omit<RosterInput, 'selectable'> &
|
|
133
147
|
RosterStyleProps & {
|
|
148
|
+
/**
|
|
149
|
+
* Selected interval content; absent means interval presses retain no selection.
|
|
150
|
+
* Lives on RosterProps because the chassis mounts content and enables useRoster selection.
|
|
151
|
+
*/
|
|
152
|
+
intervalDetailComponent?: ComponentType<IntervalDetailInput>;
|
|
153
|
+
/**
|
|
154
|
+
* Presentation strategy for the body and selected details; defaults to RosterSelectionPopover.
|
|
155
|
+
* Lives on RosterProps because only the chassis mounts the layout, not useRoster.
|
|
156
|
+
*/
|
|
157
|
+
selectionLayout?: ComponentType<SelectionLayoutProps>;
|
|
158
|
+
/**
|
|
159
|
+
* Native portal host name; defaults to a unique useId name for this roster.
|
|
160
|
+
* Lives on RosterProps because only the chassis mounts the layout, not useRoster.
|
|
161
|
+
*/
|
|
162
|
+
portalHost?: string;
|
|
134
163
|
incompleteLabel?: string;
|
|
135
164
|
neverSetLabel?: string;
|
|
136
165
|
/** Label, differing lane zone, effective flag, and completeness notice. */
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useId, useState } from 'react';
|
|
2
|
+
import { BackHandler, ScrollView, View } from 'react-native';
|
|
3
|
+
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
|
|
4
|
+
import { Portal, PortalHost } from '../../primitives/portal';
|
|
5
|
+
import type { SelectionLayoutProps } from './selection-layout.types';
|
|
6
|
+
|
|
7
|
+
const overlay = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } as const;
|
|
8
|
+
|
|
9
|
+
/** Native selection presentation with a local portal destination and shared scroll translation. */
|
|
10
|
+
export function RosterSelectionPopover({
|
|
11
|
+
anchorZone,
|
|
12
|
+
contentZone,
|
|
13
|
+
targetBounds,
|
|
14
|
+
open,
|
|
15
|
+
onDismiss,
|
|
16
|
+
portalHost,
|
|
17
|
+
scroll,
|
|
18
|
+
}: SelectionLayoutProps) {
|
|
19
|
+
const name = useId();
|
|
20
|
+
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
|
21
|
+
const [content, setContent] = useState({ width: 0, height: 0 });
|
|
22
|
+
const maxWidth = Math.max(0, viewport.width - 8);
|
|
23
|
+
const maxHeight = Math.max(0, viewport.height - 8);
|
|
24
|
+
const size = { maxWidth, maxHeight, flexShrink: 1 };
|
|
25
|
+
const { x, y } = scroll;
|
|
26
|
+
const position = useAnimatedStyle(() => {
|
|
27
|
+
const below = targetBounds ? targetBounds.y + targetBounds.height + 4 - y.get() : 0;
|
|
28
|
+
const above = targetBounds ? targetBounds.y - content.height - 4 - y.get() : 0;
|
|
29
|
+
const maxTop = Math.max(0, viewport.height - content.height);
|
|
30
|
+
return {
|
|
31
|
+
left: Math.max(0, Math.min((targetBounds?.x ?? 0) - x.get(), viewport.width - content.width)),
|
|
32
|
+
top: below >= 0 && below <= maxTop ? below : above >= 0 && above <= maxTop ? above : 0,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!open) return;
|
|
37
|
+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
38
|
+
onDismiss();
|
|
39
|
+
return true;
|
|
40
|
+
});
|
|
41
|
+
return () => subscription.remove();
|
|
42
|
+
}, [open, onDismiss]);
|
|
43
|
+
let detailZone: ReactNode = null;
|
|
44
|
+
if (open && targetBounds && maxWidth > 0 && maxHeight > 0) {
|
|
45
|
+
detailZone = (
|
|
46
|
+
<Portal hostName={portalHost} name={name}>
|
|
47
|
+
<Animated.View style={[{ position: 'absolute', maxWidth, maxHeight }, position]}>
|
|
48
|
+
<View
|
|
49
|
+
accessibilityRole="summary"
|
|
50
|
+
onStartShouldSetResponder={() => true}
|
|
51
|
+
style={size}
|
|
52
|
+
onLayout={({ nativeEvent }) =>
|
|
53
|
+
setContent({
|
|
54
|
+
width: nativeEvent.layout.width,
|
|
55
|
+
height: nativeEvent.layout.height,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
<ScrollView style={size} nestedScrollEnabled>
|
|
60
|
+
<ScrollView
|
|
61
|
+
horizontal
|
|
62
|
+
style={{ maxWidth, flexGrow: 0, flexShrink: 0 }}
|
|
63
|
+
nestedScrollEnabled
|
|
64
|
+
>
|
|
65
|
+
{contentZone}
|
|
66
|
+
</ScrollView>
|
|
67
|
+
</ScrollView>
|
|
68
|
+
</View>
|
|
69
|
+
</Animated.View>
|
|
70
|
+
</Portal>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<View
|
|
75
|
+
style={{ flex: 1, minHeight: 0 }}
|
|
76
|
+
onLayout={({ nativeEvent }) =>
|
|
77
|
+
setViewport({
|
|
78
|
+
width: nativeEvent.layout.width,
|
|
79
|
+
height: nativeEvent.layout.height,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
{anchorZone}
|
|
84
|
+
<View pointerEvents="box-none" style={overlay}>
|
|
85
|
+
<PortalHost name={portalHost} />
|
|
86
|
+
</View>
|
|
87
|
+
{detailZone}
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { RosterScroll } from '../roster.types';
|
|
3
|
+
|
|
4
|
+
/** Presentation-only contract shared by native, web, and consumer strategies. */
|
|
5
|
+
export type SelectionLayoutProps = {
|
|
6
|
+
/** Mounted body node, rendered once beneath or beside the details. */
|
|
7
|
+
anchorZone: ReactNode;
|
|
8
|
+
/** Mounted detail node, or null when closed. */
|
|
9
|
+
contentZone: ReactNode;
|
|
10
|
+
/** Interval bounds in body-content coordinates, before scroll translation. */
|
|
11
|
+
targetBounds: { x: number; y: number; width: number; height: number } | null;
|
|
12
|
+
/** Whether the detail presentation is open. */
|
|
13
|
+
open: boolean;
|
|
14
|
+
/** Report outside press, hardware back, Escape, or a presentation close action. */
|
|
15
|
+
onDismiss: () => void;
|
|
16
|
+
/** Unique native destination name owned by this selection layout. */
|
|
17
|
+
portalHost: string;
|
|
18
|
+
/** Shared offsets follow body scrolling without updating React state. */
|
|
19
|
+
scroll: Pick<RosterScroll, 'x' | 'y' | 'headerStyle' | 'labelStyle'>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Anchor, Content, Portal, Root } from '@radix-ui/react-popover';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import Animated from 'react-native-reanimated';
|
|
4
|
+
import type { SelectionLayoutProps } from './selection-layout.types';
|
|
5
|
+
|
|
6
|
+
/** Web selection presentation; Radix owns outside press, Escape, and collisions. */
|
|
7
|
+
export function RosterSelectionPopover({
|
|
8
|
+
anchorZone,
|
|
9
|
+
contentZone,
|
|
10
|
+
targetBounds,
|
|
11
|
+
open,
|
|
12
|
+
onDismiss,
|
|
13
|
+
scroll,
|
|
14
|
+
}: SelectionLayoutProps) {
|
|
15
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
16
|
+
const returnFocusRef = useRef<Element | null>(null);
|
|
17
|
+
const keyboardDismissRef = useRef(false);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (open && targetBounds && bodyRef.current?.contains(document.activeElement)) {
|
|
20
|
+
returnFocusRef.current = document.activeElement;
|
|
21
|
+
}
|
|
22
|
+
}, [open, targetBounds]);
|
|
23
|
+
return (
|
|
24
|
+
<Root
|
|
25
|
+
open={open}
|
|
26
|
+
onOpenChange={(next) => {
|
|
27
|
+
if (!next) onDismiss();
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<div
|
|
31
|
+
ref={bodyRef}
|
|
32
|
+
style={{
|
|
33
|
+
position: 'relative',
|
|
34
|
+
display: 'flex',
|
|
35
|
+
flexDirection: 'column',
|
|
36
|
+
flex: 1,
|
|
37
|
+
minHeight: 0,
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{anchorZone}
|
|
41
|
+
<Animated.View style={[{ position: 'absolute', top: 0, left: 0 }, scroll.headerStyle]}>
|
|
42
|
+
<Animated.View style={scroll.labelStyle}>
|
|
43
|
+
<Anchor asChild>
|
|
44
|
+
<div
|
|
45
|
+
data-testid="roster-selection-target"
|
|
46
|
+
style={{
|
|
47
|
+
position: 'absolute',
|
|
48
|
+
left: targetBounds?.x ?? 0,
|
|
49
|
+
top: targetBounds ? targetBounds.y + targetBounds.height : 0,
|
|
50
|
+
width: 0,
|
|
51
|
+
height: 0,
|
|
52
|
+
pointerEvents: 'none',
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
</Anchor>
|
|
56
|
+
</Animated.View>
|
|
57
|
+
</Animated.View>
|
|
58
|
+
</div>
|
|
59
|
+
<Portal>
|
|
60
|
+
<Content
|
|
61
|
+
onOpenAutoFocus={() => {
|
|
62
|
+
returnFocusRef.current = document.activeElement;
|
|
63
|
+
keyboardDismissRef.current = false;
|
|
64
|
+
}}
|
|
65
|
+
onEscapeKeyDown={() => {
|
|
66
|
+
keyboardDismissRef.current = true;
|
|
67
|
+
}}
|
|
68
|
+
onCloseAutoFocus={(event) => {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
if (keyboardDismissRef.current && returnFocusRef.current instanceof HTMLElement) {
|
|
71
|
+
returnFocusRef.current.focus({ preventScroll: true });
|
|
72
|
+
}
|
|
73
|
+
}}
|
|
74
|
+
onInteractOutside={(event) => {
|
|
75
|
+
if (bodyRef.current?.contains(event.target as Node)) event.preventDefault();
|
|
76
|
+
}}
|
|
77
|
+
sideOffset={4}
|
|
78
|
+
align="start"
|
|
79
|
+
updatePositionStrategy="always"
|
|
80
|
+
aria-label="Interval details"
|
|
81
|
+
style={{ zIndex: 1000 }}
|
|
82
|
+
>
|
|
83
|
+
{contentZone}
|
|
84
|
+
</Content>
|
|
85
|
+
</Portal>
|
|
86
|
+
</Root>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -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,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
|
|
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
|
))}
|
package/src/core/README.md
CHANGED
|
@@ -6,6 +6,9 @@ no React Native, no dependencies.
|
|
|
6
6
|
- Public subpath: `react-native-roster/core`; the root entry re-exports it
|
|
7
7
|
- Authoritative types: `types.ts` (`Lane`, `Layer`, `Interval`, `Gap`,
|
|
8
8
|
`Source`, `Window`, `Projection`, `Rect`, `Coverage`, `LaneGeometry`)
|
|
9
|
+
- Interval helpers: `extentOf(segments: readonly Window[]): Window | null`,
|
|
10
|
+
`intersectionOf(spans: readonly Window[]): Window | null`, and
|
|
11
|
+
`unionOf(spans: readonly Window[]): Window[]`
|
|
9
12
|
- Barrel: `index.ts` only
|
|
10
13
|
|
|
11
14
|
| File | Role |
|
|
@@ -13,7 +16,8 @@ no React Native, no dependencies.
|
|
|
13
16
|
| `axis.ts` | window specs, spans, and navigation (`windowFor`, `next`, `prev`, `today`) |
|
|
14
17
|
| `layout.ts` | rect geometry for one lane in either projection |
|
|
15
18
|
| `coverage.ts` | covered and removed minutes per lane |
|
|
16
|
-
| `
|
|
19
|
+
| `source.ts` | `hasSource(sources, source)` matches kind and id, ignoring labels |
|
|
20
|
+
| `spans.ts` | `extentOf`, `intersectionOf`, `unionOf`, and the provenance sweep with exact sources per merged span |
|
|
17
21
|
| `columns.ts` | day columns, transitions, and skipped dates for the columns projection |
|
|
18
22
|
| `zone.ts` | Intl-only timezone math (offsets, first instants, transitions) |
|
|
19
23
|
| `scale.ts`, `snap.ts` | pixel and time conversion, pointer snapping |
|
|
@@ -21,5 +25,18 @@ no React Native, no dependencies.
|
|
|
21
25
|
| `order.ts`, `flag.ts` | lane comparators and lane flags |
|
|
22
26
|
| `cache.ts`, `hash.ts` | layout and coverage caches keyed by lane content |
|
|
23
27
|
|
|
28
|
+
All interval helpers reuse the bare `{ start, end }` `Window` type with
|
|
29
|
+
end-exclusive epoch millisecond bounds. `extentOf` bridges disjoint segments;
|
|
30
|
+
`intersectionOf` returns only their shared span. Both return `null` for empty
|
|
31
|
+
input; an empty intersection, including touching bounds, also returns `null`.
|
|
32
|
+
`unionOf` merges overlapping or touching windows, sorts by start, and keeps
|
|
33
|
+
disjoint windows separate. It returns `[]` for empty input and new window objects.
|
|
34
|
+
Every input must have finite bounds and `end > start`; otherwise the helpers throw `RangeError`.
|
|
35
|
+
Inputs may be unsorted, overlapping, nested, or duplicated; inputs are not mutated.
|
|
36
|
+
The existing `Span` type names axis choices only.
|
|
37
|
+
|
|
24
38
|
Reference: [design](../../docs/design.md), [timezones](../../docs/timezones.md),
|
|
25
39
|
[caches](../../docs/caches.md). Tests: `test/core`.
|
|
40
|
+
|
|
41
|
+
The public entry binds immutable function aliases directly to preserve implementation
|
|
42
|
+
identity while avoiding repeated CommonJS getter wrappers in consumer bundles.
|
package/src/core/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ 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';
|
|
18
|
+
export { extentOf, intersectionOf, unionOf } from './spans';
|
|
17
19
|
export type {
|
|
18
20
|
Coverage,
|
|
19
21
|
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/core/spans.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Provenance sweep logic and the extentOf, intersectionOf, and unionOf interval helpers.
|
|
2
|
+
// The sweep uses reference counts to preserve provenance through nested intervals
|
|
3
|
+
// and equal-time ends/starts. Labels never affect source identity.
|
|
3
4
|
import type { Interval, Source, Window } from './types';
|
|
4
5
|
|
|
5
6
|
type Boundary = { at: number; delta: number; sources: Source[] };
|
|
@@ -56,3 +57,66 @@ export function sourceSpans(intervals: Interval[], window: Window): Interval[] {
|
|
|
56
57
|
}
|
|
57
58
|
return spans;
|
|
58
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Earliest start through latest end in epoch milliseconds, end-exclusive; empty input returns null.
|
|
63
|
+
* Throws RangeError for non-finite bounds or end <= start.
|
|
64
|
+
* Inputs may be unsorted, overlapping, nested, or duplicated; inputs are not mutated.
|
|
65
|
+
*/
|
|
66
|
+
export function extentOf(segments: readonly Window[]): Window | null {
|
|
67
|
+
let start = Infinity;
|
|
68
|
+
let end = -Infinity;
|
|
69
|
+
for (const segment of segments) {
|
|
70
|
+
validateSpan(segment);
|
|
71
|
+
start = Math.min(start, segment.start);
|
|
72
|
+
end = Math.max(end, segment.end);
|
|
73
|
+
}
|
|
74
|
+
return start < end ? { start, end } : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Shared epoch milliseconds, end-exclusive; empty input or an empty intersection returns null.
|
|
79
|
+
* Throws RangeError for non-finite bounds or end <= start.
|
|
80
|
+
* Inputs may be unsorted, overlapping, nested, or duplicated; inputs are not mutated.
|
|
81
|
+
*/
|
|
82
|
+
export function intersectionOf(spans: readonly Window[]): Window | null {
|
|
83
|
+
if (spans.length === 0) return null;
|
|
84
|
+
let start = -Infinity;
|
|
85
|
+
let end = Infinity;
|
|
86
|
+
for (const span of spans) {
|
|
87
|
+
validateSpan(span);
|
|
88
|
+
start = Math.max(start, span.start);
|
|
89
|
+
end = Math.min(end, span.end);
|
|
90
|
+
}
|
|
91
|
+
return start < end ? { start, end } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Merged end-exclusive epoch windows sorted by start; empty input returns an empty array.
|
|
96
|
+
* Overlapping or touching spans merge; disjoint spans remain separate.
|
|
97
|
+
* Throws RangeError for non-finite bounds or end <= start.
|
|
98
|
+
* Returns new objects without mutating inputs.
|
|
99
|
+
*/
|
|
100
|
+
export function unionOf(spans: readonly Window[]): Window[] {
|
|
101
|
+
const sorted = spans.map((span) => {
|
|
102
|
+
validateSpan(span);
|
|
103
|
+
return { start: span.start, end: span.end };
|
|
104
|
+
});
|
|
105
|
+
sorted.sort((a, b) => a.start - b.start);
|
|
106
|
+
const merged: Window[] = [];
|
|
107
|
+
for (const span of sorted) {
|
|
108
|
+
const last = merged.at(-1);
|
|
109
|
+
if (last && span.start <= last.end) {
|
|
110
|
+
last.end = Math.max(last.end, span.end);
|
|
111
|
+
} else {
|
|
112
|
+
merged.push(span);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return merged;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateSpan(span: Window): void {
|
|
119
|
+
if (!Number.isFinite(span.start) || !Number.isFinite(span.end) || span.end <= span.start) {
|
|
120
|
+
throw new RangeError('Span bounds must be finite with end greater than start');
|
|
121
|
+
}
|
|
122
|
+
}
|