@lotics/ui 17.0.2 → 18.1.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/MIGRATION.md +20 -0
- package/docs/catalog.md +45 -17
- package/docs/composition.md +23 -12
- package/docs/data_entry.md +3 -1
- package/docs/templates.md +3 -2
- package/examples/tpl_record.tsx +2 -2
- package/package.json +1 -1
- package/src/combobox.tsx +24 -14
- package/src/date_calendar.tsx +6 -19
- package/src/date_filter.tsx +95 -102
- package/src/date_picker.tsx +5 -0
- package/src/date_range_selection.test.ts +60 -0
- package/src/date_range_selection.ts +32 -0
- package/src/date_segments.test.ts +48 -0
- package/src/date_segments.ts +34 -0
- package/src/inline_time_picker.tsx +11 -12
- package/src/linked_record_box.tsx +77 -21
- package/src/locale.tsx +2 -2
- package/src/menu_button.tsx +32 -5
- package/src/pressable_highlight.tsx +7 -0
- package/src/section_heading.tsx +2 -1
- package/src/section_stack.tsx +14 -8
- package/src/text_input_field.tsx +46 -7
- package/src/time_picker.tsx +97 -39
- package/src/use_section_nav.test.ts +103 -1
- package/src/use_section_nav.ts +47 -20
|
@@ -4,7 +4,13 @@ import { act, renderHook } from "@testing-library/react";
|
|
|
4
4
|
import { useSectionNav } from "./use_section_nav";
|
|
5
5
|
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// RN-web's layout event carries the measured node as `nativeEvent.target`; RN's
|
|
8
|
+
// own (native-only) types don't model it, so it goes on after the cast.
|
|
9
|
+
const layoutAt = (y: number, target?: Element) => {
|
|
10
|
+
const e = { nativeEvent: { layout: { y } } } as LayoutChangeEvent;
|
|
11
|
+
if (target) Object.assign(e.nativeEvent, { target });
|
|
12
|
+
return e;
|
|
13
|
+
};
|
|
8
14
|
const scrollTo = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as NativeSyntheticEvent<NativeScrollEvent>;
|
|
9
15
|
|
|
10
16
|
function mounted() {
|
|
@@ -17,6 +23,68 @@ function mounted() {
|
|
|
17
23
|
return hook;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
const rect = (top: number): DOMRect => ({
|
|
27
|
+
top,
|
|
28
|
+
bottom: top,
|
|
29
|
+
left: 0,
|
|
30
|
+
right: 0,
|
|
31
|
+
width: 0,
|
|
32
|
+
height: 0,
|
|
33
|
+
x: 0,
|
|
34
|
+
y: top,
|
|
35
|
+
toJSON: () => ({}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A web page in miniature: a scroll container pinned at the viewport top plus
|
|
40
|
+
* section nodes sitting at live content offsets. `scrolledTo` moves the scroll
|
|
41
|
+
* position and every rect together (as a browser does), `shift` is the async
|
|
42
|
+
* content growth that moves sections WITHOUT an `onLayout` — the case a cached
|
|
43
|
+
* offset cannot see. A detached node measures as 0, like a real one.
|
|
44
|
+
*/
|
|
45
|
+
function webPage<K extends string>(keys: readonly K[], contentY: Record<K, number>) {
|
|
46
|
+
let scrollY = 0;
|
|
47
|
+
let live: Record<K, number> = { ...contentY };
|
|
48
|
+
const node = document.createElement("div");
|
|
49
|
+
document.body.appendChild(node);
|
|
50
|
+
Object.defineProperty(node, "scrollTop", { get: () => scrollY });
|
|
51
|
+
node.getBoundingClientRect = () => rect(0);
|
|
52
|
+
const els = {} as Record<K, HTMLElement>;
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const el = document.createElement("div");
|
|
55
|
+
document.body.appendChild(el);
|
|
56
|
+
el.getBoundingClientRect = () => (el.isConnected ? rect(live[key] - scrollY) : rect(0));
|
|
57
|
+
els[key] = el;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
node,
|
|
61
|
+
els,
|
|
62
|
+
shift: (next: Partial<Record<K, number>>) => {
|
|
63
|
+
live = { ...live, ...next };
|
|
64
|
+
},
|
|
65
|
+
scrolledTo: (y: number) => {
|
|
66
|
+
scrollY = y;
|
|
67
|
+
return scrollTo(y);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The registered page above, wired to a hook whose `scrollTo` calls are recorded. */
|
|
73
|
+
function webMounted(contentY: Record<"a" | "b" | "c", number>) {
|
|
74
|
+
const keys = ["a", "b", "c"] as const;
|
|
75
|
+
const page = webPage(keys, contentY);
|
|
76
|
+
const calls: { y: number }[] = [];
|
|
77
|
+
const hook = renderHook(() => useSectionNav(keys));
|
|
78
|
+
(hook.result.current.scrollRef as { current: unknown }).current = {
|
|
79
|
+
getScrollableNode: () => page.node,
|
|
80
|
+
scrollTo: (opts: { y: number }) => calls.push(opts),
|
|
81
|
+
};
|
|
82
|
+
act(() => {
|
|
83
|
+
for (const key of keys) hook.result.current.register(key)(layoutAt(contentY[key], page.els[key]));
|
|
84
|
+
});
|
|
85
|
+
return { result: hook.result, page, calls };
|
|
86
|
+
}
|
|
87
|
+
|
|
20
88
|
describe("useSectionNav", () => {
|
|
21
89
|
it("starts on the first key", () => {
|
|
22
90
|
const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
|
|
@@ -66,4 +134,38 @@ describe("useSectionNav", () => {
|
|
|
66
134
|
act(() => result.current.jumpTo("a"));
|
|
67
135
|
expect(calls[1]?.y).toBe(0);
|
|
68
136
|
});
|
|
137
|
+
|
|
138
|
+
it("on web the highlight follows content that shifted after layout", () => {
|
|
139
|
+
const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
|
|
140
|
+
act(() => result.current.onScroll(page.scrolledTo(321)));
|
|
141
|
+
expect(result.current.activeKey).toBe("b");
|
|
142
|
+
|
|
143
|
+
// a table above b finishes loading and pushes everything below it down 300px
|
|
144
|
+
page.shift({ b: 700, c: 1200 });
|
|
145
|
+
act(() => result.current.onScroll(page.scrolledTo(321)));
|
|
146
|
+
expect(result.current.activeKey).toBe("a");
|
|
147
|
+
act(() => result.current.onScroll(page.scrolledTo(700)));
|
|
148
|
+
expect(result.current.activeKey).toBe("b");
|
|
149
|
+
|
|
150
|
+
// and back: content above collapses, pulling the sections up again
|
|
151
|
+
page.shift({ b: 200, c: 500 });
|
|
152
|
+
act(() => result.current.onScroll(page.scrolledTo(450)));
|
|
153
|
+
expect(result.current.activeKey).toBe("c");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("on web jumpTo lands on the section's shifted position", () => {
|
|
157
|
+
const { result, page, calls } = webMounted({ a: 0, b: 400, c: 900 });
|
|
158
|
+
page.shift({ b: 700 });
|
|
159
|
+
act(() => result.current.jumpTo("b"));
|
|
160
|
+
expect(calls[0]?.y).toBe(688);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("a section removed from the DOM keeps its last offset instead of measuring as the page top", () => {
|
|
164
|
+
const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
|
|
165
|
+
page.els.c.remove();
|
|
166
|
+
// 500 + 80 = 580: past b, still short of c — a detached c measured live
|
|
167
|
+
// would read as the current scroll position and steal the highlight.
|
|
168
|
+
act(() => result.current.onScroll(page.scrolledTo(500)));
|
|
169
|
+
expect(result.current.activeKey).toBe("b");
|
|
170
|
+
});
|
|
69
171
|
});
|
package/src/use_section_nav.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, Scroll
|
|
|
4
4
|
export interface SectionNavHandle<K extends string> {
|
|
5
5
|
/** Attach to the content `ScrollView` (with `onScroll` + `scrollEventThrottle={16}`). */
|
|
6
6
|
scrollRef: RefObject<ScrollView | null>;
|
|
7
|
-
/** The section the scroll currently sits in — drives the rail's `
|
|
7
|
+
/** The section the scroll currently sits in — drives the rail item's `current`. */
|
|
8
8
|
activeKey: K;
|
|
9
9
|
/** `onLayout={register(key)}` on each section wrapper. The wrapper must be a
|
|
10
10
|
* DIRECT child of the ScrollView content (layout.y is content-relative). */
|
|
@@ -21,28 +21,30 @@ export interface SectionNavHandle<K extends string> {
|
|
|
21
21
|
* top has passed the viewport edge is the active one).
|
|
22
22
|
*
|
|
23
23
|
* const nav = useSectionNav(["details", "gatein", "gateout"] as const);
|
|
24
|
-
* <MenuButton title="Gate in"
|
|
24
|
+
* <MenuButton title="Gate in" current={nav.activeKey === "gatein"}
|
|
25
25
|
* onPress={() => nav.jumpTo("gatein")} />
|
|
26
26
|
* <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16}>
|
|
27
27
|
* <View onLayout={nav.register("gatein")}>…</View>
|
|
28
28
|
*
|
|
29
|
+
* A rail item is the CURRENT item in a set of navigation links, so it takes
|
|
30
|
+
* `current` (→ `aria-current`), never `selected` (that is listbox selection).
|
|
31
|
+
*
|
|
29
32
|
* Pass the keys in PAGE ORDER — the spy walks them top-down. Hide the rail on
|
|
30
33
|
* narrow containers (the page still scrolls; the rail is a wide-screen aid).
|
|
31
34
|
*
|
|
32
35
|
* Contract: every key's section stays MOUNTED (an unmounted section leaves its
|
|
33
36
|
* last offset registered — conditional sections belong inside an always-mounted
|
|
34
|
-
* wrapper that carries the `onLayout`). On WEB
|
|
35
|
-
* DOM position, so async content growing ABOVE a section (a photo
|
|
36
|
-
* late-loading table) can never stale
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* matters (`onLayout` won't refire on a pure position shift).
|
|
37
|
+
* wrapper that carries the `onLayout`). On WEB both the jump AND the highlight
|
|
38
|
+
* read the live DOM position, so async content growing ABOVE a section (a photo
|
|
39
|
+
* grid, a late-loading table) can never stale either. On native there is no
|
|
40
|
+
* re-measure — content above should settle before precision matters
|
|
41
|
+
* (`onLayout` won't refire on a pure position shift).
|
|
40
42
|
*/
|
|
41
43
|
export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): SectionNavHandle<K> {
|
|
42
44
|
const scrollRef = useRef<ScrollView>(null);
|
|
43
45
|
const sectionY = useRef<Partial<Record<K, number>>>({});
|
|
44
46
|
// The registered wrapper's DOM node (web only) — RN-web's layout event
|
|
45
|
-
// exposes it as `nativeEvent.target`, which lets
|
|
47
|
+
// exposes it as `nativeEvent.target`, which lets us re-measure live.
|
|
46
48
|
const sectionEl = useRef<Partial<Record<K, Element>>>({});
|
|
47
49
|
const [activeKey, setActiveKey] = useState<K>(keys[0]);
|
|
48
50
|
const register = (key: K) => (e: LayoutChangeEvent) => {
|
|
@@ -50,27 +52,52 @@ export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): Sec
|
|
|
50
52
|
const target: unknown = (e.nativeEvent as { target?: unknown }).target;
|
|
51
53
|
if (typeof Element !== "undefined" && target instanceof Element) sectionEl.current[key] = target;
|
|
52
54
|
};
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The sections' CURRENT content-relative offsets: refreshed from the live DOM
|
|
57
|
+
* on web, the cached `layout.y` otherwise. THE source of truth for both the
|
|
58
|
+
* jump and the highlight — a spy reading a different number than the jump is
|
|
59
|
+
* a spy that points at the wrong section.
|
|
60
|
+
*
|
|
61
|
+
* WHY measure on every scroll event rather than trust the cache: `onLayout`
|
|
62
|
+
* does not refire when a section merely MOVES, so any async shift above it (a
|
|
63
|
+
* table finishing load, a disclosure opening, an image settling) strands the
|
|
64
|
+
* cache and the rail highlights the wrong section until something re-measures.
|
|
65
|
+
*
|
|
66
|
+
* WHY that is not the per-frame layout thrash it looks like: reading a rect
|
|
67
|
+
* forces a reflow only while layout is DIRTY — which is exactly the case where
|
|
68
|
+
* the cache would have lied. A pure scroll dirties no layout, so these reads
|
|
69
|
+
* hit a clean tree and cost about a property access. The pass only READS
|
|
70
|
+
* (container once, then each section), so a dirty frame costs ONE flush for
|
|
71
|
+
* all sections, not one per section. Coalescing behind rAF was rejected: it
|
|
72
|
+
* cannot beat "free when nothing changed", and it would make the highlight
|
|
73
|
+
* lag the scroll by a frame while adding a cancel-on-unmount lifecycle.
|
|
74
|
+
*/
|
|
75
|
+
const measure = (): Partial<Record<K, number>> => {
|
|
57
76
|
const sv = scrollRef.current as (ScrollView & { getScrollableNode?: () => unknown }) | null;
|
|
58
77
|
const sc = typeof sv?.getScrollableNode === "function" ? sv.getScrollableNode() : null;
|
|
59
|
-
if (typeof Element
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
if (typeof Element === "undefined" || !(sc instanceof Element)) return sectionY.current;
|
|
79
|
+
// The content origin in viewport coordinates; a section's top minus it is
|
|
80
|
+
// the content-relative offset both `scrollTo` and the spy speak in.
|
|
81
|
+
const origin = sc.getBoundingClientRect().top - sc.scrollTop;
|
|
82
|
+
for (const k of keys) {
|
|
83
|
+
const el = sectionEl.current[k];
|
|
84
|
+
// A detached node measures as 0, which would pin the spy to the last
|
|
85
|
+
// section — a section that broke the mounted contract keeps its last
|
|
86
|
+
// known offset instead, exactly as it does on native.
|
|
87
|
+
if (el?.isConnected) sectionY.current[k] = el.getBoundingClientRect().top - origin;
|
|
63
88
|
}
|
|
64
|
-
return sectionY.current
|
|
89
|
+
return sectionY.current;
|
|
65
90
|
};
|
|
66
91
|
const jumpTo = (key: K) => {
|
|
67
|
-
|
|
92
|
+
const y = measure()[key] ?? 0;
|
|
93
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, y - 12), animated: true });
|
|
68
94
|
};
|
|
69
95
|
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
70
96
|
const y = e.nativeEvent.contentOffset.y + 80;
|
|
97
|
+
const offsets = measure();
|
|
71
98
|
let cur: K = keys[0];
|
|
72
99
|
for (const k of keys) {
|
|
73
|
-
const sy =
|
|
100
|
+
const sy = offsets[k];
|
|
74
101
|
if (sy != null && sy <= y) cur = k;
|
|
75
102
|
}
|
|
76
103
|
if (cur !== activeKey) setActiveKey(cur);
|