@lotics/ui 46.1.0 → 46.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/AGENTS.md +24 -0
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +34 -14
- package/docs/catalog.md +109 -22
- package/docs/composition.md +48 -6
- package/docs/templates.md +3 -1
- package/docs/testing.md +6 -0
- package/package.json +3 -2
- package/src/agent_run.tsx +74 -58
- package/src/agent_run_pane.tsx +7 -5
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- package/src/line_chart.tsx +2 -2
- package/src/locale.tsx +4 -4
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/src/line_chart_labels.ts +0 -32
package/src/dialog.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
useNavigationStack,
|
|
18
18
|
} from "./screen_router";
|
|
19
19
|
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
20
|
+
import { useScrollSeam } from "./use_scroll_seam";
|
|
20
21
|
|
|
21
22
|
// ============================================================================
|
|
22
23
|
// Shared Navigation Context (used by both Dialog and MasterDetailDialog)
|
|
@@ -191,29 +192,39 @@ export function Dialog(props: DialogProps) {
|
|
|
191
192
|
<ScreenRouterInternalContext.Provider value={internalValue}>
|
|
192
193
|
<DialogContext.Provider value={dialogValue}>
|
|
193
194
|
<DialogNavigationProvider value={navigationContextValue}>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
195
|
+
{/* Mounted only while OPEN. react-native-web appends a `Modal`'s
|
|
196
|
+
body-level div on first render and never re-orders it, so an
|
|
197
|
+
always-mounted dialog claims its DOM slot before a drawer that
|
|
198
|
+
opens later and is then covered by it — readable, announced, and
|
|
199
|
+
dead to every press. Mounting on open makes DOM order open order.
|
|
200
|
+
Nothing is lost: a closed `Modal` renders its children as `null`
|
|
201
|
+
already, and everything that must survive a close (the router,
|
|
202
|
+
the contexts) lives outside this element. */}
|
|
203
|
+
{open && (
|
|
204
|
+
<Modal visible onRequestClose={handleClose} transparent>
|
|
205
|
+
<View style={styles.base}>
|
|
206
|
+
<View style={styles.background} />
|
|
207
|
+
<Animated.View
|
|
208
|
+
style={{
|
|
209
|
+
top: effectiveOffsetTop,
|
|
210
|
+
width: screenSize.small ? "100%" : width,
|
|
211
|
+
height: screenSize.small ? "100%" : height,
|
|
212
|
+
maxHeight: screenSize.small ? undefined : maxHeight,
|
|
213
|
+
maxWidth: screenSize.small ? undefined : maxWidth,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<PortalHost>
|
|
217
|
+
<View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
|
|
218
|
+
<View style={[styles.closeButtonContainer, { paddingHorizontal: gutter }]}>
|
|
219
|
+
<IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
|
|
220
|
+
</View>
|
|
221
|
+
<SizeBoundary style={styles.container}>{children}</SizeBoundary>
|
|
210
222
|
</View>
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
</Modal>
|
|
223
|
+
</PortalHost>
|
|
224
|
+
</Animated.View>
|
|
225
|
+
</View>
|
|
226
|
+
</Modal>
|
|
227
|
+
)}
|
|
217
228
|
</DialogNavigationProvider>
|
|
218
229
|
</DialogContext.Provider>
|
|
219
230
|
</ScreenRouterInternalContext.Provider>
|
|
@@ -280,11 +291,19 @@ export function DialogHeaderActions(props: DialogHeaderActionsProps) {
|
|
|
280
291
|
|
|
281
292
|
export interface DialogScrollAreaProps {
|
|
282
293
|
children: React.ReactNode;
|
|
294
|
+
/**
|
|
295
|
+
* The IDENTITY of the content in the scroller, for a pane that SWAPS its body
|
|
296
|
+
* in place — see `DrawerScrollArea`. A dialog that navigates between `Screen`s
|
|
297
|
+
* does not need it: a stacked screen stays mounted, so each screen keeps its
|
|
298
|
+
* own scroll area and its own offset already.
|
|
299
|
+
*/
|
|
300
|
+
scrollKey?: string;
|
|
283
301
|
}
|
|
284
302
|
|
|
285
303
|
export function DialogScrollArea(props: DialogScrollAreaProps) {
|
|
286
|
-
const { children } = props;
|
|
304
|
+
const { children, scrollKey } = props;
|
|
287
305
|
const gutter = useDialogGutter();
|
|
306
|
+
const seam = useScrollSeam(scrollKey);
|
|
288
307
|
|
|
289
308
|
// The gutter and the heading altitude are the same kind of fact — both belong
|
|
290
309
|
// to the panel, and both were being answered by callers who could only guess.
|
|
@@ -292,7 +311,10 @@ export function DialogScrollArea(props: DialogScrollAreaProps) {
|
|
|
292
311
|
// `lg` `DialogHeaderTitle` above it. See `heading_altitude.ts`.
|
|
293
312
|
return (
|
|
294
313
|
<HeadingAltitudeContext.Provider value="panel">
|
|
295
|
-
<ScrollView
|
|
314
|
+
<ScrollView
|
|
315
|
+
{...seam}
|
|
316
|
+
contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: gutter }]}
|
|
317
|
+
>
|
|
296
318
|
{children}
|
|
297
319
|
</ScrollView>
|
|
298
320
|
</HeadingAltitudeContext.Provider>
|
package/src/drawer.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { Text } from "@lotics/ui/text";
|
|
|
9
9
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
10
10
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
11
11
|
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
12
|
+
import { useScrollSeam } from "./use_scroll_seam";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The panel's inset — the ONE left edge the header, `DrawerScrollArea` and the footer
|
|
@@ -103,8 +104,14 @@ export function Drawer(props: DrawerProps) {
|
|
|
103
104
|
return () => document.removeEventListener("keydown", handler);
|
|
104
105
|
}, [open, onPrev, onNext]);
|
|
105
106
|
|
|
107
|
+
// Mounted only while OPEN — see `overlay_layer.ts`. react-native-web appends a
|
|
108
|
+
// `Modal`'s body-level div on first render and never re-orders it, so an
|
|
109
|
+
// always-mounted overlay claims its slot ahead of one opened later and covers
|
|
110
|
+
// it. Mounting on open makes DOM order open order.
|
|
111
|
+
if (!open) return null;
|
|
112
|
+
|
|
106
113
|
return (
|
|
107
|
-
<Modal visible
|
|
114
|
+
<Modal visible onRequestClose={handleClose} transparent>
|
|
108
115
|
<View style={styles.base}>
|
|
109
116
|
{/* Scrim is a sibling of the panel, so tapping the panel never closes. */}
|
|
110
117
|
<Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel={loc.close} tabIndex={-1} />
|
|
@@ -153,6 +160,17 @@ export function Drawer(props: DrawerProps) {
|
|
|
153
160
|
|
|
154
161
|
export interface DrawerScrollAreaProps {
|
|
155
162
|
children: ReactNode;
|
|
163
|
+
/**
|
|
164
|
+
* The IDENTITY of the content in the scroller — a record id, a step name.
|
|
165
|
+
*
|
|
166
|
+
* Set it on a drawer that SWAPS its body in place (the master-detail shape: a
|
|
167
|
+
* child row replaces the record rather than stacking a second drawer).
|
|
168
|
+
* Changing it opens the new content at the top and restores the previous
|
|
169
|
+
* content's offset when the key comes back, so the way forward starts where a
|
|
170
|
+
* reader expects and the way back keeps their place in the list they came
|
|
171
|
+
* from. Omit it on a drawer whose body is one thing.
|
|
172
|
+
*/
|
|
173
|
+
scrollKey?: string;
|
|
156
174
|
}
|
|
157
175
|
|
|
158
176
|
/**
|
|
@@ -192,9 +210,10 @@ export function DrawerScrollArea(props: DrawerScrollAreaProps) {
|
|
|
192
210
|
// Not on `Drawer` itself: the drawer's BARE slot is where a whole record
|
|
193
211
|
// screen goes, and that surface brings its own `#` identity band, so its
|
|
194
212
|
// sections are page sections and must stay `##`. See `heading_altitude.ts`.
|
|
213
|
+
const seam = useScrollSeam(props.scrollKey);
|
|
195
214
|
return (
|
|
196
215
|
<HeadingAltitudeContext.Provider value="panel">
|
|
197
|
-
<ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>
|
|
216
|
+
<ScrollView {...seam} style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>
|
|
198
217
|
</HeadingAltitudeContext.Provider>
|
|
199
218
|
);
|
|
200
219
|
}
|
|
@@ -211,6 +211,9 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
211
211
|
];
|
|
212
212
|
|
|
213
213
|
return (
|
|
214
|
+
// Rendered only while a file is open (`activeIndex !== null` returns null
|
|
215
|
+
// above), which is what puts this overlay's body-level div in open order —
|
|
216
|
+
// see `overlay_layer.ts`.
|
|
214
217
|
<Modal visible transparent onRequestClose={close} animationType="fade">
|
|
215
218
|
{/* PortalHost so the ⋯ menu's popover portals INSIDE the modal's stacking
|
|
216
219
|
context (on top) instead of to the app-root host behind the overlay —
|
package/src/line_chart.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { useMemo, useState, useCallback } from "react";
|
|
5
5
|
import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
|
|
6
6
|
import { useLoticsLocale } from "./locale";
|
|
7
|
-
import {
|
|
7
|
+
import { axisLabelIndices } from "./axis_label_indices";
|
|
8
8
|
|
|
9
9
|
export interface LineChartPoint {
|
|
10
10
|
x: string | number;
|
|
@@ -97,7 +97,7 @@ export function LineChart(props: LineChartProps) {
|
|
|
97
97
|
const visibleLabels = useMemo(() => {
|
|
98
98
|
if (data.length === 0 || chartWidth === 0) return [];
|
|
99
99
|
|
|
100
|
-
return
|
|
100
|
+
return axisLabelIndices(data.length, chartWidth).map((index) => ({
|
|
101
101
|
index,
|
|
102
102
|
label: formatXLabel(data[index].x),
|
|
103
103
|
}));
|
package/src/locale.tsx
CHANGED
|
@@ -460,9 +460,9 @@ export const en: LoticsLocale = {
|
|
|
460
460
|
scrollToBottom: { tooltip: "Scroll to bottom" },
|
|
461
461
|
textInputField: { clear: "Clear" },
|
|
462
462
|
agentRun: {
|
|
463
|
-
starting: "Starting
|
|
463
|
+
starting: "Starting",
|
|
464
464
|
thinking: "Thinking",
|
|
465
|
-
thinkingStreaming: "Thinking
|
|
465
|
+
thinkingStreaming: "Thinking",
|
|
466
466
|
input: "Input",
|
|
467
467
|
error: "Error",
|
|
468
468
|
output: "Output",
|
|
@@ -667,9 +667,9 @@ export const vi: LoticsLocale = {
|
|
|
667
667
|
scrollToBottom: { tooltip: "Cuộn xuống cuối" },
|
|
668
668
|
textInputField: { clear: "Xóa" },
|
|
669
669
|
agentRun: {
|
|
670
|
-
starting: "Đang bắt đầu
|
|
670
|
+
starting: "Đang bắt đầu",
|
|
671
671
|
thinking: "Suy nghĩ",
|
|
672
|
-
thinkingStreaming: "Đang suy
|
|
672
|
+
thinkingStreaming: "Đang suy nghĩ",
|
|
673
673
|
input: "Đầu vào",
|
|
674
674
|
error: "Lỗi",
|
|
675
675
|
output: "Kết quả",
|
package/src/modal.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { PortalHost } from "@lotics/ui/portal";
|
|
|
7
7
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
8
8
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
9
9
|
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
10
|
+
import { useScrollSeam } from "./use_scroll_seam";
|
|
10
11
|
|
|
11
12
|
export interface ModalProps {
|
|
12
13
|
open: boolean;
|
|
@@ -34,8 +35,17 @@ export function Modal(props: ModalProps) {
|
|
|
34
35
|
const { open, onClose, children, testID } = props;
|
|
35
36
|
useOverlayScope(open);
|
|
36
37
|
|
|
38
|
+
// Mounted only while OPEN — see `overlay_layer.ts`. react-native-web appends a
|
|
39
|
+
// `Modal`'s body-level div on first render and never re-orders it, so an
|
|
40
|
+
// always-mounted overlay claims its slot ahead of one opened later and covers
|
|
41
|
+
// it. Mounting on open makes DOM order open order. `animationType` still
|
|
42
|
+
// FADES IN (the element mounts already visible and the animation runs on the
|
|
43
|
+
// first paint); the exit is immediate, exactly as `FileGalleryModal`'s
|
|
44
|
+
// full-screen takeover has always closed.
|
|
45
|
+
if (!open) return null;
|
|
46
|
+
|
|
37
47
|
return (
|
|
38
|
-
<RNModal visible
|
|
48
|
+
<RNModal visible onRequestClose={onClose} animationType="fade">
|
|
39
49
|
<View testID={testID} style={styles.surface}>
|
|
40
50
|
<PortalHost>{children}</PortalHost>
|
|
41
51
|
</View>
|
|
@@ -93,6 +103,13 @@ export function ModalHeader(props: ModalHeaderProps) {
|
|
|
93
103
|
|
|
94
104
|
export interface ModalBodyProps {
|
|
95
105
|
children: ReactNode;
|
|
106
|
+
/**
|
|
107
|
+
* The IDENTITY of the content in the scroller, for a takeover that SWAPS its
|
|
108
|
+
* body in place — a wizard stepping between steps, a console changing subject.
|
|
109
|
+
* Changing it opens the new content at the top and restores the previous
|
|
110
|
+
* content's offset when the key comes back. See `DrawerScrollArea`.
|
|
111
|
+
*/
|
|
112
|
+
scrollKey?: string;
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
/**
|
|
@@ -100,14 +117,17 @@ export interface ModalBodyProps {
|
|
|
100
117
|
* footer. Fills the remaining height; its content scrolls.
|
|
101
118
|
*/
|
|
102
119
|
export function ModalBody(props: ModalBodyProps) {
|
|
103
|
-
const { children } = props;
|
|
120
|
+
const { children, scrollKey } = props;
|
|
121
|
+
const seam = useScrollSeam(scrollKey);
|
|
104
122
|
// A takeover is still a panel: `ModalHeader`'s title is `lg`, so a section
|
|
105
123
|
// heading inside the body takes the ramp's `####` rung. Same rule as the
|
|
106
124
|
// drawer and the dialog, published from the same kind of region — the one
|
|
107
125
|
// that owns the surface's padding. See `heading_altitude.ts`.
|
|
108
126
|
return (
|
|
109
127
|
<HeadingAltitudeContext.Provider value="panel">
|
|
110
|
-
<ScrollView contentContainerStyle={styles.bodyContent}>
|
|
128
|
+
<ScrollView {...seam} contentContainerStyle={styles.bodyContent}>
|
|
129
|
+
{children}
|
|
130
|
+
</ScrollView>
|
|
111
131
|
</HeadingAltitudeContext.Provider>
|
|
112
132
|
);
|
|
113
133
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PAINT ORDER OF EVERYTHING THE KIT PUTS AT THE TOP OF THE DOCUMENT.
|
|
3
|
+
*
|
|
4
|
+
* Every overlay in the kit (`Dialog`, `Drawer`, `Modal`, `FileGalleryModal`) is
|
|
5
|
+
* a react-native `Modal`, and on web that mounts a bare `<div>` on
|
|
6
|
+
* `document.body`, at the END of it, and removes it again when it unmounts. The
|
|
7
|
+
* layer inside sits at one fixed z-index, the same one for every overlay, so two
|
|
8
|
+
* open overlays tie and the tie is broken by DOM order.
|
|
9
|
+
*
|
|
10
|
+
* So the rule is one line of composition, not a mechanism: **the react-native
|
|
11
|
+
* `Modal` element is rendered only while the overlay is OPEN**. Its body-level
|
|
12
|
+
* div is then appended when it opens and removed when it closes, DOM order is
|
|
13
|
+
* open order, and paint order follows for free — a drawer opened from a dialog
|
|
14
|
+
* covers the dialog, a dialog opened from a drawer covers the drawer, and
|
|
15
|
+
* neither composition has to be picked in advance.
|
|
16
|
+
*
|
|
17
|
+
* Mounting the `Modal` while CLOSED is what breaks this, and it breaks it
|
|
18
|
+
* silently: `ModalPortal` appends its div on FIRST RENDER and never re-orders
|
|
19
|
+
* it, so an always-mounted dialog claims its slot on the app's first paint and a
|
|
20
|
+
* drawer opened later lands after it and covers it. The dialog then renders
|
|
21
|
+
* perfectly — centred, readable, correctly announced — and every control of it
|
|
22
|
+
* under the drawer panel is dead, because `elementFromPoint` there answers the
|
|
23
|
+
* drawer. Nothing is lost by not mounting it: react-native-web renders a closed
|
|
24
|
+
* `Modal`'s children as `null` anyway, so the subtree is already unmounted; only
|
|
25
|
+
* the empty portal div was being held.
|
|
26
|
+
*
|
|
27
|
+
* The rungs BESIDE the overlays are what needs naming, and they are a PUBLISHED
|
|
28
|
+
* CONTRACT — anything outside the kit that must clear a Lotics overlay reads one
|
|
29
|
+
* from here rather than picking a literal:
|
|
30
|
+
*
|
|
31
|
+
* 9999 every overlay, and `Popover` (`OVERLAY_Z`)
|
|
32
|
+
* 10000 `Tooltip`, `Alert` (`OVERLAY_Z_ABOVE`)
|
|
33
|
+
* 10001 a transient notification / toast (`NOTIFICATION_Z`)
|
|
34
|
+
* 10002 the skip link (`SKIP_LINK_Z`)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where every overlay sits — react-native-web's own number for a `Modal`, and
|
|
39
|
+
* the one `Popover` writes into its panel.
|
|
40
|
+
*
|
|
41
|
+
* They all share it on purpose. A popover is not an RN `Modal`: it portals into
|
|
42
|
+
* the nearest `PortalHost`, so a popover opened INSIDE an overlay is already
|
|
43
|
+
* inside that overlay's stacking context, and a page-level one is a child of the
|
|
44
|
+
* app root — which every modal's body-level div follows. One number plus DOM
|
|
45
|
+
* order therefore says the same thing for a popover as for a modal: whatever was
|
|
46
|
+
* opened last is on top.
|
|
47
|
+
*/
|
|
48
|
+
export const OVERLAY_Z = 9999;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ABOVE EVERY OVERLAY — a tooltip and an alert are ABOUT the surface under them,
|
|
52
|
+
* so neither can ever be covered by it.
|
|
53
|
+
*/
|
|
54
|
+
export const OVERLAY_Z_ABOVE = OVERLAY_Z + 1;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A transient notification (a toast) reporting the outcome of an action — over
|
|
58
|
+
* the alert on the rung below, because the action that raised it is often the
|
|
59
|
+
* one that alert confirmed, and a report nobody can read is not a report.
|
|
60
|
+
*/
|
|
61
|
+
export const NOTIFICATION_Z = OVERLAY_Z_ABOVE + 1;
|
|
62
|
+
|
|
63
|
+
/** The skip link outranks everything: it is the first thing a keyboard reaches.
|
|
64
|
+
* It shared a rung with the toast until this table gave each one a name. */
|
|
65
|
+
export const SKIP_LINK_Z = NOTIFICATION_Z + 1;
|
package/src/page_content.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { ScrollView, View } from "react-native";
|
|
2
|
-
import { Text } from "@lotics/ui/text";
|
|
3
2
|
import { colors } from "@lotics/ui/colors";
|
|
4
|
-
import {
|
|
3
|
+
import { PageHeader } from "@lotics/ui/page_header";
|
|
5
4
|
import { ReactNode } from "react";
|
|
6
5
|
import { useContainerSize } from "@lotics/ui/size_boundary";
|
|
7
6
|
import { pagePad } from "@lotics/ui/spacing";
|
|
@@ -81,27 +80,14 @@ export function PageContent(props: PageContentProps) {
|
|
|
81
80
|
paddingHorizontal: pad,
|
|
82
81
|
}}
|
|
83
82
|
>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{!!title && (
|
|
92
|
-
<Text size="xxl" weight="semibold">
|
|
93
|
-
{title}
|
|
94
|
-
</Text>
|
|
95
|
-
)}
|
|
96
|
-
{titleRight && <View>{titleRight}</View>}
|
|
97
|
-
</View>
|
|
98
|
-
{!!description && (
|
|
99
|
-
<>
|
|
100
|
-
{title && <Spacer size={8} />}
|
|
101
|
-
<Text color="zinc-500">{description}</Text>
|
|
102
|
-
</>
|
|
83
|
+
{/* The title band IS a `PageHeader` — the same optional xxl title, the
|
|
84
|
+
same right-hand slot, the same zinc-500 description, so it is that
|
|
85
|
+
component and not a second copy of it. Written out here it drifted
|
|
86
|
+
immediately: two spellings of one row law and two rhythms around
|
|
87
|
+
it, with `titleRight` and `actions` naming the same slot. */}
|
|
88
|
+
{(!!title || !!description || !!titleRight) && (
|
|
89
|
+
<PageHeader title={title} description={description} actions={titleRight} />
|
|
103
90
|
)}
|
|
104
|
-
{(title || description) && <Spacer size={24} />}
|
|
105
91
|
<View style={{ flex: 1 }}>{children}</View>
|
|
106
92
|
</View>
|
|
107
93
|
</ScrollView>
|
package/src/page_header.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { View } from "react-native";
|
|
2
2
|
import { Text } from "@lotics/ui/text";
|
|
3
|
+
import { Spacer } from "@lotics/ui/spacer";
|
|
3
4
|
import { ReactNode } from "react";
|
|
4
5
|
|
|
5
6
|
interface PageHeaderProps {
|
|
6
|
-
|
|
7
|
+
/** The page's name. Optional only so the SHELL can delegate to this component
|
|
8
|
+
* for a band that is description-only; a page header normally has one. */
|
|
9
|
+
title?: string;
|
|
7
10
|
description?: string | null;
|
|
8
11
|
/** The nav row ABOVE the title — breadcrumbs, a back control, row-level status. */
|
|
9
12
|
left?: ReactNode;
|
|
@@ -60,7 +63,6 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
60
63
|
style={{
|
|
61
64
|
flexDirection: "row",
|
|
62
65
|
alignItems: "center",
|
|
63
|
-
justifyContent: "space-between",
|
|
64
66
|
flexWrap: "wrap",
|
|
65
67
|
gap: 12,
|
|
66
68
|
}}
|
|
@@ -74,9 +76,11 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
74
76
|
minWidth: 0,
|
|
75
77
|
}}
|
|
76
78
|
>
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
79
|
+
{!!title && (
|
|
80
|
+
<Text size="xxl" weight="semibold" style={{ flexShrink: 1, minWidth: 0 }}>
|
|
81
|
+
{title}
|
|
82
|
+
</Text>
|
|
83
|
+
)}
|
|
80
84
|
{/* Never shrinks: the title is what gives way, and a control squeezed
|
|
81
85
|
below its own icon is not a smaller control, it is a broken one. */}
|
|
82
86
|
{trailing !== undefined && <View style={{ flexShrink: 0 }}>{trailing}</View>}
|
|
@@ -84,8 +88,14 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
84
88
|
{/* Wrapped so which side gives way is STATED rather than left to whatever
|
|
85
89
|
the caller happened to pass. A bare `{actions}` inherited its own
|
|
86
90
|
shrink behaviour, so the row's bargain held or broke depending on the
|
|
87
|
-
CTA — and a bargain that depends on the other party is not one.
|
|
88
|
-
|
|
91
|
+
CTA — and a bargain that depends on the other party is not one.
|
|
92
|
+
`marginLeft: auto` rather than the row's `justifyContent`, because
|
|
93
|
+
justification is applied PER LINE: once the row wraps, the second line
|
|
94
|
+
holds only the actions and `space-between` packs that one item at the
|
|
95
|
+
START, dropping the CTA to the left edge under the title on exactly the
|
|
96
|
+
narrow frame the wrap exists for. An auto margin right-aligns it on the
|
|
97
|
+
shared line and on a line of its own alike. */}
|
|
98
|
+
{actions !== undefined && <View style={{ flexShrink: 0, marginLeft: "auto" }}>{actions}</View>}
|
|
89
99
|
</View>
|
|
90
100
|
);
|
|
91
101
|
|
|
@@ -93,9 +103,45 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
93
103
|
<View style={{ paddingBottom: 16 }}>
|
|
94
104
|
{hasNav ? (
|
|
95
105
|
<>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
{/* The SAME three facts as the title row, for the same reason — this
|
|
107
|
+
row is a pair of intrinsically-sized children too, and a
|
|
108
|
+
breadcrumb trail is exactly the kind of thing that outgrows a
|
|
109
|
+
narrow frame. `minHeight` rather than `height`: a fixed height
|
|
110
|
+
cannot absorb a wrap, so the row that most needs to grow was the
|
|
111
|
+
one forbidden to. */}
|
|
112
|
+
<View
|
|
113
|
+
style={{
|
|
114
|
+
flexDirection: "row",
|
|
115
|
+
alignItems: "center",
|
|
116
|
+
flexWrap: "wrap",
|
|
117
|
+
gap: 12,
|
|
118
|
+
minHeight: 64,
|
|
119
|
+
paddingBottom: 16,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{/* `flexDirection: "row"` is not decoration: these wrappers RE-PARENT
|
|
123
|
+
what used to be a direct child of the row, and a react-native
|
|
124
|
+
`View` defaults to `column`. Both props are documented as taking
|
|
125
|
+
a nav row's worth of content — "breadcrumbs, a back control,
|
|
126
|
+
row-level status" — so a fragment of several nodes is the shape
|
|
127
|
+
they invite, and without the axis restated it lays out
|
|
128
|
+
top-to-bottom. A wrapper added to state a shrink rule must not
|
|
129
|
+
also silently state a direction. */}
|
|
130
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexShrink: 1, minWidth: 0 }}>
|
|
131
|
+
{left}
|
|
132
|
+
</View>
|
|
133
|
+
{right !== undefined && (
|
|
134
|
+
<View
|
|
135
|
+
style={{
|
|
136
|
+
flexDirection: "row",
|
|
137
|
+
alignItems: "center",
|
|
138
|
+
flexShrink: 0,
|
|
139
|
+
marginLeft: "auto",
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{right}
|
|
143
|
+
</View>
|
|
144
|
+
)}
|
|
99
145
|
</View>
|
|
100
146
|
{titleRow}
|
|
101
147
|
</>
|
|
@@ -103,7 +149,10 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
103
149
|
titleRow
|
|
104
150
|
)}
|
|
105
151
|
{!!description && (
|
|
106
|
-
|
|
152
|
+
<>
|
|
153
|
+
{!!title && <Spacer size={8} />}
|
|
154
|
+
<Text color="zinc-500">{description}</Text>
|
|
155
|
+
</>
|
|
107
156
|
)}
|
|
108
157
|
</View>
|
|
109
158
|
);
|
package/src/popover.tsx
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
} from "./popover_layers";
|
|
24
24
|
import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
|
|
25
25
|
import { useLoticsLocale } from "./locale";
|
|
26
|
+
import { OVERLAY_Z } from "./overlay_layer";
|
|
27
|
+
import { useScrollSeam } from "./use_scroll_seam";
|
|
26
28
|
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
27
29
|
|
|
28
30
|
export type PopoverSide = "top" | "right" | "bottom" | "left";
|
|
@@ -269,6 +271,22 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
269
271
|
// inside it must not dismiss this popover. See popover_layers.ts.
|
|
270
272
|
const modalsAtOpenRef = useRef<ReadonlySet<Element> | null>(null);
|
|
271
273
|
|
|
274
|
+
// THE SEAM EVERY SCROLLER THAT SWAPS ITS BODY IN PLACE TAKES.
|
|
275
|
+
//
|
|
276
|
+
// A routed popover is one: `PopoverScreen` renders `null` for every route but
|
|
277
|
+
// the active one, and the screens are children of THIS scroller — so
|
|
278
|
+
// navigating from a long root list to a sub-screen leaves the container at the
|
|
279
|
+
// list's offset and opens the sub-screen part-way down itself, its
|
|
280
|
+
// `PopoverNavHeader` and back chevron scrolled off the top. Same failure as a
|
|
281
|
+
// master-detail drawer's, in the kit's own navigation primitive.
|
|
282
|
+
//
|
|
283
|
+
// The route IS the content's identity, so it is read here rather than asked
|
|
284
|
+
// for: a popover with no sub-screens sits on the root route forever, its key
|
|
285
|
+
// never changes, and the seam is inert — which is exactly the opt-out
|
|
286
|
+
// `use_scroll_seam` defines.
|
|
287
|
+
const currentRoute = useContext(PopoverNavContext)?.currentRoute;
|
|
288
|
+
const bodySeam = useScrollSeam(currentRoute);
|
|
289
|
+
|
|
272
290
|
const handleClose = useCallback(() => {
|
|
273
291
|
if (!open) return;
|
|
274
292
|
onOpenChange(false);
|
|
@@ -603,9 +621,6 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
603
621
|
if (!open) return null;
|
|
604
622
|
|
|
605
623
|
const nestingLevel = getNestingLevel();
|
|
606
|
-
const baseZIndex = 9999;
|
|
607
|
-
const overlayZIndex = baseZIndex + nestingLevel * 2;
|
|
608
|
-
const contentZIndex = baseZIndex + nestingLevel * 2 + 1;
|
|
609
624
|
|
|
610
625
|
return (
|
|
611
626
|
// A popover is dialog-scale — a few hundred px with its own chrome — so a
|
|
@@ -630,7 +645,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
630
645
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
631
646
|
opacity: isBottomSheetShown ? 1 : 0,
|
|
632
647
|
transition: "opacity 0.3s ease",
|
|
633
|
-
zIndex:
|
|
648
|
+
zIndex: OVERLAY_Z,
|
|
634
649
|
pointerEvents: "auto",
|
|
635
650
|
}}
|
|
636
651
|
onClick={handleOverlayClick}
|
|
@@ -656,7 +671,15 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
656
671
|
backgroundColor: colors.background,
|
|
657
672
|
boxShadow: colors.shadow,
|
|
658
673
|
boxSizing: "border-box",
|
|
659
|
-
|
|
674
|
+
// The kit's ONE overlay rung (`overlay_layer.ts`), shared with every
|
|
675
|
+
// react-native `Modal` — DOM order settles the tie, and DOM order is
|
|
676
|
+
// open order for all of them. A nested popover portals into the same
|
|
677
|
+
// host AFTER its parent; a page-level one is inside the app root,
|
|
678
|
+
// which every modal's body-level div follows; and one opened INSIDE
|
|
679
|
+
// an overlay portals into that overlay's own host. The scrim and the
|
|
680
|
+
// panel share the rung too: siblings in one container, panel written
|
|
681
|
+
// second.
|
|
682
|
+
zIndex: OVERLAY_Z,
|
|
660
683
|
transition: small ? "transform 0.3s ease" : undefined,
|
|
661
684
|
...(small
|
|
662
685
|
? {
|
|
@@ -752,6 +775,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
752
775
|
// Horizontal only: the vertical padding is the gap to the header and
|
|
753
776
|
// footer, which is a gap the reader wants.
|
|
754
777
|
<ScrollView
|
|
778
|
+
{...bodySeam}
|
|
755
779
|
style={[SCROLL_BODY, style]}
|
|
756
780
|
contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
|
|
757
781
|
>
|
package/src/skip_link.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Platform } from "react-native";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
|
+
import { SKIP_LINK_Z } from "./overlay_layer";
|
|
3
4
|
|
|
4
5
|
export interface SkipLinkProps {
|
|
5
6
|
/** DOM id of the main region to jump to, e.g. `"main-content"`. */
|
|
@@ -29,7 +30,7 @@ export function SkipLink(props: SkipLinkProps) {
|
|
|
29
30
|
color: colors.white,
|
|
30
31
|
textDecoration: "none",
|
|
31
32
|
borderRadius: 4,
|
|
32
|
-
zIndex:
|
|
33
|
+
zIndex: SKIP_LINK_Z,
|
|
33
34
|
transform: "translateY(-200%)",
|
|
34
35
|
transition: "transform 0.15s ease",
|
|
35
36
|
}}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useMemo, type ReactNode } from "react";
|
|
2
2
|
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { LegendItem } from "./legend_item";
|
|
5
5
|
import { useLoticsLocale } from "./locale";
|
|
6
6
|
import { SPACE } from "./spacing";
|
|
7
7
|
import { Text } from "./text";
|
|
8
|
+
import { TYPE_LEADING_TIGHT_DESKTOP, TYPE_LEADING_TIGHT_MOBILE } from "./type_ramp";
|
|
8
9
|
import type { TextColor } from "./text_utils";
|
|
9
10
|
|
|
10
11
|
export interface StackedBarSeries {
|
|
@@ -17,6 +18,20 @@ export interface StackedBarRow {
|
|
|
17
18
|
key: string;
|
|
18
19
|
/** The entity this row is about — a campaign, a depot, a month. */
|
|
19
20
|
label: string;
|
|
21
|
+
/**
|
|
22
|
+
* That entity's own mark, before its name — a `BrandMark`, an `Avatar`, a
|
|
23
|
+
* status dot. A chart and a table over the SAME entities inside one card have
|
|
24
|
+
* to draw them the same way; without a slot the chart can only say the name in
|
|
25
|
+
* text, and one value ends up with two renderings a few hundred pixels apart.
|
|
26
|
+
*
|
|
27
|
+
* A slot rather than a widened `label`, following `Table`/`FileRow`: `label`
|
|
28
|
+
* is also this row's accessible name and the target of the two-line clamp, and
|
|
29
|
+
* a `ReactNode` there would lose both.
|
|
30
|
+
*
|
|
31
|
+
* The row's SERIES colours belong to the measure, so unlike a legend row this
|
|
32
|
+
* one carries no identity of its own until you give it one.
|
|
33
|
+
*/
|
|
34
|
+
leading?: ReactNode;
|
|
20
35
|
/** A neutral qualifier beside the label ("Đang chạy", "12 đơn"). */
|
|
21
36
|
meta?: string;
|
|
22
37
|
/** Per-series magnitudes, keyed by `StackedBarSeries.key`. Negatives are dropped. */
|
|
@@ -110,6 +125,9 @@ export function StackedBarChart(props: StackedBarChartProps) {
|
|
|
110
125
|
that also has to fit a status and a figure spends a narrow
|
|
111
126
|
container's width truncating exactly that. */}
|
|
112
127
|
<View style={styles.head}>
|
|
128
|
+
{row.leading !== undefined ? (
|
|
129
|
+
<View style={styles.leading}>{row.leading}</View>
|
|
130
|
+
) : null}
|
|
113
131
|
<View style={styles.headText}>
|
|
114
132
|
<Text size="sm" weight="medium" numberOfLines={2} leading="tight">
|
|
115
133
|
{row.label}
|
|
@@ -181,6 +199,18 @@ const styles = StyleSheet.create({
|
|
|
181
199
|
minWidth: 0,
|
|
182
200
|
gap: 1,
|
|
183
201
|
},
|
|
202
|
+
// The mark centres on the LABEL'S OWN LINE BOX, not on the head — a row that
|
|
203
|
+
// also carries `meta` is two lines tall, and centring on the head would drift
|
|
204
|
+
// the mark down between them for that row only. Giving the slot the line box's
|
|
205
|
+
// height and centring inside it lands any mark on the first line whatever the
|
|
206
|
+
// mark's size, so a dot and a 24px avatar both sit right without either being
|
|
207
|
+
// measured. `sm` is the same box at both breakpoints; the max is what keeps
|
|
208
|
+
// that from being an assumption.
|
|
209
|
+
leading: {
|
|
210
|
+
height: Math.max(TYPE_LEADING_TIGHT_MOBILE.sm, TYPE_LEADING_TIGHT_DESKTOP.sm),
|
|
211
|
+
justifyContent: "center",
|
|
212
|
+
flexShrink: 0,
|
|
213
|
+
},
|
|
184
214
|
// The track is the SHARED ruler; the fill is this row's share of it, and the
|
|
185
215
|
// segments split the fill. Three boxes, because collapsing the middle one is
|
|
186
216
|
// exactly how a stacked bar loses its scale.
|