@lotics/ui 47.13.0 → 47.14.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 +16 -2
- package/docs/catalog.md +225 -27
- package/docs/composition.md +83 -4
- package/docs/data_entry.md +17 -7
- package/docs/reviewing.md +30 -0
- package/docs/templates.md +1 -1
- package/examples/tpl_attendance.tsx +51 -80
- package/examples/tpl_pivot.tsx +10 -1
- package/examples/tpl_record.tsx +33 -107
- package/package.json +9 -1
- package/src/avatar_size.ts +9 -8
- package/src/axis_ticks.ts +63 -0
- package/src/bar_chart.tsx +69 -50
- package/src/cell_stack.tsx +43 -7
- package/src/chip_group.tsx +12 -6
- package/src/danger_zone.tsx +23 -10
- package/src/date_filter.tsx +17 -7
- package/src/date_filter_presets.ts +209 -1
- package/src/date_range_filter_field.tsx +14 -4
- package/src/detail_row.tsx +18 -18
- package/src/drawer.tsx +66 -28
- package/src/format_date.ts +31 -3
- package/src/index.css +18 -0
- package/src/ledger.tsx +23 -15
- package/src/line_chart.tsx +194 -61
- package/src/list_item.tsx +22 -3
- package/src/locale.tsx +25 -0
- package/src/matrix.tsx +116 -16
- package/src/matrix_totals.ts +71 -3
- package/src/month_math.ts +16 -0
- package/src/month_stepper.tsx +62 -0
- package/src/organization_name.ts +94 -0
- package/src/search_match.ts +19 -0
- package/src/section_nav.tsx +163 -0
- package/src/state_matrix.tsx +265 -0
- package/src/table.tsx +67 -5
- package/src/timetable.tsx +167 -0
- package/src/toggle_strip.tsx +259 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Badge } from "./badge";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { type IconName } from "./icon";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
8
|
+
import { MenuButton } from "./menu_button";
|
|
9
|
+
import { Popover, PopoverContent } from "./popover";
|
|
10
|
+
|
|
11
|
+
/** One stop on the outline. `issue` is what makes the nav answer "where is the
|
|
12
|
+
* problem" and not merely "where am I" — the section still states the condition
|
|
13
|
+
* itself beside the fields it concerns; this only says where to look. */
|
|
14
|
+
export interface SectionNavItem<K extends string = string> {
|
|
15
|
+
key: K;
|
|
16
|
+
label: string;
|
|
17
|
+
icon: IconName;
|
|
18
|
+
issue?: "warning" | "danger";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SectionNavProps<K extends string = string> {
|
|
22
|
+
/** In PAGE ORDER — the same order `useSectionNav` spies on. */
|
|
23
|
+
items: readonly SectionNavItem<K>[];
|
|
24
|
+
/** From `useSectionNav().activeKey`. */
|
|
25
|
+
activeKey: K;
|
|
26
|
+
/** From `useSectionNav().jumpTo`. */
|
|
27
|
+
onJump: (key: K) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Rail when true, pinned bar when false.
|
|
30
|
+
*
|
|
31
|
+
* The CALLER decides, from its own measured container — never from the window.
|
|
32
|
+
* A record surface renders inside a page and inside a drawer, and a window
|
|
33
|
+
* read gives the drawer the page's answer: the rail appears in a column that
|
|
34
|
+
* has no gutter to hold it.
|
|
35
|
+
*/
|
|
36
|
+
wide: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Where this record returns to — a `BackButton`, in both forms.
|
|
39
|
+
*
|
|
40
|
+
* The rail's standard opens with it, visually apart from the outline items; the
|
|
41
|
+
* bar keeps it for the same reason. Omit it where the container already has
|
|
42
|
+
* one (a drawer's own header), because two exits are two exits.
|
|
43
|
+
*/
|
|
44
|
+
back?: ReactNode;
|
|
45
|
+
/** Rail width. The gutter it floats in is the caller's arithmetic. */
|
|
46
|
+
railWidth?: number;
|
|
47
|
+
/** The bar's horizontal gutter. A fill-less button shows no box, so its INK is
|
|
48
|
+
* what lines up with the section titles below: pass the container's content
|
|
49
|
+
* padding and this subtracts the button's own. */
|
|
50
|
+
barGutter?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The record surface's outline, in its two forms.
|
|
55
|
+
*
|
|
56
|
+
* `useSectionNav` is the scroll-spy half — offsets, `activeKey`, `jumpTo`. This
|
|
57
|
+
* is the CHROME half, and the two are used together: the hook decides which
|
|
58
|
+
* section the reader is in, this draws the list that says so and jumps.
|
|
59
|
+
*
|
|
60
|
+
* ## The two forms are one list
|
|
61
|
+
*
|
|
62
|
+
* Where the container affords a gutter, the outline is a RAIL floating in it.
|
|
63
|
+
* Where it does not, the same list collapses to a pinned bar naming the CURRENT
|
|
64
|
+
* section, which opens the list as a picker. Both render the same items with the
|
|
65
|
+
* same `current` item and the same attention dots, because they are one control
|
|
66
|
+
* — a reader who learns the rail has learned the bar.
|
|
67
|
+
*
|
|
68
|
+
* The picker anchors to the bar's own button (`triggerRef`, since the trigger IS
|
|
69
|
+
* a `Button` and a button must not nest in a pressable), so it is scoped to
|
|
70
|
+
* whatever container that bar sits in. A `Modal` escapes the container and takes
|
|
71
|
+
* the window, which inside a drawer covers the register behind it.
|
|
72
|
+
*
|
|
73
|
+
* ```tsx
|
|
74
|
+
* const nav = useSectionNav(["general", "fees"] as const);
|
|
75
|
+
* const [w, setW] = useState<number | null>(null);
|
|
76
|
+
* <View onLayout={(e) => setW(e.nativeEvent.layout.width)}>
|
|
77
|
+
* <SectionNav
|
|
78
|
+
* items={SECTIONS}
|
|
79
|
+
* activeKey={nav.activeKey}
|
|
80
|
+
* onJump={nav.jumpTo}
|
|
81
|
+
* wide={w != null && w >= MY_THRESHOLD}
|
|
82
|
+
* back={<BackButton label="Records" onPress={close} />}
|
|
83
|
+
* />
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* What this does NOT own, and must not grow: the gutter the rail floats in, the
|
|
87
|
+
* width threshold, and which sections are in trouble. Those read the caller's
|
|
88
|
+
* layout and the caller's domain — one consumer cannot tell them apart from the
|
|
89
|
+
* contract, and lifting them along is how a component starts describing one
|
|
90
|
+
* screen instead of a shape.
|
|
91
|
+
*/
|
|
92
|
+
export function SectionNav<K extends string = string>(props: SectionNavProps<K>) {
|
|
93
|
+
const { items, activeKey, onJump, wide, back, railWidth = 208, barGutter = 28 } = props;
|
|
94
|
+
const [open, setOpen] = useState(false);
|
|
95
|
+
const anchor = useRef<View>(null);
|
|
96
|
+
const words = useLoticsLocale().sectionNav;
|
|
97
|
+
|
|
98
|
+
// A bare dot announces nothing, and here it is the ONLY thing saying a
|
|
99
|
+
// section needs looking at — so it carries the word a sighted reader infers
|
|
100
|
+
// from its colour.
|
|
101
|
+
const dot = (issue: SectionNavItem<K>["issue"]) =>
|
|
102
|
+
issue ? (
|
|
103
|
+
<View accessibilityLabel={issue === "danger" ? words.hasProblem : words.needsAttention}>
|
|
104
|
+
<Badge variant="dot" color={issue === "danger" ? "red" : "amber"} />
|
|
105
|
+
</View>
|
|
106
|
+
) : undefined;
|
|
107
|
+
|
|
108
|
+
const rows = (onPick: (key: K) => void) => (
|
|
109
|
+
<View style={{ gap: 2 }}>
|
|
110
|
+
{items.map((s) => (
|
|
111
|
+
<MenuButton
|
|
112
|
+
key={s.key}
|
|
113
|
+
icon={s.icon}
|
|
114
|
+
title={s.label}
|
|
115
|
+
current={activeKey === s.key}
|
|
116
|
+
right={dot(s.issue)}
|
|
117
|
+
onPress={() => onPick(s.key)}
|
|
118
|
+
/>
|
|
119
|
+
))}
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (wide) {
|
|
124
|
+
return (
|
|
125
|
+
<View style={{ width: railWidth, gap: 10 }}>
|
|
126
|
+
{back}
|
|
127
|
+
{rows(onJump)}
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const current = items.find((s) => s.key === activeKey);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<View
|
|
136
|
+
style={{
|
|
137
|
+
borderBottomWidth: 1,
|
|
138
|
+
borderBottomColor: colors.zinc[200],
|
|
139
|
+
paddingHorizontal: Math.max(0, barGutter - 10),
|
|
140
|
+
paddingVertical: 6,
|
|
141
|
+
flexDirection: "row",
|
|
142
|
+
alignItems: "center",
|
|
143
|
+
gap: 8,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{back}
|
|
147
|
+
<Popover open={open} onOpenChange={setOpen} triggerRef={anchor} side="bottom" align="start">
|
|
148
|
+
<View ref={anchor}>
|
|
149
|
+
{/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
|
|
150
|
+
position readout that happens to be pressable, and a filled chip
|
|
151
|
+
competes with the record's own actions for the eye. */}
|
|
152
|
+
<Button title={current?.label ?? words.sections} color="muted" onPress={() => setOpen(true)} />
|
|
153
|
+
</View>
|
|
154
|
+
<PopoverContent style={{ width: 248 }} disableBodyScroll>
|
|
155
|
+
{rows((key) => {
|
|
156
|
+
setOpen(false);
|
|
157
|
+
onJump(key);
|
|
158
|
+
})}
|
|
159
|
+
</PopoverContent>
|
|
160
|
+
</Popover>
|
|
161
|
+
</View>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { type ReactNode, useState } from "react";
|
|
2
|
+
import { ScrollView, StyleSheet, View } from "react-native";
|
|
3
|
+
import { LEADING_GAP } from "./cell_stack";
|
|
4
|
+
import { colors, solid, type ColorName } from "./colors";
|
|
5
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
7
|
+
import { Text } from "./text";
|
|
8
|
+
|
|
9
|
+
/** The glyph's geometry. The legend draws the same one, so a cell and its
|
|
10
|
+
* legend entry can never disagree. */
|
|
11
|
+
export type StateMatrixShape = "filled" | "hollow" | "dot" | "none";
|
|
12
|
+
|
|
13
|
+
export interface StateMatrixState {
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** Palette family — the glyph and the legend swatch derive from it. */
|
|
17
|
+
color: ColorName;
|
|
18
|
+
/** Default `filled`. `none` keeps the box and draws nothing — "nothing was
|
|
19
|
+
* due here", as distinct from a state worth a glyph. */
|
|
20
|
+
shape?: StateMatrixShape;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StateMatrixColumn {
|
|
24
|
+
key: string;
|
|
25
|
+
/** One or two characters — a day number, an hour. Shown ONCE in the header. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** A second header line — the weekday under the day number. */
|
|
28
|
+
caption?: string;
|
|
29
|
+
/** The column the reader is standing in. Tinted down the grid, `aria-current`. */
|
|
30
|
+
current?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StateMatrixCell {
|
|
34
|
+
state: string;
|
|
35
|
+
/** Replaces the glyph — a count, when one cell holds several. */
|
|
36
|
+
value?: ReactNode;
|
|
37
|
+
/** Only a pressable cell is a `button`; the rest announce as marks. */
|
|
38
|
+
pressable?: boolean;
|
|
39
|
+
/** What a screen reader says — the row, the column and the state, in words. */
|
|
40
|
+
name: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StateMatrixRow {
|
|
44
|
+
key: string;
|
|
45
|
+
label: ReactNode;
|
|
46
|
+
/** An identity mark before the label. */
|
|
47
|
+
leading?: ReactNode;
|
|
48
|
+
/** A pinned figure beside the label — "9/14". */
|
|
49
|
+
total?: string;
|
|
50
|
+
/** One per column, in column order. */
|
|
51
|
+
cells: StateMatrixCell[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StateMatrixLabels {
|
|
55
|
+
/** Header of the pinned total column. */
|
|
56
|
+
total: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StateMatrixProps {
|
|
60
|
+
columns: StateMatrixColumn[];
|
|
61
|
+
rows: StateMatrixRow[];
|
|
62
|
+
states: StateMatrixState[];
|
|
63
|
+
onPressCell?: (rowKey: string, colKey: string) => void;
|
|
64
|
+
/** Width of the pinned label block. Default 180. */
|
|
65
|
+
rowLabelWidth?: number;
|
|
66
|
+
/** Cell edge. Default 28 — thirty-one of them fit a 1024px pane beside the labels. */
|
|
67
|
+
cellWidth?: number;
|
|
68
|
+
labels?: Partial<StateMatrixLabels>;
|
|
69
|
+
testID?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ROW_HEIGHT = 36;
|
|
73
|
+
const HEADER_HEIGHT = 40;
|
|
74
|
+
const CELL_GAP = 2;
|
|
75
|
+
const TOTAL_WIDTH = 64;
|
|
76
|
+
const MARK = 12;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A CATEGORICAL cross-tab: rows of subjects, a fixed axis of positions across
|
|
80
|
+
* the top, and in every cell a STATE drawn as a glyph — an attendance sheet,
|
|
81
|
+
* a fleet's month day by day, a rota. The reader runs an eye down one column
|
|
82
|
+
* to see who was out on the 14th, or along one row to see a subject's month.
|
|
83
|
+
*
|
|
84
|
+
* It is not `Matrix`: nothing here is a number, and a blank cell means
|
|
85
|
+
* "nothing was due", never zero. It is not `StatusGrid`: that is a pile of
|
|
86
|
+
* units with no axes. It is not `Timetable`: a timetable's positions are a
|
|
87
|
+
* pattern with no dates and every mark is its own editor; here the columns are
|
|
88
|
+
* dated and a cell is read, and opened when it has something to say.
|
|
89
|
+
*
|
|
90
|
+
* What the component owns, and why it is not a `View` of `View`s: the label
|
|
91
|
+
* block is PINNED outside the scroller so the row keeps its name however far
|
|
92
|
+
* the axis scrolls; the scroll is the grid's own, never the page's; the legend
|
|
93
|
+
* derives from `states`, so it cannot list a glyph the cells do not draw; the
|
|
94
|
+
* current column carries `aria-current` and a tint down its whole height; and
|
|
95
|
+
* every cell has a NAME, because a glyph announces as nothing.
|
|
96
|
+
*/
|
|
97
|
+
export function StateMatrix(props: StateMatrixProps) {
|
|
98
|
+
const { columns, rows, states, onPressCell, rowLabelWidth = 180, cellWidth = 28, labels, testID } = props;
|
|
99
|
+
const l = { ...useLoticsLocale().stateMatrix, ...labels };
|
|
100
|
+
const hasTotal = rows.some((r) => r.total !== undefined);
|
|
101
|
+
const stateOf = (key: string) => states.find((s) => s.key === key);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View style={styles.root} testID={testID}>
|
|
105
|
+
<View style={styles.body}>
|
|
106
|
+
{/* Pinned: the row's name and its figure stay put while the axis scrolls. */}
|
|
107
|
+
<View style={{ width: rowLabelWidth + (hasTotal ? TOTAL_WIDTH : 0) }}>
|
|
108
|
+
<View style={[styles.headRow, { height: HEADER_HEIGHT }]}>
|
|
109
|
+
<View style={{ flex: 1 }} />
|
|
110
|
+
{hasTotal ? (
|
|
111
|
+
<Text size="sm" weight="medium" color="muted" align="right" numberOfLines={1} style={{ width: TOTAL_WIDTH }}>
|
|
112
|
+
{l.total}
|
|
113
|
+
</Text>
|
|
114
|
+
) : null}
|
|
115
|
+
</View>
|
|
116
|
+
{rows.map((r) => (
|
|
117
|
+
<View key={r.key} style={[styles.labelRow, { height: ROW_HEIGHT }]}>
|
|
118
|
+
{r.leading}
|
|
119
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
120
|
+
{typeof r.label === "string" ? (
|
|
121
|
+
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
122
|
+
{r.label}
|
|
123
|
+
</Text>
|
|
124
|
+
) : (
|
|
125
|
+
r.label
|
|
126
|
+
)}
|
|
127
|
+
</View>
|
|
128
|
+
{hasTotal ? (
|
|
129
|
+
<Text size="sm" tabular align="right" numberOfLines={1} style={{ width: TOTAL_WIDTH }}>
|
|
130
|
+
{r.total ?? ""}
|
|
131
|
+
</Text>
|
|
132
|
+
) : null}
|
|
133
|
+
</View>
|
|
134
|
+
))}
|
|
135
|
+
</View>
|
|
136
|
+
|
|
137
|
+
<ScrollView horizontal style={{ flex: 1 }} contentContainerStyle={styles.scrollContent}>
|
|
138
|
+
<View>
|
|
139
|
+
<View style={[styles.axis, { height: HEADER_HEIGHT }]}>
|
|
140
|
+
{columns.map((c) => (
|
|
141
|
+
<View
|
|
142
|
+
key={c.key}
|
|
143
|
+
aria-current={c.current ? "date" : undefined}
|
|
144
|
+
style={[styles.axisCell, { width: cellWidth }, c.current ? styles.currentWash : null]}
|
|
145
|
+
>
|
|
146
|
+
<Text size="sm" weight="medium" color={c.current ? "default" : "muted"} tabular numberOfLines={1}>
|
|
147
|
+
{c.label}
|
|
148
|
+
</Text>
|
|
149
|
+
{c.caption !== undefined ? (
|
|
150
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
151
|
+
{c.caption}
|
|
152
|
+
</Text>
|
|
153
|
+
) : null}
|
|
154
|
+
</View>
|
|
155
|
+
))}
|
|
156
|
+
</View>
|
|
157
|
+
{rows.map((r) => (
|
|
158
|
+
<View key={r.key} style={[styles.cellRow, { height: ROW_HEIGHT }]}>
|
|
159
|
+
{columns.map((c, i) => {
|
|
160
|
+
const cell = r.cells[i];
|
|
161
|
+
if (cell === undefined) return <View key={c.key} style={{ width: cellWidth }} />;
|
|
162
|
+
const state = stateOf(cell.state);
|
|
163
|
+
return (
|
|
164
|
+
<Cell
|
|
165
|
+
key={c.key}
|
|
166
|
+
cell={cell}
|
|
167
|
+
width={cellWidth}
|
|
168
|
+
current={c.current === true}
|
|
169
|
+
color={state?.color ?? "zinc"}
|
|
170
|
+
shape={state?.shape ?? "filled"}
|
|
171
|
+
onPress={cell.pressable && onPressCell ? () => onPressCell(r.key, c.key) : undefined}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
})}
|
|
175
|
+
</View>
|
|
176
|
+
))}
|
|
177
|
+
</View>
|
|
178
|
+
</ScrollView>
|
|
179
|
+
</View>
|
|
180
|
+
|
|
181
|
+
<View style={styles.legend}>
|
|
182
|
+
{states.map((s) => {
|
|
183
|
+
const count = rows.reduce((n, r) => n + r.cells.filter((c) => c.state === s.key).length, 0);
|
|
184
|
+
return (
|
|
185
|
+
<View key={s.key} style={styles.legendItem}>
|
|
186
|
+
<Mark color={s.color} shape={s.shape ?? "filled"} />
|
|
187
|
+
<Text size="xs">{s.label}</Text>
|
|
188
|
+
<Text size="xs" color="muted" tabular>
|
|
189
|
+
{String(count)}
|
|
190
|
+
</Text>
|
|
191
|
+
</View>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</View>
|
|
195
|
+
</View>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface CellProps {
|
|
200
|
+
cell: StateMatrixCell;
|
|
201
|
+
width: number;
|
|
202
|
+
current: boolean;
|
|
203
|
+
color: ColorName;
|
|
204
|
+
shape: StateMatrixShape;
|
|
205
|
+
onPress?: () => void;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function Cell({ cell, width, current, color, shape, onPress }: CellProps) {
|
|
209
|
+
const [hovered, setHovered] = useState(false);
|
|
210
|
+
const inner = cell.value !== undefined ? (
|
|
211
|
+
<Text size="xs" weight="medium" tabular>
|
|
212
|
+
{cell.value}
|
|
213
|
+
</Text>
|
|
214
|
+
) : (
|
|
215
|
+
<Mark color={color} shape={shape} />
|
|
216
|
+
);
|
|
217
|
+
const wash = current ? styles.currentWash : null;
|
|
218
|
+
if (onPress === undefined) {
|
|
219
|
+
return (
|
|
220
|
+
<View accessibilityRole="image" accessibilityLabel={cell.name} style={[styles.cell, { width }, wash]}>
|
|
221
|
+
{inner}
|
|
222
|
+
</View>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return (
|
|
226
|
+
<FocusRingPressable
|
|
227
|
+
accessibilityRole="button"
|
|
228
|
+
accessibilityLabel={cell.name}
|
|
229
|
+
onPress={onPress}
|
|
230
|
+
onHoverIn={() => setHovered(true)}
|
|
231
|
+
onHoverOut={() => setHovered(false)}
|
|
232
|
+
style={[styles.cell, { width }, wash, hovered ? styles.cellHover : null]}
|
|
233
|
+
>
|
|
234
|
+
{inner}
|
|
235
|
+
</FocusRingPressable>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** One glyph, drawn the same in a cell and in the legend. */
|
|
240
|
+
function Mark({ color, shape }: { color: ColorName; shape: StateMatrixShape }) {
|
|
241
|
+
if (shape === "none") return <View style={styles.mark} />;
|
|
242
|
+
if (shape === "dot") return <View style={[styles.mark, styles.markDotBox]}><View style={[styles.dot, { backgroundColor: solid(color) }]} /></View>;
|
|
243
|
+
if (shape === "hollow") return <View style={[styles.mark, styles.markHollow, { borderColor: solid(color) }]} />;
|
|
244
|
+
return <View style={[styles.mark, { backgroundColor: solid(color) }]} />;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const styles = StyleSheet.create({
|
|
248
|
+
root: { gap: 12 },
|
|
249
|
+
body: { flexDirection: "row" },
|
|
250
|
+
headRow: { flexDirection: "row", alignItems: "flex-end", paddingBottom: 6 },
|
|
251
|
+
labelRow: { flexDirection: "row", alignItems: "center", gap: LEADING_GAP, paddingRight: 8 },
|
|
252
|
+
scrollContent: { flexGrow: 1 },
|
|
253
|
+
axis: { flexDirection: "row", gap: CELL_GAP, alignItems: "flex-end", paddingBottom: 2 },
|
|
254
|
+
axisCell: { alignItems: "center", paddingVertical: 2, borderRadius: 6 },
|
|
255
|
+
cellRow: { flexDirection: "row", gap: CELL_GAP },
|
|
256
|
+
cell: { alignItems: "center", justifyContent: "center", borderRadius: 6 },
|
|
257
|
+
cellHover: { backgroundColor: colors.zinc[200] },
|
|
258
|
+
currentWash: { backgroundColor: colors.zinc[100] },
|
|
259
|
+
mark: { width: MARK, height: MARK, borderRadius: 3 },
|
|
260
|
+
markHollow: { borderWidth: 1.5, backgroundColor: colors.white },
|
|
261
|
+
markDotBox: { alignItems: "center", justifyContent: "center" },
|
|
262
|
+
dot: { width: 6, height: 6, borderRadius: 999 },
|
|
263
|
+
legend: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 14, rowGap: 4 },
|
|
264
|
+
legendItem: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 24 },
|
|
265
|
+
});
|
package/src/table.tsx
CHANGED
|
@@ -16,9 +16,9 @@ import { colors, solid, type ColorName } from "./colors";
|
|
|
16
16
|
import { Icon } from "./icon";
|
|
17
17
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
18
18
|
import { PressableRow } from "./pressable_row";
|
|
19
|
-
import { CONTROL_HEIGHT } from "./control_surface";
|
|
19
|
+
import { CONTROL_HEIGHT, MIN_CONTROL_WIDTH } from "./control_surface";
|
|
20
20
|
import { PressDoor } from "./press_door";
|
|
21
|
-
import { DetailRow } from "./detail_row";
|
|
21
|
+
import { DetailRow, DetailTable } from "./detail_row";
|
|
22
22
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
23
23
|
import { COLUMN_GAP, ROW_GUTTER, ROW_HEIGHT, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
|
|
24
24
|
|
|
@@ -40,6 +40,13 @@ export interface TableColumn extends TableFitColumn {
|
|
|
40
40
|
/** Header label — sentence case, at the BODY size. Omit for a control column.
|
|
41
41
|
* In stacked mode it renders as the eyebrow over the cell's value. */
|
|
42
42
|
label?: string;
|
|
43
|
+
/**
|
|
44
|
+
* A header that is not a WORD — the seven positions a `Timetable` row toggles,
|
|
45
|
+
* a unit ruler. Rendered in the band in place of `label`; `label` stays the
|
|
46
|
+
* column's name for the stacked eyebrow and the sort control. Never a way to
|
|
47
|
+
* restyle a label: a header is chrome, and chrome has one treatment.
|
|
48
|
+
*/
|
|
49
|
+
header?: ReactNode;
|
|
43
50
|
/** Flex grow when no `width` (default 1). */
|
|
44
51
|
flex?: number;
|
|
45
52
|
align?: "left" | "right";
|
|
@@ -81,6 +88,17 @@ interface TableCtx {
|
|
|
81
88
|
}
|
|
82
89
|
const TableContext = createContext<TableCtx | null>(null);
|
|
83
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The label column a STACKED register's field lines take.
|
|
93
|
+
*
|
|
94
|
+
* Narrower than `DETAIL_LABEL_WIDTH` (130) because these labels are COLUMN
|
|
95
|
+
* HEADERS — "Hạn xử lý", "Nhà xe", "Trạng thái" — not field names, and they are
|
|
96
|
+
* competing for a phone's width with the value beside them. It is one number for
|
|
97
|
+
* the surface, which is the whole point: the labels wrap inside it rather than
|
|
98
|
+
* any one of them pushing its own value out of line with the row above.
|
|
99
|
+
*/
|
|
100
|
+
const STACKED_LABEL_WIDTH = 104;
|
|
101
|
+
|
|
84
102
|
/**
|
|
85
103
|
* The STT gutter — wide enough for four digits at `xs`, which is every register
|
|
86
104
|
* anyone scrolls. Fixed rather than measured: a width that grew with the number
|
|
@@ -288,7 +306,9 @@ export function Table(props: TableProps) {
|
|
|
288
306
|
<LeadGutter ordinal={count}>{selectAll}</LeadGutter>
|
|
289
307
|
{visibleColumns.map((col) => (
|
|
290
308
|
<View key={col.key} style={colStyle(col)}>
|
|
291
|
-
{col.
|
|
309
|
+
{col.header != null ? (
|
|
310
|
+
col.header
|
|
311
|
+
) : col.label ? (
|
|
292
312
|
col.sortable && onSort ? (
|
|
293
313
|
<SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
|
|
294
314
|
) : (
|
|
@@ -643,13 +663,37 @@ export function TableRow(props: TableRowProps) {
|
|
|
643
663
|
{cells.length > 1 ? (
|
|
644
664
|
// The field lines sit on the identity column's text edge — indented
|
|
645
665
|
// past the leading gutter, never wrapping under the checkbox.
|
|
666
|
+
//
|
|
667
|
+
// WRAPPED IN A `DetailTable`, which is what gives them the FORM grammar:
|
|
668
|
+
// one label column, every value on one left edge. Without it each cell's
|
|
669
|
+
// `DetailRow` fell back to SPREAD — the label takes the slack and the
|
|
670
|
+
// value is pushed right — and a spread row is right for ONE detail value
|
|
671
|
+
// and wrong for a register of them. Repeated down twenty-one rows, the
|
|
672
|
+
// same label string measured seven distinct widths and its value started
|
|
673
|
+
// at a different x on every row, so the narrow fork had no value column
|
|
674
|
+
// at all. The `DetailTable` also carries its own floor: below it the rows
|
|
675
|
+
// stack label-over-value, which is still ONE left edge.
|
|
646
676
|
<View
|
|
647
677
|
style={[
|
|
648
678
|
styles.stackedFields,
|
|
649
679
|
ctx.leading + ctx.ordinal > 0 ? { paddingLeft: ctx.leading + ctx.ordinal + COLUMN_GAP } : null,
|
|
650
680
|
]}
|
|
651
681
|
>
|
|
652
|
-
{
|
|
682
|
+
{/* `MIN_CONTROL_WIDTH`, not the record's `MIN_VALUE_WIDTH`. The two
|
|
683
|
+
answer different questions and a register's cells are the other
|
|
684
|
+
case: they hold a badge, a date, a short kind or a control, not the
|
|
685
|
+
company names and addresses a record's value column carries. Left
|
|
686
|
+
at 200 the grid stacked label-OVER-value at 375, turning every row
|
|
687
|
+
into four lines and a twenty-one-row queue into a scroll marathon
|
|
688
|
+
— correct alignment, wrong density. */}
|
|
689
|
+
<DetailTable
|
|
690
|
+
labelWidth={STACKED_LABEL_WIDTH}
|
|
691
|
+
minValueWidth={MIN_CONTROL_WIDTH}
|
|
692
|
+
minHeight={0}
|
|
693
|
+
style={styles.stackedFieldGrid}
|
|
694
|
+
>
|
|
695
|
+
{cells.slice(1)}
|
|
696
|
+
</DetailTable>
|
|
653
697
|
</View>
|
|
654
698
|
) : null}
|
|
655
699
|
{action != null ? <View style={styles.stackedActionLine}>{action}</View> : null}
|
|
@@ -665,7 +709,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
665
709
|
return <View style={styles.staticStackedRow}>{body}</View>;
|
|
666
710
|
}
|
|
667
711
|
return (
|
|
668
|
-
<View style={[styles.staticRow,
|
|
712
|
+
<View style={topAligned ? [styles.staticRow, { alignItems: "flex-start" as const }] : styles.staticRow}>
|
|
669
713
|
<LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
|
|
670
714
|
<View style={cellsStyle}>{cells}</View>
|
|
671
715
|
{ctx.trailing > 0 ? (
|
|
@@ -752,6 +796,18 @@ export function TableCell(props: TableCellProps) {
|
|
|
752
796
|
if (_primary) {
|
|
753
797
|
return <>{children}</>;
|
|
754
798
|
}
|
|
799
|
+
// A column whose header is a set of POSITIONS keeps them over its value
|
|
800
|
+
// when the register stacks: the marks under it are unreadable otherwise.
|
|
801
|
+
if (_column?.header != null) {
|
|
802
|
+
return (
|
|
803
|
+
<View style={styles.stackedBareCell}>
|
|
804
|
+
<View style={{ gap: 4 }}>
|
|
805
|
+
{_column.header}
|
|
806
|
+
{children}
|
|
807
|
+
</View>
|
|
808
|
+
</View>
|
|
809
|
+
);
|
|
810
|
+
}
|
|
755
811
|
if (!_column?.label) {
|
|
756
812
|
return <View style={styles.stackedBareCell}>{children}</View>;
|
|
757
813
|
}
|
|
@@ -903,6 +959,12 @@ const styles = StyleSheet.create({
|
|
|
903
959
|
stackedFields: {
|
|
904
960
|
gap: 8,
|
|
905
961
|
},
|
|
962
|
+
// The field grid inside a stacked row. `DetailTable` owns the label column and
|
|
963
|
+
// the row gap; this only removes the gap it would add on top of
|
|
964
|
+
// `stackedFields`' own, so the two are not paid twice.
|
|
965
|
+
stackedFieldGrid: {
|
|
966
|
+
gap: 8,
|
|
967
|
+
},
|
|
906
968
|
// The register's trailing gutter composes [action, overflow] on one row —
|
|
907
969
|
// the gap matches the composite the templates previously hand-rolled.
|
|
908
970
|
trailingSlot: {
|