@lotics/ui 45.9.0 → 45.10.1
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 +70 -137
- package/docs/ai_patterns.md +88 -150
- package/docs/catalog.md +243 -285
- package/docs/composition.md +455 -465
- package/docs/data_entry.md +109 -155
- package/docs/reviewing.md +47 -55
- package/docs/templates.md +480 -371
- package/docs/testing.md +3 -7
- package/package.json +3 -1
- package/src/bar_chart.tsx +20 -2
- package/src/chip_group.tsx +5 -3
- package/src/finding.tsx +18 -3
- package/src/line_chart.tsx +5 -14
- package/src/line_chart_labels.ts +32 -0
- package/src/page_header.tsx +13 -1
- package/src/pressable_row.tsx +7 -5
- package/src/stacked_bar_chart.tsx +197 -0
- package/src/table.tsx +15 -14
- package/src/waterfall_chart.tsx +398 -0
package/docs/testing.md
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
Three kit anatomies where the accessibility tree says one thing and an automated
|
|
4
4
|
driver has to do another. Each is **by design** — the shape that makes the
|
|
5
5
|
component correct for a keyboard and a screen reader is the shape that defeats a
|
|
6
|
-
naive `click()` — so each will read as a bug the first time
|
|
7
|
-
reaction (`force: true`, or "the component is broken") costs a debugging session.
|
|
6
|
+
naive `click()` — so each will read as a bug the first time.
|
|
8
7
|
|
|
9
8
|
Everything here is true wherever the kit renders. The iframe that a Lotics app
|
|
10
9
|
runs inside adds one more rule on top — `lotics docs building_an_app` § 8.
|
|
@@ -25,9 +24,7 @@ So the a11y tree shows `button "Open ACME-1042"`, and clicking it fails:
|
|
|
25
24
|
subtree intercepts pointer events
|
|
26
25
|
```
|
|
27
26
|
|
|
28
|
-
That is the design working, not a defect
|
|
29
|
-
precisely so a pointer lands on the row rather than the door. Drive it the way a
|
|
30
|
-
mouse user does:
|
|
27
|
+
That is the design working, not a defect. Drive it the way a mouse user does:
|
|
31
28
|
|
|
32
29
|
- **click the row container** — the `generic [cursor=pointer]` wrapping the door;
|
|
33
30
|
the click bubbles to `PressableRow`
|
|
@@ -66,5 +63,4 @@ and the window are the right ones. From the dragged element,
|
|
|
66
63
|
|
|
67
64
|
Then check **both** halves: re-snapshot for the optimistic move, and re-read the
|
|
68
65
|
record to confirm the mutation actually persisted. An optimistic move that never
|
|
69
|
-
reached the server looks identical on screen
|
|
70
|
-
the second one.
|
|
66
|
+
reached the server looks identical on screen.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "45.
|
|
3
|
+
"version": "45.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -83,6 +83,8 @@
|
|
|
83
83
|
"./gantt": "./src/gantt/index.ts",
|
|
84
84
|
"./progress_ring": "./src/progress_ring.tsx",
|
|
85
85
|
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
|
86
|
+
"./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
|
|
87
|
+
"./waterfall_chart": "./src/waterfall_chart.tsx",
|
|
86
88
|
"./legend_item": "./src/legend_item.tsx",
|
|
87
89
|
"./breakdown": "./src/breakdown.tsx",
|
|
88
90
|
"./funnel": "./src/funnel.tsx",
|
package/src/bar_chart.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { View, StyleSheet } from "react-native";
|
|
1
|
+
import { View, StyleSheet, type ViewStyle } from "react-native";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
@@ -293,7 +293,25 @@ const styles = StyleSheet.create({
|
|
|
293
293
|
horizontalAxisTick: {
|
|
294
294
|
position: "absolute",
|
|
295
295
|
alignItems: "center",
|
|
296
|
-
|
|
296
|
+
/**
|
|
297
|
+
* `max-content` + a half-width shift, so the label sizes to its own text and
|
|
298
|
+
* then centres on its tick at ANY label length.
|
|
299
|
+
*
|
|
300
|
+
* Both halves are load-bearing. An absolutely-positioned box with only
|
|
301
|
+
* `left` set is sized by the space remaining to the container's RIGHT EDGE,
|
|
302
|
+
* so the tick at 100% shrink-wraps to a few pixels and breaks its text
|
|
303
|
+
* across two lines — visible only at the widths where that label happens
|
|
304
|
+
* not to fit, which is why it survives a desktop-only pass. And a fixed
|
|
305
|
+
* pixel nudge cannot centre anything: it assumes one label width, so every
|
|
306
|
+
* tick is off by however far its own label differs from the guess.
|
|
307
|
+
*/
|
|
308
|
+
transform: "translateX(-50%)",
|
|
309
|
+
// The one style here RN cannot type: `DimensionValue` covers numbers and
|
|
310
|
+
// percentages, and `max-content` is a real CSS width that react-native-web
|
|
311
|
+
// passes straight through. Nothing typed does this job — a number floor
|
|
312
|
+
// only moves the breaking point to the next longer label, and dropping to
|
|
313
|
+
// the track's own width is what breaks it in the first place.
|
|
314
|
+
...({ width: "max-content" } as unknown as ViewStyle),
|
|
297
315
|
},
|
|
298
316
|
tickMark: {
|
|
299
317
|
width: 1,
|
package/src/chip_group.tsx
CHANGED
|
@@ -4,9 +4,11 @@ import { PressableHighlight } from "./pressable_highlight";
|
|
|
4
4
|
import { chipSurfaceStyle } from "./control_surface";
|
|
5
5
|
|
|
6
6
|
// One-of-N chips: every option visible, one tap to switch, the row wraps on
|
|
7
|
-
// narrow widths. Quiet zinc styling — bordered white at rest,
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// narrow widths. Quiet zinc styling — bordered white at rest, and the active one
|
|
8
|
+
// keeps that same ground and takes a doubled zinc-900 EDGE instead, so a
|
|
9
|
+
// selection never spends the brand and never speaks the row's language (the
|
|
10
|
+
// reasoning is on `chipSurfaceStyle`). Colour stays reserved for status +
|
|
11
|
+
// primary actions. Two jobs, one control:
|
|
10
12
|
// - a SMALL, HOT FILTER the user flips between constantly ("narrow this
|
|
11
13
|
// view") — model the unfiltered state as an explicit option (e.g. "All").
|
|
12
14
|
// - a small REQUIRED single-select in a form / composer (a call outcome, a
|
package/src/finding.tsx
CHANGED
|
@@ -142,7 +142,7 @@ export function Finding(props: FindingProps) {
|
|
|
142
142
|
{readings.map((reading, index) => (
|
|
143
143
|
<View key={`${reading.source}-${index}`} style={styles.reading}>
|
|
144
144
|
<SourceName label={reading.source} onOpen={props.onOpenSource} />
|
|
145
|
-
<Text size="sm" weight="medium" tabular numberOfLines={1}>
|
|
145
|
+
<Text size="sm" weight="medium" tabular numberOfLines={1} style={styles.readingValue}>
|
|
146
146
|
{reading.value}
|
|
147
147
|
</Text>
|
|
148
148
|
</View>
|
|
@@ -181,13 +181,18 @@ export function Finding(props: FindingProps) {
|
|
|
181
181
|
function SourceName({ label, onOpen }: { label: string; onOpen?: (source: string) => void }) {
|
|
182
182
|
if (!onOpen) {
|
|
183
183
|
return (
|
|
184
|
-
<Text size="sm" color="muted" numberOfLines={1}>
|
|
184
|
+
<Text size="sm" color="muted" numberOfLines={1} style={styles.sourceName}>
|
|
185
185
|
{label}
|
|
186
186
|
</Text>
|
|
187
187
|
);
|
|
188
188
|
}
|
|
189
189
|
return (
|
|
190
|
-
<PressableHighlight
|
|
190
|
+
<PressableHighlight
|
|
191
|
+
onPress={() => onOpen(label)}
|
|
192
|
+
accessibilityRole="link"
|
|
193
|
+
accessibilityLabel={label}
|
|
194
|
+
style={styles.sourceName}
|
|
195
|
+
>
|
|
191
196
|
<Text size="sm" weight="medium" decoration="underline" numberOfLines={1}>
|
|
192
197
|
{label}
|
|
193
198
|
</Text>
|
|
@@ -217,4 +222,14 @@ const styles = StyleSheet.create({
|
|
|
217
222
|
// each source with its own value at every width and needs no separator at all.
|
|
218
223
|
readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
|
|
219
224
|
reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
|
|
225
|
+
// The same split as `title`/`delta` one row up, for the same reason: the SOURCE
|
|
226
|
+
// is a label and gives way, the VALUE is the figure and never does. Both halves
|
|
227
|
+
// are required on the label — a React Native `Text` is `flexShrink: 0` unlike
|
|
228
|
+
// the web, and `flexShrink` alone then floors it at its longest word. Without
|
|
229
|
+
// this pair the row laid both children out at intrinsic width and simply ran
|
|
230
|
+
// off the card: measured at x=790 in a 390px frame, clipped, with no page
|
|
231
|
+
// scroll to reach it — so on a phone the evidence a Finding exists to show was
|
|
232
|
+
// the part you could not read.
|
|
233
|
+
sourceName: { flexShrink: 1, minWidth: 0 },
|
|
234
|
+
readingValue: { flexShrink: 0 },
|
|
220
235
|
});
|
package/src/line_chart.tsx
CHANGED
|
@@ -4,6 +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 { lineChartLabelIndices } from "./line_chart_labels";
|
|
7
8
|
|
|
8
9
|
export interface LineChartPoint {
|
|
9
10
|
x: string | number;
|
|
@@ -96,20 +97,10 @@ export function LineChart(props: LineChartProps) {
|
|
|
96
97
|
const visibleLabels = useMemo(() => {
|
|
97
98
|
if (data.length === 0 || chartWidth === 0) return [];
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const labels: { index: number; label: string }[] = [];
|
|
104
|
-
for (let i = 0; i < data.length; i++) {
|
|
105
|
-
const isFirst = i === 0;
|
|
106
|
-
const isLast = i === data.length - 1;
|
|
107
|
-
const isStep = i % step === 0;
|
|
108
|
-
if (isFirst || isLast || isStep) {
|
|
109
|
-
labels.push({ index: i, label: formatXLabel(data[i].x) });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return labels;
|
|
100
|
+
return lineChartLabelIndices(data.length, chartWidth).map((index) => ({
|
|
101
|
+
index,
|
|
102
|
+
label: formatXLabel(data[index].x),
|
|
103
|
+
}));
|
|
113
104
|
}, [data, chartWidth, formatXLabel]);
|
|
114
105
|
|
|
115
106
|
if (data.length === 0) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a line chart's x positions get a printed label.
|
|
3
|
+
*
|
|
4
|
+
* A label is roughly `minLabelWidth` wide, so only so many fit across the
|
|
5
|
+
* track; the rest are thinned out. The rule that matters is WHERE the thinning
|
|
6
|
+
* is anchored: stepping forwards from the first point and then forcing the last
|
|
7
|
+
* one in collides whenever the series length minus one is not a multiple of the
|
|
8
|
+
* step — the newest label lands a few pixels from the one before it while every
|
|
9
|
+
* other pair is a full step apart. Anchoring on the LAST point makes the spacing
|
|
10
|
+
* uniform by construction, and the last point is the one a reader looks up
|
|
11
|
+
* first. The first point is then kept only when it clears the same distance.
|
|
12
|
+
*/
|
|
13
|
+
export function lineChartLabelIndices(
|
|
14
|
+
count: number,
|
|
15
|
+
chartWidth: number,
|
|
16
|
+
minLabelWidth = 50,
|
|
17
|
+
): number[] {
|
|
18
|
+
if (count <= 0 || chartWidth <= 0) return [];
|
|
19
|
+
if (count === 1) return [0];
|
|
20
|
+
|
|
21
|
+
const last = count - 1;
|
|
22
|
+
const maxLabels = Math.max(2, Math.floor(chartWidth / minLabelWidth));
|
|
23
|
+
const step = Math.max(1, Math.ceil(count / maxLabels));
|
|
24
|
+
|
|
25
|
+
const kept: number[] = [];
|
|
26
|
+
for (let i = last; i >= 0; i -= step) kept.unshift(i);
|
|
27
|
+
|
|
28
|
+
const firstKept = kept[0] ?? 0;
|
|
29
|
+
if (firstKept > 0 && (firstKept / last) * chartWidth >= minLabelWidth) kept.unshift(0);
|
|
30
|
+
|
|
31
|
+
return kept;
|
|
32
|
+
}
|
package/src/page_header.tsx
CHANGED
|
@@ -48,12 +48,20 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
48
48
|
// then floors the item at its MIN-CONTENT width — the longest word — so
|
|
49
49
|
// `minWidth: 0` is what lets it actually wrap. `DetailRow`'s spread value is
|
|
50
50
|
// the same pair for the same reason.
|
|
51
|
+
// `flexWrap` is the floor under the shrink rule below, not a competing one. A
|
|
52
|
+
// title shrinks only as far as its longest WORD; past that, min-content title
|
|
53
|
+
// plus a full-width CTA can still exceed the row, and a row that cannot wrap
|
|
54
|
+
// has nowhere to put the excess but off the edge — measured at 393px of
|
|
55
|
+
// content in a 360px frame, clipped, scrolling sideways, which `composition.md`
|
|
56
|
+
// bans outright. Wrapping drops the actions to their own line and keeps the
|
|
57
|
+
// stated bargain intact: the CTA stays reachable, the header is merely taller.
|
|
51
58
|
const titleRow = (
|
|
52
59
|
<View
|
|
53
60
|
style={{
|
|
54
61
|
flexDirection: "row",
|
|
55
62
|
alignItems: "center",
|
|
56
63
|
justifyContent: "space-between",
|
|
64
|
+
flexWrap: "wrap",
|
|
57
65
|
gap: 12,
|
|
58
66
|
}}
|
|
59
67
|
>
|
|
@@ -73,7 +81,11 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
73
81
|
below its own icon is not a smaller control, it is a broken one. */}
|
|
74
82
|
{trailing !== undefined && <View style={{ flexShrink: 0 }}>{trailing}</View>}
|
|
75
83
|
</View>
|
|
76
|
-
{
|
|
84
|
+
{/* Wrapped so which side gives way is STATED rather than left to whatever
|
|
85
|
+
the caller happened to pass. A bare `{actions}` inherited its own
|
|
86
|
+
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
|
+
{actions !== undefined && <View style={{ flexShrink: 0 }}>{actions}</View>}
|
|
77
89
|
</View>
|
|
78
90
|
);
|
|
79
91
|
|
package/src/pressable_row.tsx
CHANGED
|
@@ -14,10 +14,11 @@ export interface PressableRowProps {
|
|
|
14
14
|
marked?: boolean;
|
|
15
15
|
/**
|
|
16
16
|
* - "register" (THE record-list default): a rounded row whose hover/open/`marked`
|
|
17
|
-
* wash spans the FULL row width (incl. nested controls);
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* wash spans the FULL row width (incl. nested controls); the wash BLEEDS outward
|
|
18
|
+
* and the content stays on the container's own edge, so a `Table` header and its
|
|
19
|
+
* cells still align. The `Table` draws NOTHING between rows — it spaces them by
|
|
20
|
+
* 4px and the wash is the only mark a row makes — every app inherits this
|
|
21
|
+
* register look.
|
|
21
22
|
* - "bleed" (legacy): px-20, square wash to the edges, `Divider`-separated.
|
|
22
23
|
* The pre-rounded register row — kept for an edge-to-edge data grid that
|
|
23
24
|
* genuinely wants hard rules, not the floating default.
|
|
@@ -143,7 +144,8 @@ const styles = StyleSheet.create({
|
|
|
143
144
|
paddingHorizontal: ROW_WASH_BLEED,
|
|
144
145
|
marginHorizontal: -ROW_WASH_BLEED,
|
|
145
146
|
},
|
|
146
|
-
// Square, full-bleed —
|
|
147
|
+
// Square, full-bleed — the pre-rounded register row, for a grid that draws its own
|
|
148
|
+
// rules between rows. The register variant draws none. Legacy.
|
|
147
149
|
bleed: {
|
|
148
150
|
paddingHorizontal: ROW_WASH_BLEED,
|
|
149
151
|
marginHorizontal: -ROW_WASH_BLEED,
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { LegendItem } from "./legend_item";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
|
+
import { SPACE } from "./spacing";
|
|
7
|
+
import { Text } from "./text";
|
|
8
|
+
import type { TextColor } from "./text_utils";
|
|
9
|
+
|
|
10
|
+
export interface StackedBarSeries {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
color: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StackedBarRow {
|
|
17
|
+
key: string;
|
|
18
|
+
/** The entity this row is about — a campaign, a depot, a month. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** A neutral qualifier beside the label ("Đang chạy", "12 đơn"). */
|
|
21
|
+
meta?: string;
|
|
22
|
+
/** Per-series magnitudes, keyed by `StackedBarSeries.key`. Negatives are dropped. */
|
|
23
|
+
values: Record<string, number>;
|
|
24
|
+
/** The row's headline figure, right of the label. Already formatted by the caller. */
|
|
25
|
+
value?: string;
|
|
26
|
+
valueTone?: TextColor;
|
|
27
|
+
/** One full sentence under the bar, saying what the segments amount to. */
|
|
28
|
+
caption?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StackedBarChartProps {
|
|
32
|
+
series: StackedBarSeries[];
|
|
33
|
+
rows: StackedBarRow[];
|
|
34
|
+
/**
|
|
35
|
+
* The shared scale every row is measured on. Defaults to the largest row
|
|
36
|
+
* total, which is what makes this a chart: leave it alone unless a second
|
|
37
|
+
* chart beside this one must be read against the same ruler.
|
|
38
|
+
*/
|
|
39
|
+
max?: number;
|
|
40
|
+
/** Bar thickness in px. Default 12. */
|
|
41
|
+
height?: number;
|
|
42
|
+
/** Render the series legend above the rows. Default true. */
|
|
43
|
+
legend?: boolean;
|
|
44
|
+
emptyLabel?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Rows of stacked bars on ONE SHARED SCALE — spend against return per campaign,
|
|
49
|
+
* cost make-up per product line, hours per crew. Each row's bar is as long as
|
|
50
|
+
* that row's total is against the largest row, and the segments inside it split
|
|
51
|
+
* that length; so bar LENGTH answers "how big is this one" and the segment run
|
|
52
|
+
* answers "what is it made of", in one pass.
|
|
53
|
+
*
|
|
54
|
+
* That shared scale is the whole difference from `StackedProgressBar`, and the
|
|
55
|
+
* reason this is a separate component rather than a prop on it. That one draws
|
|
56
|
+
* ONE whole split across segments and fills its track whatever the total — put
|
|
57
|
+
* it on ten rows and ten unequal totals draw ten equal bars, which reads as a
|
|
58
|
+
* chart and states nothing. `Breakdown` is the other neighbour: one whole, one
|
|
59
|
+
* bar, ranked share rows beneath it. Reach here the moment there are SEVERAL
|
|
60
|
+
* wholes to compare.
|
|
61
|
+
*
|
|
62
|
+
* Segments carry meaning by colour, so the legend is on by default and the
|
|
63
|
+
* colours are the caller's — one `ColorName` family per dimension, per the
|
|
64
|
+
* kit's colour discipline. A row's `caption` is the second channel: colour
|
|
65
|
+
* alone never has to carry the point.
|
|
66
|
+
*/
|
|
67
|
+
export function StackedBarChart(props: StackedBarChartProps) {
|
|
68
|
+
const locale = useLoticsLocale();
|
|
69
|
+
const {
|
|
70
|
+
series,
|
|
71
|
+
rows,
|
|
72
|
+
max: maxProp,
|
|
73
|
+
height = 12,
|
|
74
|
+
legend = true,
|
|
75
|
+
emptyLabel = locale.chart.noData,
|
|
76
|
+
} = props;
|
|
77
|
+
|
|
78
|
+
const totals = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
rows.map((r) => series.reduce((t, s) => t + Math.max(0, r.values[s.key] ?? 0), 0)),
|
|
81
|
+
[rows, series],
|
|
82
|
+
);
|
|
83
|
+
const max = maxProp ?? Math.max(...totals, 0);
|
|
84
|
+
|
|
85
|
+
if (rows.length === 0) {
|
|
86
|
+
return (
|
|
87
|
+
<View style={styles.empty}>
|
|
88
|
+
<Text color="muted">{emptyLabel}</Text>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<View style={styles.container}>
|
|
95
|
+
{legend ? (
|
|
96
|
+
<View style={styles.legend}>
|
|
97
|
+
{series.map((s) => (
|
|
98
|
+
<LegendItem key={s.key} color={s.color} label={s.label} />
|
|
99
|
+
))}
|
|
100
|
+
</View>
|
|
101
|
+
) : null}
|
|
102
|
+
<View style={styles.rows}>
|
|
103
|
+
{rows.map((row, i) => {
|
|
104
|
+
const total = totals[i];
|
|
105
|
+
const share = max > 0 ? total / max : 0;
|
|
106
|
+
return (
|
|
107
|
+
<View key={row.key} style={styles.row}>
|
|
108
|
+
{/* The name over its qualifier, the figure alongside — not three
|
|
109
|
+
things on one line. A row's name is its identity, and a line
|
|
110
|
+
that also has to fit a status and a figure spends a narrow
|
|
111
|
+
container's width truncating exactly that. */}
|
|
112
|
+
<View style={styles.head}>
|
|
113
|
+
<View style={styles.headText}>
|
|
114
|
+
<Text size="sm" weight="medium" numberOfLines={2} leading="tight">
|
|
115
|
+
{row.label}
|
|
116
|
+
</Text>
|
|
117
|
+
{row.meta ? (
|
|
118
|
+
<Text size="xs" color="muted" numberOfLines={1} leading="tight">
|
|
119
|
+
{row.meta}
|
|
120
|
+
</Text>
|
|
121
|
+
) : null}
|
|
122
|
+
</View>
|
|
123
|
+
{row.value ? (
|
|
124
|
+
<Text size="sm" weight="medium" tabular align="right" color={row.valueTone}>
|
|
125
|
+
{row.value}
|
|
126
|
+
</Text>
|
|
127
|
+
) : null}
|
|
128
|
+
</View>
|
|
129
|
+
<View style={[styles.track, { height }]}>
|
|
130
|
+
<View style={[styles.fill, { width: `${share * 100}%` }]}>
|
|
131
|
+
{series
|
|
132
|
+
.filter((s) => (row.values[s.key] ?? 0) > 0)
|
|
133
|
+
.map((s) => (
|
|
134
|
+
<View
|
|
135
|
+
key={s.key}
|
|
136
|
+
style={{ flex: row.values[s.key], backgroundColor: s.color }}
|
|
137
|
+
/>
|
|
138
|
+
))}
|
|
139
|
+
</View>
|
|
140
|
+
</View>
|
|
141
|
+
{row.caption ? (
|
|
142
|
+
<Text size="xs" color="muted">
|
|
143
|
+
{row.caption}
|
|
144
|
+
</Text>
|
|
145
|
+
) : null}
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
149
|
+
</View>
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const styles = StyleSheet.create({
|
|
155
|
+
empty: {
|
|
156
|
+
justifyContent: "center",
|
|
157
|
+
},
|
|
158
|
+
container: {
|
|
159
|
+
gap: SPACE.md,
|
|
160
|
+
},
|
|
161
|
+
legend: {
|
|
162
|
+
flexDirection: "row",
|
|
163
|
+
flexWrap: "wrap",
|
|
164
|
+
gap: SPACE.md,
|
|
165
|
+
},
|
|
166
|
+
// Between two rows, more than between a row's own name, bar and sentence:
|
|
167
|
+
// the three belong to one campaign and have to read as one object.
|
|
168
|
+
rows: {
|
|
169
|
+
gap: SPACE.lg,
|
|
170
|
+
},
|
|
171
|
+
row: {
|
|
172
|
+
gap: 6,
|
|
173
|
+
},
|
|
174
|
+
head: {
|
|
175
|
+
flexDirection: "row",
|
|
176
|
+
alignItems: "flex-start",
|
|
177
|
+
gap: SPACE.md,
|
|
178
|
+
},
|
|
179
|
+
headText: {
|
|
180
|
+
flex: 1,
|
|
181
|
+
minWidth: 0,
|
|
182
|
+
gap: 1,
|
|
183
|
+
},
|
|
184
|
+
// The track is the SHARED ruler; the fill is this row's share of it, and the
|
|
185
|
+
// segments split the fill. Three boxes, because collapsing the middle one is
|
|
186
|
+
// exactly how a stacked bar loses its scale.
|
|
187
|
+
track: {
|
|
188
|
+
borderRadius: 999,
|
|
189
|
+
overflow: "hidden",
|
|
190
|
+
backgroundColor: colors.zinc[100],
|
|
191
|
+
},
|
|
192
|
+
fill: {
|
|
193
|
+
flexDirection: "row",
|
|
194
|
+
height: "100%",
|
|
195
|
+
minWidth: 2,
|
|
196
|
+
},
|
|
197
|
+
});
|
package/src/table.tsx
CHANGED
|
@@ -181,9 +181,9 @@ export interface TableProps {
|
|
|
181
181
|
* THE columnar register — define `columns` once and the header band + every row's
|
|
182
182
|
* cell widths come from it, so they can't drift (no hand-rolled `W` map, no
|
|
183
183
|
* `<View style={{width}}>` per cell). Renders a full-bleed eyebrow header (a
|
|
184
|
-
* sortable column becomes a `SortHeader`) and its `TableRow` children,
|
|
185
|
-
*
|
|
186
|
-
* non-columnar list (entity piles, card stacks) use `PressableRow` directly.
|
|
184
|
+
* sortable column becomes a `SortHeader`) and its `TableRow` children, spaced by
|
|
185
|
+
* 4px with no rule between them. Compose `TableRow` / `TableCell` for the body.
|
|
186
|
+
* For a non-columnar list (entity piles, card stacks) use `PressableRow` directly.
|
|
187
187
|
*
|
|
188
188
|
* The register is container-responsive with no prop: when the measured width
|
|
189
189
|
* can't fit every column it drops droppable columns by `priority`, and below
|
|
@@ -312,10 +312,10 @@ export interface TableGroupProps {
|
|
|
312
312
|
* column: that is a sort, and it costs a band of chrome per value while telling
|
|
313
313
|
* them what the cell beside it says.
|
|
314
314
|
*
|
|
315
|
-
* The band separates itself with AIR rather than a rule, because
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* beneath it, which would orphan the title from the rows it opens.
|
|
315
|
+
* The band separates itself with AIR rather than a rule, because the register
|
|
316
|
+
* already separates its own rows with air and keeps ONE line — the band capping
|
|
317
|
+
* the columns — so a second would compete with it. The heading takes no rule
|
|
318
|
+
* beneath it either, which would orphan the title from the rows it opens.
|
|
319
319
|
*
|
|
320
320
|
* A group's rows keep their own `ordinal` numbering if they carry one: restart
|
|
321
321
|
* inside each band, where the reader's question is "which of these" rather than
|
|
@@ -609,8 +609,8 @@ const styles = StyleSheet.create({
|
|
|
609
609
|
paddingTop: 16,
|
|
610
610
|
paddingBottom: 16,
|
|
611
611
|
},
|
|
612
|
-
// A hairline under the column header anchors the columns; the rows below it
|
|
613
|
-
//
|
|
612
|
+
// A hairline under the column header anchors the columns; the rows below it carry
|
|
613
|
+
// none — see the file header for why that boundary is the register's only rule.
|
|
614
614
|
headerBandFilled: {
|
|
615
615
|
// The literal neutral, not `accent_wash`: this band is a lid on the columns,
|
|
616
616
|
// never a "you are here", and reading it through the brand token made a
|
|
@@ -644,11 +644,12 @@ const styles = StyleSheet.create({
|
|
|
644
644
|
body: {
|
|
645
645
|
gap: 4,
|
|
646
646
|
},
|
|
647
|
-
// Air, not a rule. A band boundary is the LARGEST break inside a register and
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
647
|
+
// Air, not a rule. A band boundary is the LARGEST break inside a register, and the
|
|
648
|
+
// register does not rule the smallest one either — rows are spaced, not ruled — so
|
|
649
|
+
// a line here would be a second rule competing with the column band's, and the
|
|
650
|
+
// grouping stops reading. The first band's heading sits under the column band's own
|
|
651
|
+
// hairline, so it takes less top space than the ones that follow — handled by the
|
|
652
|
+
// heading's own padding rather than by the caller.
|
|
652
653
|
groupHeading: {
|
|
653
654
|
flexDirection: "row",
|
|
654
655
|
alignItems: "center",
|