@lotics/ui 46.2.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 +11 -0
- package/docs/catalog.md +99 -15
- 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/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/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/bar_chart.tsx
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import { View, StyleSheet, type ViewStyle } from "react-native";
|
|
2
|
-
import { useMemo } from "react";
|
|
1
|
+
import { View, StyleSheet, type LayoutChangeEvent, type ViewStyle } from "react-native";
|
|
2
|
+
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { axisLabelIndices } from "./axis_label_indices";
|
|
5
6
|
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_BAR_COLOR = colors.blue[500];
|
|
8
9
|
|
|
10
|
+
/** The horizontal orientation's label column, in px, when the caller names none. */
|
|
11
|
+
const DEFAULT_LABEL_WIDTH = 80;
|
|
12
|
+
|
|
13
|
+
/** The value axis's own column — the gutter the vertical bars start after. */
|
|
14
|
+
const AXIS_LABEL_WIDTH = 56;
|
|
15
|
+
|
|
16
|
+
/** The gap between bar slots, and therefore between label slots. Named because
|
|
17
|
+
* the label box's width is computed from the slot PITCH (slot + gap), and a
|
|
18
|
+
* second literal there would silently stop matching the row it measures. */
|
|
19
|
+
const LABEL_GAP = 8;
|
|
20
|
+
|
|
9
21
|
const defaultFormatNumber = (n: number): string =>
|
|
10
22
|
new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
|
|
11
23
|
|
|
@@ -13,6 +25,17 @@ export interface BarChartItem {
|
|
|
13
25
|
label: string;
|
|
14
26
|
value: number;
|
|
15
27
|
color?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The entity's own mark, before its name — a `BrandMark`, an `Avatar`, a
|
|
30
|
+
* status dot. A bar is one entity, and a chart beside a table over the same
|
|
31
|
+
* entities has to draw them the same way; the bar's COLOUR belongs to the
|
|
32
|
+
* measure, so it cannot carry identity as a legend swatch does.
|
|
33
|
+
*
|
|
34
|
+
* Horizontal: in the label column, ahead of the name. Vertical: above it,
|
|
35
|
+
* where the axis label sits — and thinned out with that label when the axis
|
|
36
|
+
* cannot fit them all.
|
|
37
|
+
*/
|
|
38
|
+
leading?: ReactNode;
|
|
16
39
|
}
|
|
17
40
|
|
|
18
41
|
export interface BarChartProps {
|
|
@@ -20,6 +43,13 @@ export interface BarChartProps {
|
|
|
20
43
|
orientation?: "vertical" | "horizontal";
|
|
21
44
|
formatNumber?: (n: number) => string;
|
|
22
45
|
emptyLabel?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The horizontal orientation's fixed label column, in px (default 80). It is
|
|
48
|
+
* fixed because every row's bar has to start on one left edge; it is a prop
|
|
49
|
+
* because 80 fits a date and not a company name, and how long an entity's
|
|
50
|
+
* name runs is the caller's fact, not the chart's.
|
|
51
|
+
*/
|
|
52
|
+
labelWidth?: number;
|
|
23
53
|
}
|
|
24
54
|
|
|
25
55
|
function useAxisTicks(maxValue: number) {
|
|
@@ -62,9 +92,13 @@ function useAxisTicks(maxValue: number) {
|
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
/**
|
|
65
|
-
* The canonical SVG bar chart over `data: { label, value, color? }[]` —
|
|
66
|
-
* vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels
|
|
67
|
-
*
|
|
95
|
+
* The canonical SVG bar chart over `data: { label, value, color?, leading? }[]` —
|
|
96
|
+
* `orientation` vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels,
|
|
97
|
+
* `labelWidth` for the horizontal label column. (No recharts.) For one inline trend use
|
|
98
|
+
* `Sparkline`; for parts-of-a-whole, `PieChart` / `Breakdown`.
|
|
99
|
+
*
|
|
100
|
+
* The vertical axis prints as many labels as it fits and thins the rest, so the number of
|
|
101
|
+
* bars is never decided by how wide a label happens to be.
|
|
68
102
|
*/
|
|
69
103
|
export function BarChart(props: BarChartProps) {
|
|
70
104
|
const locale = useLoticsLocale();
|
|
@@ -73,12 +107,73 @@ export function BarChart(props: BarChartProps) {
|
|
|
73
107
|
orientation = "vertical",
|
|
74
108
|
formatNumber = defaultFormatNumber,
|
|
75
109
|
emptyLabel = locale.chart.noData,
|
|
110
|
+
labelWidth = DEFAULT_LABEL_WIDTH,
|
|
76
111
|
} = props;
|
|
77
112
|
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
|
78
113
|
const axisTicks = useAxisTicks(maxValue);
|
|
79
114
|
const axisMax = axisTicks[axisTicks.length - 1] || maxValue;
|
|
80
115
|
const barAreaHeight = 200;
|
|
81
116
|
|
|
117
|
+
// The vertical axis prints as many labels as the row fits and thins the rest —
|
|
118
|
+
// the same rule `LineChart` runs, because it is the axis's rule and not one
|
|
119
|
+
// chart's. Without it the label box IS the bar slot: 13 bars in a 1000px card
|
|
120
|
+
// give each label 38px, so a `dd/MM` ellipsises and the axis stops being
|
|
121
|
+
// readable at exactly the density that makes a bar chart worth drawing. The
|
|
122
|
+
// BARS never thin — dropping data to fit typography is the wrong trade.
|
|
123
|
+
const [labelsWidth, setLabelsWidth] = useState(0);
|
|
124
|
+
const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
|
|
125
|
+
setLabelsWidth(event.nativeEvent.layout.width);
|
|
126
|
+
}, []);
|
|
127
|
+
// ONE derivation from the ONE measurement — which labels survive AND how wide
|
|
128
|
+
// the box each survivor gets. They are two answers about the same row, and a
|
|
129
|
+
// second pass over the same numbers is exactly how they came to disagree about
|
|
130
|
+
// it.
|
|
131
|
+
//
|
|
132
|
+
// THE SPACE THE THINNING FREED, HANDED TO THE LABEL THAT SURVIVED.
|
|
133
|
+
//
|
|
134
|
+
// Dropping a neighbour is only half the fix: the survivor still sits in its
|
|
135
|
+
// own bar slot, and a one-line `Text` is capped at that slot's width and
|
|
136
|
+
// ellipsises inside it — so the axis kept fewer labels and clipped them just
|
|
137
|
+
// the same, with a blank slot beside each one. The label box therefore spans
|
|
138
|
+
// the STEP: `step` slots and the gaps between them, centred on its bar, which
|
|
139
|
+
// is exactly the room the dropped labels vacated. The bar slots themselves
|
|
140
|
+
// never move, so an unlabelled bar stays a blank slot rather than a narrower
|
|
141
|
+
// one — the box overflows the slot instead of resizing it.
|
|
142
|
+
const axisLabels = useMemo(() => {
|
|
143
|
+
// Unmeasured (first paint, and any host with no layout pass) shows every
|
|
144
|
+
// label, which is where this started — thinning is what measurement BUYS,
|
|
145
|
+
// never a precondition for rendering an axis at all.
|
|
146
|
+
if (labelsWidth <= 0 || data.length === 0) return null;
|
|
147
|
+
// `labelsRow` holds the axis spacer AND one slot per bar, so its N slots
|
|
148
|
+
// carry N gaps rather than N-1: `track` is already `sum(slots) + N·gap`, and
|
|
149
|
+
// the slot PITCH is therefore `track / N` — the gaps net out exactly. The
|
|
150
|
+
// survivor's box spans `step` slots and the `step - 1` gaps between them,
|
|
151
|
+
// which is `step · pitch - gap`.
|
|
152
|
+
const track = labelsWidth - AXIS_LABEL_WIDTH;
|
|
153
|
+
const pitch = track / data.length;
|
|
154
|
+
const keptIndices = axisLabelIndices(data.length, track);
|
|
155
|
+
// The span comes from the KEPT SET, never from `axisLabelStep`. The two are
|
|
156
|
+
// allowed to disagree — `axisLabelIndices` re-adds index 0 when there is
|
|
157
|
+
// room for it, so a step of `count` can still yield two survivors — and
|
|
158
|
+
// sizing off the step then hands EACH of them a box spanning the whole
|
|
159
|
+
// track, so the two labels overlap. Reading one source of truth cannot
|
|
160
|
+
// drift, whatever the thinning rule becomes later.
|
|
161
|
+
const gaps = keptIndices.slice(1).map((v, i) => v - keptIndices[i]);
|
|
162
|
+
const span = gaps.length > 0 ? Math.min(...gaps) : data.length;
|
|
163
|
+
const boxWidth = span > 1 ? span * pitch - LABEL_GAP : 0;
|
|
164
|
+
return {
|
|
165
|
+
kept: new Set(keptIndices),
|
|
166
|
+
// Undefined when nothing was dropped (`step === 1`): the slot IS the box,
|
|
167
|
+
// and an explicit width there would only re-state it. Undefined too when
|
|
168
|
+
// the span is not a positive number of pixels — a card narrower than the
|
|
169
|
+
// value axis's own gutter leaves a track of zero or less, and a `width: 0`
|
|
170
|
+
// box would ERASE the label the thinning just chose to keep. Letting it
|
|
171
|
+
// overflow its slot is the same trade the row makes everywhere else: the
|
|
172
|
+
// axis thins labels, it never deletes them.
|
|
173
|
+
boxWidth: boxWidth > 0 ? boxWidth : undefined,
|
|
174
|
+
};
|
|
175
|
+
}, [data.length, labelsWidth]);
|
|
176
|
+
|
|
82
177
|
if (data.length === 0) {
|
|
83
178
|
return (
|
|
84
179
|
<View style={styles.chartContainer}>
|
|
@@ -97,7 +192,8 @@ export function BarChart(props: BarChartProps) {
|
|
|
97
192
|
|
|
98
193
|
return (
|
|
99
194
|
<View key={index} style={styles.horizontalBarContainer}>
|
|
100
|
-
<View style={styles.horizontalBarLabel}>
|
|
195
|
+
<View style={[styles.horizontalBarLabel, { width: labelWidth }]}>
|
|
196
|
+
{item.leading !== undefined ? item.leading : null}
|
|
101
197
|
<Text size="sm" numberOfLines={1}>
|
|
102
198
|
{item.label}
|
|
103
199
|
</Text>
|
|
@@ -122,7 +218,7 @@ export function BarChart(props: BarChartProps) {
|
|
|
122
218
|
);
|
|
123
219
|
})}
|
|
124
220
|
<View style={styles.horizontalAxisContainer}>
|
|
125
|
-
<View style={
|
|
221
|
+
<View style={{ width: labelWidth }} />
|
|
126
222
|
<View style={styles.horizontalAxisTrack}>
|
|
127
223
|
{axisTicks.map((tick, index) => (
|
|
128
224
|
<View
|
|
@@ -186,14 +282,27 @@ export function BarChart(props: BarChartProps) {
|
|
|
186
282
|
})}
|
|
187
283
|
</View>
|
|
188
284
|
</View>
|
|
189
|
-
<View style={styles.labelsRow}>
|
|
285
|
+
<View style={styles.labelsRow} onLayout={handleLabelsLayout}>
|
|
190
286
|
<View style={styles.axisLabelSpacer} />
|
|
191
287
|
{data.map((item, index) => (
|
|
288
|
+
// The slot stays whatever the axis prints, so the bars above it keep
|
|
289
|
+
// their positions — an unlabelled bar is a blank slot, never a
|
|
290
|
+
// narrower one.
|
|
192
291
|
<View key={index} style={styles.labelContainer}>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
292
|
+
{axisLabels === null || axisLabels.kept.has(index) ? (
|
|
293
|
+
<View
|
|
294
|
+
style={[
|
|
295
|
+
styles.labelBox,
|
|
296
|
+
axisLabels?.boxWidth !== undefined && { width: axisLabels.boxWidth },
|
|
297
|
+
]}
|
|
298
|
+
>
|
|
299
|
+
{item.leading !== undefined ? item.leading : null}
|
|
300
|
+
<Text numberOfLines={1} weight="medium">
|
|
301
|
+
{item.label}
|
|
302
|
+
</Text>
|
|
303
|
+
<Text color="muted">{formatNumber(item.value)}</Text>
|
|
304
|
+
</View>
|
|
305
|
+
) : null}
|
|
197
306
|
</View>
|
|
198
307
|
))}
|
|
199
308
|
</View>
|
|
@@ -215,7 +324,7 @@ const styles = StyleSheet.create({
|
|
|
215
324
|
gap: 8,
|
|
216
325
|
},
|
|
217
326
|
verticalAxis: {
|
|
218
|
-
width:
|
|
327
|
+
width: AXIS_LABEL_WIDTH,
|
|
219
328
|
position: "relative",
|
|
220
329
|
},
|
|
221
330
|
verticalAxisTick: {
|
|
@@ -243,15 +352,23 @@ const styles = StyleSheet.create({
|
|
|
243
352
|
},
|
|
244
353
|
labelsRow: {
|
|
245
354
|
flexDirection: "row",
|
|
246
|
-
gap:
|
|
355
|
+
gap: LABEL_GAP,
|
|
247
356
|
},
|
|
248
357
|
labelContainer: {
|
|
249
358
|
flex: 1,
|
|
250
359
|
alignItems: "center",
|
|
251
360
|
gap: 2,
|
|
252
361
|
},
|
|
362
|
+
// Wider than its slot when the axis thinned, and centred on the bar by the
|
|
363
|
+
// slot's own `alignItems` — a flex item wider than its cross-axis space
|
|
364
|
+
// overflows both edges equally, which is what puts the label over the blank
|
|
365
|
+
// slots beside it rather than clipping it inside its own.
|
|
366
|
+
labelBox: {
|
|
367
|
+
alignItems: "center",
|
|
368
|
+
gap: 2,
|
|
369
|
+
},
|
|
253
370
|
axisLabelSpacer: {
|
|
254
|
-
width:
|
|
371
|
+
width: AXIS_LABEL_WIDTH,
|
|
255
372
|
},
|
|
256
373
|
horizontalChart: {
|
|
257
374
|
gap: 8,
|
|
@@ -264,8 +381,12 @@ const styles = StyleSheet.create({
|
|
|
264
381
|
alignItems: "center",
|
|
265
382
|
gap: 8,
|
|
266
383
|
},
|
|
384
|
+
// The width is the instance's `labelWidth`; what lives here is the row shape —
|
|
385
|
+
// the entity's mark, then its name, on one line.
|
|
267
386
|
horizontalBarLabel: {
|
|
268
|
-
|
|
387
|
+
flexDirection: "row",
|
|
388
|
+
alignItems: "center",
|
|
389
|
+
gap: 6,
|
|
269
390
|
},
|
|
270
391
|
horizontalBarTrack: {
|
|
271
392
|
flex: 1,
|
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/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>
|