@lotics/ui 8.0.0 → 10.0.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/AGENTS.md +177 -70
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +16 -13
- package/examples/tpl_tasks.tsx +15 -28
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +8 -7
- package/src/capture_row.tsx +59 -0
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/detail_row.tsx +137 -10
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +25 -0
- package/src/page_header.tsx +0 -2
- package/src/popover_nav.tsx +40 -0
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/suggestion_chip.tsx +47 -0
- package/src/trend_footer.tsx +3 -1
- package/src/use_screen_size.ts +1 -1
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_wizard.tsx +0 -223
- package/src/animation_horizontal_slide.tsx +0 -75
- package/src/form_time_picker.tsx +0 -22
- package/src/highlighted_text.tsx +0 -92
- package/src/menu_title.tsx +0 -15
- package/src/pager_view.tsx +0 -167
- package/src/popover_header.tsx +0 -38
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
|
|
5
|
+
export interface RecordSummaryMetric {
|
|
6
|
+
/** Caption above the number ("Outstanding", "Total value"). */
|
|
7
|
+
label: string;
|
|
8
|
+
/** The headline figure — rendered lg semibold tabular. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Valence of the figure (and its note) — the summary's ONE permitted accent. */
|
|
11
|
+
tone?: "default" | "danger" | "warning" | "success";
|
|
12
|
+
/** One short qualifier under the number ("Overdue 25 days"). */
|
|
13
|
+
note?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RecordSummaryProps {
|
|
17
|
+
/** The record's id/name — xl semibold, tabular so ids align across records. */
|
|
18
|
+
title: string;
|
|
19
|
+
/** Heading level of the title (`role="header"` + aria-level). Default 1 —
|
|
20
|
+
* the record page's one h1; pass 2 when the band sits inside a surface
|
|
21
|
+
* that already owns the h1 (a drawer with its own titled header). */
|
|
22
|
+
level?: 1 | 2 | 3;
|
|
23
|
+
/** Identity qualifiers on the title line ("HCM → Hamburg · Export · FCL") —
|
|
24
|
+
* sm muted; wraps under the title when narrow. */
|
|
25
|
+
subtitle?: string;
|
|
26
|
+
/** A status chip (a `Badge`) or any small node right after the title. */
|
|
27
|
+
status?: ReactNode;
|
|
28
|
+
/** ONE headline number with valence — pinned to the summary's top right.
|
|
29
|
+
* More than one figure belongs in a KPI strip, not the record header. */
|
|
30
|
+
metric?: RecordSummaryMetric;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The identity band of a single record's detail screen or drawer: ONE row —
|
|
35
|
+
* title · subtitle · status chip, with an optional headline metric pinned
|
|
36
|
+
* right. It exists to stop hand-rolled record headers drifting: mixed type
|
|
37
|
+
* scales, several competing figures, color noise.
|
|
38
|
+
*
|
|
39
|
+
* Color discipline: the header stays neutral; `metric.tone` is the one accent.
|
|
40
|
+
* The record's FIELDS never live in the header — compose them as
|
|
41
|
+
* `DetailTable`s in the sections below (`SectionHeading` + `Divider` rhythm) —
|
|
42
|
+
* see `examples/tpl_record.tsx`.
|
|
43
|
+
*/
|
|
44
|
+
export function RecordSummary(props: RecordSummaryProps) {
|
|
45
|
+
const { title, subtitle, status, metric, level = 1 } = props;
|
|
46
|
+
return (
|
|
47
|
+
<View style={styles.identityRow}>
|
|
48
|
+
<View style={styles.identity}>
|
|
49
|
+
<View style={styles.titleRow}>
|
|
50
|
+
<Text size="xl" weight="semibold" tabular level={level}>
|
|
51
|
+
{title}
|
|
52
|
+
</Text>
|
|
53
|
+
{status ?? null}
|
|
54
|
+
</View>
|
|
55
|
+
{subtitle ? (
|
|
56
|
+
<Text size="sm" color="muted">
|
|
57
|
+
{subtitle}
|
|
58
|
+
</Text>
|
|
59
|
+
) : null}
|
|
60
|
+
</View>
|
|
61
|
+
{metric ? (
|
|
62
|
+
<View style={styles.metric}>
|
|
63
|
+
<Text size="xs" color="muted" weight="medium" align="right">
|
|
64
|
+
{metric.label}
|
|
65
|
+
</Text>
|
|
66
|
+
<Text size="lg" weight="semibold" tabular align="right" color={metric.tone === "default" ? undefined : metric.tone}>
|
|
67
|
+
{metric.value}
|
|
68
|
+
</Text>
|
|
69
|
+
{metric.note ? (
|
|
70
|
+
<Text size="xs" align="right" weight={metric.tone && metric.tone !== "default" ? "medium" : "regular"} color={metric.tone && metric.tone !== "default" ? metric.tone : "muted"}>
|
|
71
|
+
{metric.note}
|
|
72
|
+
</Text>
|
|
73
|
+
) : null}
|
|
74
|
+
</View>
|
|
75
|
+
) : null}
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const styles = StyleSheet.create({
|
|
81
|
+
identityRow: {
|
|
82
|
+
flexDirection: "row",
|
|
83
|
+
alignItems: "flex-start",
|
|
84
|
+
gap: 16,
|
|
85
|
+
},
|
|
86
|
+
identity: {
|
|
87
|
+
flex: 1,
|
|
88
|
+
gap: 2,
|
|
89
|
+
},
|
|
90
|
+
titleRow: {
|
|
91
|
+
flexDirection: "row",
|
|
92
|
+
alignItems: "center",
|
|
93
|
+
flexWrap: "wrap",
|
|
94
|
+
columnGap: 10,
|
|
95
|
+
rowGap: 4,
|
|
96
|
+
},
|
|
97
|
+
metric: {
|
|
98
|
+
alignItems: "flex-end",
|
|
99
|
+
gap: 1,
|
|
100
|
+
},
|
|
101
|
+
});
|
package/src/section_heading.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
|
2
2
|
import { Text, type HeadingLevel } from "./text";
|
|
3
3
|
import { Icon, type IconName } from "./icon";
|
|
4
4
|
import { InfoPopover } from "./info_popover";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
5
6
|
|
|
6
7
|
// The card-less section header — the bare-canvas sibling of `CardHeader`, built
|
|
7
8
|
// the same compound way. A title (+ optional leading icon / description) on one
|
|
@@ -36,7 +37,12 @@ export interface SectionProps {
|
|
|
36
37
|
* </Section>
|
|
37
38
|
*/
|
|
38
39
|
export function Section(props: SectionProps) {
|
|
39
|
-
|
|
40
|
+
// gap 12 spaces the heading from its body. Space BETWEEN sections belongs to
|
|
41
|
+
// the page column: flat record/form pages use `gap: 32` on the content column
|
|
42
|
+
// with a bare `Divider` BETWEEN sections (never under the heading — the
|
|
43
|
+
// heading belongs to its content; the hairline separates it from the LAST
|
|
44
|
+
// section).
|
|
45
|
+
return <View style={[{ gap: 12 }, props.style]}>{props.children}</View>;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export interface SectionHeadingProps {
|
|
@@ -58,26 +64,28 @@ export interface SectionHeadingTitleProps {
|
|
|
58
64
|
icon?: IconName;
|
|
59
65
|
/** Heading rank. Defaults to 2 — typical for page-level section titles. */
|
|
60
66
|
level?: HeadingLevel;
|
|
61
|
-
/** Title weight. Defaults to `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
67
|
+
/** Title weight. Defaults to `semibold` — a section title must separate from
|
|
68
|
+
* the body rows at a glance (medium blends in; it remains as an opt-down for
|
|
69
|
+
* a surface where the heading competes with a stronger band above it). */
|
|
64
70
|
weight?: "medium" | "semibold";
|
|
65
71
|
/** An ⓘ popover after the title — a short "what this is / where it came from"
|
|
66
72
|
* gloss, mirroring `CardHeaderTitle`'s `info`. */
|
|
67
73
|
info?: string;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
|
-
/** The title block —
|
|
76
|
+
/** The title block — lg semibold (a section must announce itself; md blends
|
|
77
|
+
* into control labels) + an optional muted `description` line. Grows to push
|
|
78
|
+
* any sibling actions to the right edge. */
|
|
71
79
|
export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
72
|
-
const { children, description, icon, level = 2, weight = "
|
|
80
|
+
const { children, description, icon, level = 2, weight = "semibold", info } = props;
|
|
73
81
|
return (
|
|
74
82
|
<View style={{ flex: 1, gap: 2 }}>
|
|
75
83
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
76
84
|
{icon ? <Icon name={icon} size={18} /> : null}
|
|
77
|
-
<Text level={level} weight={weight} size="
|
|
85
|
+
<Text level={level} weight={weight} size="lg">
|
|
78
86
|
{children}
|
|
79
87
|
</Text>
|
|
80
|
-
{info ? <InfoPopover text={info} accessibilityLabel=
|
|
88
|
+
{info ? <InfoPopover text={info} accessibilityLabel={useLoticsLocale().sectionHeading.info} /> : null}
|
|
81
89
|
</View>
|
|
82
90
|
{description ? (
|
|
83
91
|
<Text color="zinc-500" size="sm">
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Chip } from "./chip";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
|
+
import { Text } from "./text";
|
|
7
|
+
|
|
8
|
+
export interface SuggestionChipProps {
|
|
9
|
+
/** The suggested item ("Verify the customer's tax ID"). */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Materialize the suggestion. */
|
|
12
|
+
onAdd: () => void;
|
|
13
|
+
/** Refuse the suggestion (the ✕). Omit for take-it-or-leave-it pills. */
|
|
14
|
+
onDismiss?: () => void;
|
|
15
|
+
/** Accessible name of the press target; default "Add: <label>". */
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
/** The ✕'s name; defaults to the locale's "Dismiss suggestion". */
|
|
18
|
+
dismissLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A dismissible SUGGESTION pill — an item the record could have but doesn't
|
|
23
|
+
* yet (a common task, an expected line): a `Chip` whose press MATERIALIZES it
|
|
24
|
+
* and whose ✕ refuses it. A pill can never be mistaken for the real row it
|
|
25
|
+
* would become; suggestions never count in any total. Filter out labels the
|
|
26
|
+
* list already holds before rendering.
|
|
27
|
+
*/
|
|
28
|
+
export function SuggestionChip(props: SuggestionChipProps) {
|
|
29
|
+
const { label, onAdd, onDismiss, accessibilityLabel, dismissLabel } = props;
|
|
30
|
+
const labels = useLoticsLocale().suggestionChip;
|
|
31
|
+
return (
|
|
32
|
+
<Chip onPress={onAdd} accessibilityLabel={accessibilityLabel ?? labels.add(label)} onDismiss={onDismiss} dismissTooltip={dismissLabel ?? labels.dismiss}>
|
|
33
|
+
<View style={styles.content}>
|
|
34
|
+
<Icon name="plus" size={15} color={colors.zinc[500]} />
|
|
35
|
+
<Text size="sm">{label}</Text>
|
|
36
|
+
</View>
|
|
37
|
+
</Chip>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const styles = StyleSheet.create({
|
|
42
|
+
content: {
|
|
43
|
+
flexDirection: "row",
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
gap: 6,
|
|
46
|
+
},
|
|
47
|
+
});
|
package/src/trend_footer.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TrendingDown, TrendingUp } from "lucide-react";
|
|
2
|
+
import { useLoticsLocale } from "./locale";
|
|
2
3
|
import { View, StyleSheet } from "react-native";
|
|
3
4
|
import { Text } from "./text";
|
|
4
5
|
import { colors } from "./colors";
|
|
@@ -32,13 +33,14 @@ export function TrendFooter(props: TrendFooterProps) {
|
|
|
32
33
|
const flat = value === 0;
|
|
33
34
|
const isGood = flat ? null : (up && goodDirection === "up") || (!up && goodDirection === "down");
|
|
34
35
|
const color = isGood === null ? colors.zinc[600] : isGood ? colors.green[700] : colors.red[700];
|
|
36
|
+
const labels = useLoticsLocale().trendFooter;
|
|
35
37
|
const Icon = up ? TrendingUp : TrendingDown;
|
|
36
38
|
return (
|
|
37
39
|
<View style={styles.container}>
|
|
38
40
|
<View style={styles.row}>
|
|
39
41
|
<Icon size={16} color={color} />
|
|
40
42
|
<Text size="sm" weight="medium" style={{ color }}>
|
|
41
|
-
{up ?
|
|
43
|
+
{up ? labels.up : labels.down} {Math.abs(value)}% {periodLabel}
|
|
42
44
|
</Text>
|
|
43
45
|
</View>
|
|
44
46
|
{detail && (
|
package/src/use_screen_size.ts
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { useSectionNav } from "./use_section_nav";
|
|
5
|
+
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
|
|
6
|
+
|
|
7
|
+
const layoutAt = (y: number) => ({ nativeEvent: { layout: { y } } }) as LayoutChangeEvent;
|
|
8
|
+
const scrollTo = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as NativeSyntheticEvent<NativeScrollEvent>;
|
|
9
|
+
|
|
10
|
+
function mounted() {
|
|
11
|
+
const hook = renderHook(() => useSectionNav(["a", "b", "c"] as const));
|
|
12
|
+
act(() => {
|
|
13
|
+
hook.result.current.register("a")(layoutAt(0));
|
|
14
|
+
hook.result.current.register("b")(layoutAt(400));
|
|
15
|
+
hook.result.current.register("c")(layoutAt(900));
|
|
16
|
+
});
|
|
17
|
+
return hook;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("useSectionNav", () => {
|
|
21
|
+
it("starts on the first key", () => {
|
|
22
|
+
const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
|
|
23
|
+
expect(result.current.activeKey).toBe("a");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("activates the last section whose top passed the viewport edge (+80 threshold)", () => {
|
|
27
|
+
const { result } = mounted();
|
|
28
|
+
act(() => result.current.onScroll(scrollTo(0)));
|
|
29
|
+
expect(result.current.activeKey).toBe("a");
|
|
30
|
+
// 321 + 80 threshold = 401 >= b's 400 → b is active
|
|
31
|
+
act(() => result.current.onScroll(scrollTo(321)));
|
|
32
|
+
expect(result.current.activeKey).toBe("b");
|
|
33
|
+
// just short of the threshold stays on a
|
|
34
|
+
act(() => result.current.onScroll(scrollTo(319)));
|
|
35
|
+
expect(result.current.activeKey).toBe("a");
|
|
36
|
+
act(() => result.current.onScroll(scrollTo(2000)));
|
|
37
|
+
expect(result.current.activeKey).toBe("c");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keys walk in page order — a later key with a smaller offset never wins", () => {
|
|
41
|
+
const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
|
|
42
|
+
act(() => {
|
|
43
|
+
result.current.register("a")(layoutAt(500));
|
|
44
|
+
result.current.register("b")(layoutAt(100));
|
|
45
|
+
});
|
|
46
|
+
act(() => result.current.onScroll(scrollTo(600)));
|
|
47
|
+
// both tops passed; the LAST key in page order wins
|
|
48
|
+
expect(result.current.activeKey).toBe("b");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ignores keys that never registered", () => {
|
|
52
|
+
const { result } = renderHook(() => useSectionNav(["a", "b", "c"] as const));
|
|
53
|
+
act(() => result.current.register("a")(layoutAt(0)));
|
|
54
|
+
act(() => result.current.onScroll(scrollTo(999)));
|
|
55
|
+
expect(result.current.activeKey).toBe("a");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("jumpTo scrolls to the section's offset minus the 12px breathing room, clamped at 0", () => {
|
|
59
|
+
const { result } = mounted();
|
|
60
|
+
const calls: { y: number }[] = [];
|
|
61
|
+
(result.current.scrollRef as { current: unknown }).current = {
|
|
62
|
+
scrollTo: (opts: { y: number }) => calls.push(opts),
|
|
63
|
+
};
|
|
64
|
+
act(() => result.current.jumpTo("b"));
|
|
65
|
+
expect(calls[0]?.y).toBe(388);
|
|
66
|
+
act(() => result.current.jumpTo("a"));
|
|
67
|
+
expect(calls[1]?.y).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RefObject, useRef, useState } from "react";
|
|
2
|
+
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface SectionNavHandle<K extends string> {
|
|
5
|
+
/** Attach to the content `ScrollView` (with `onScroll` + `scrollEventThrottle={16}`). */
|
|
6
|
+
scrollRef: RefObject<ScrollView | null>;
|
|
7
|
+
/** The section the scroll currently sits in — drives the rail's `selected`. */
|
|
8
|
+
activeKey: K;
|
|
9
|
+
/** `onLayout={register(key)}` on each section wrapper. The wrapper must be a
|
|
10
|
+
* DIRECT child of the ScrollView content (layout.y is content-relative). */
|
|
11
|
+
register: (key: K) => (e: LayoutChangeEvent) => void;
|
|
12
|
+
/** Scroll to a section — the rail item's `onPress`. */
|
|
13
|
+
jumpTo: (key: K) => void;
|
|
14
|
+
onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scroll-spy for a LONG record surface with a left outline rail: one scrolling
|
|
19
|
+
* page whose sections register their offsets, a rail of `MenuButton`s that
|
|
20
|
+
* jumps to them, and an `activeKey` that follows the scroll (the section whose
|
|
21
|
+
* top has passed the viewport edge is the active one).
|
|
22
|
+
*
|
|
23
|
+
* const nav = useSectionNav(["details", "gatein", "gateout"] as const);
|
|
24
|
+
* <MenuButton title="Gate in" selected={nav.activeKey === "gatein"}
|
|
25
|
+
* onPress={() => nav.jumpTo("gatein")} />
|
|
26
|
+
* <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16}>
|
|
27
|
+
* <View onLayout={nav.register("gatein")}>…</View>
|
|
28
|
+
*
|
|
29
|
+
* Pass the keys in PAGE ORDER — the spy walks them top-down. Hide the rail on
|
|
30
|
+
* narrow containers (the page still scrolls; the rail is a wide-screen aid).
|
|
31
|
+
*
|
|
32
|
+
* Contract: every key's section stays MOUNTED (an unmounted section leaves its
|
|
33
|
+
* last offset registered — conditional sections belong inside an always-mounted
|
|
34
|
+
* wrapper that carries the `onLayout`), and offsets refresh only when a
|
|
35
|
+
* section's own layout changes — react-native-web's `onLayout` won't refire on
|
|
36
|
+
* a pure position shift, so content above the sections should settle before
|
|
37
|
+
* precision matters.
|
|
38
|
+
*/
|
|
39
|
+
export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): SectionNavHandle<K> {
|
|
40
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
41
|
+
const sectionY = useRef<Partial<Record<K, number>>>({});
|
|
42
|
+
const [activeKey, setActiveKey] = useState<K>(keys[0]);
|
|
43
|
+
const register = (key: K) => (e: LayoutChangeEvent) => {
|
|
44
|
+
sectionY.current[key] = e.nativeEvent.layout.y;
|
|
45
|
+
};
|
|
46
|
+
const jumpTo = (key: K) => {
|
|
47
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, (sectionY.current[key] ?? 0) - 12), animated: true });
|
|
48
|
+
};
|
|
49
|
+
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
50
|
+
const y = e.nativeEvent.contentOffset.y + 80;
|
|
51
|
+
let cur: K = keys[0];
|
|
52
|
+
for (const k of keys) {
|
|
53
|
+
const sy = sectionY.current[k];
|
|
54
|
+
if (sy != null && sy <= y) cur = k;
|
|
55
|
+
}
|
|
56
|
+
if (cur !== activeKey) setActiveKey(cur);
|
|
57
|
+
};
|
|
58
|
+
return { scrollRef, activeKey, register, jumpTo, onScroll };
|
|
59
|
+
}
|