@lotics/ui 47.13.1 → 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 +13 -6
- 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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gridline VALUES a magnitude axis prints — the "nice" round numbers under
|
|
3
|
+
* the largest bar, chosen so the axis lands on about five of them.
|
|
4
|
+
*
|
|
5
|
+
* The rule belongs to the axis rather than to one chart, and it has one clause
|
|
6
|
+
* that is easy to miss: **an axis over whole things has no half.** A count of
|
|
7
|
+
* vehicles, contracts, trips or people cannot be 0,5, so a step finer than 1
|
|
8
|
+
* prints gridlines at quantities the data can never take — and it happens
|
|
9
|
+
* exactly where the numbers are smallest, because a max of 2 is where a
|
|
10
|
+
* five-tick target starts reaching below the unit. The reader is then asked to
|
|
11
|
+
* read "one and a half contracts", and the axis has stated something false
|
|
12
|
+
* about the subject rather than about the layout. So the candidate steps are
|
|
13
|
+
* filtered by what the DATA is made of: all-integer values take integer steps.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The multipliers that make a step read as round rather than arbitrary. */
|
|
17
|
+
const NICE_MULTIPLIERS = [1, 2, 2.5, 5, 10];
|
|
18
|
+
|
|
19
|
+
const TARGET_TICKS = 5;
|
|
20
|
+
|
|
21
|
+
export function axisTicks(values: number[]): number[] {
|
|
22
|
+
const maxValue = Math.max(...values, 1);
|
|
23
|
+
const integral = values.every((v) => Number.isInteger(v));
|
|
24
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
|
|
25
|
+
|
|
26
|
+
const usable = (step: number) => step > 0 && (!integral || Number.isInteger(step));
|
|
27
|
+
|
|
28
|
+
let bestStep = magnitude;
|
|
29
|
+
let bestTickCount = Infinity;
|
|
30
|
+
|
|
31
|
+
for (const multiplier of NICE_MULTIPLIERS) {
|
|
32
|
+
const step = multiplier * magnitude;
|
|
33
|
+
if (!usable(step)) continue;
|
|
34
|
+
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
35
|
+
if (Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)) {
|
|
36
|
+
bestStep = step;
|
|
37
|
+
bestTickCount = tickCount;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A finer pass, so a max of 168 is not left on gridlines a hundred apart. It
|
|
42
|
+
// is bounded at both ends — three to eight ticks — because this is the pass
|
|
43
|
+
// that can run away, and for whole things it is also where the halves came
|
|
44
|
+
// from.
|
|
45
|
+
for (const multiplier of NICE_MULTIPLIERS) {
|
|
46
|
+
const step = multiplier * (magnitude / 10);
|
|
47
|
+
if (!usable(step)) continue;
|
|
48
|
+
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
49
|
+
if (
|
|
50
|
+
tickCount >= 3 &&
|
|
51
|
+
tickCount <= 8 &&
|
|
52
|
+
Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)
|
|
53
|
+
) {
|
|
54
|
+
bestStep = step;
|
|
55
|
+
bestTickCount = tickCount;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const stepsNeeded = Math.ceil(maxValue / bestStep);
|
|
60
|
+
const ticks: number[] = [];
|
|
61
|
+
for (let i = 0; i <= stepsNeeded; i++) ticks.push(i * bestStep);
|
|
62
|
+
return ticks;
|
|
63
|
+
}
|
package/src/bar_chart.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { axisLabelIndices } from "./axis_label_indices";
|
|
6
|
+
import { axisTicks } from "./axis_ticks";
|
|
6
7
|
import { useLoticsLocale } from "./locale";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_BAR_COLOR = colors.blue[500];
|
|
@@ -18,6 +19,20 @@ const AXIS_LABEL_WIDTH = 56;
|
|
|
18
19
|
* second literal there would silently stop matching the row it measures. */
|
|
19
20
|
const LABEL_GAP = 8;
|
|
20
21
|
|
|
22
|
+
/** The horizontal orientation's fixed value column. Named because the label
|
|
23
|
+
* column's cap is computed from it, and a second literal there would stop
|
|
24
|
+
* matching the row it is measuring against. */
|
|
25
|
+
const VALUE_COLUMN_WIDTH = 96;
|
|
26
|
+
|
|
27
|
+
/** What the PLOT keeps whatever the label column asked for. A bar chart states
|
|
28
|
+
* proportion; under this the bars sit on their own 2px floor and the picture
|
|
29
|
+
* claims every row is equal. */
|
|
30
|
+
const MIN_TRACK_WIDTH = 72;
|
|
31
|
+
|
|
32
|
+
/** And what the label column keeps in return — a mark and a few characters,
|
|
33
|
+
* so the rows can still be told apart. */
|
|
34
|
+
const MIN_LABEL_WIDTH = 56;
|
|
35
|
+
|
|
21
36
|
const defaultFormatNumber = (n: number): string =>
|
|
22
37
|
new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
|
|
23
38
|
|
|
@@ -60,45 +75,6 @@ export interface BarChartProps {
|
|
|
60
75
|
height?: number;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
|
-
function useAxisTicks(maxValue: number) {
|
|
64
|
-
return useMemo(() => {
|
|
65
|
-
const targetTickCount = 5;
|
|
66
|
-
const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
|
|
67
|
-
const niceMultipliers = [1, 2, 2.5, 5, 10];
|
|
68
|
-
let bestStep = magnitude;
|
|
69
|
-
let bestTickCount = Infinity;
|
|
70
|
-
|
|
71
|
-
for (const multiplier of niceMultipliers) {
|
|
72
|
-
const step = multiplier * magnitude;
|
|
73
|
-
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
74
|
-
if (Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)) {
|
|
75
|
-
bestStep = step;
|
|
76
|
-
bestTickCount = tickCount;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const multiplier of niceMultipliers) {
|
|
81
|
-
const step = multiplier * (magnitude / 10);
|
|
82
|
-
const tickCount = Math.ceil(maxValue / step) + 1;
|
|
83
|
-
if (
|
|
84
|
-
tickCount >= 3 &&
|
|
85
|
-
tickCount <= 8 &&
|
|
86
|
-
Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)
|
|
87
|
-
) {
|
|
88
|
-
bestStep = step;
|
|
89
|
-
bestTickCount = tickCount;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const stepsNeeded = Math.ceil(maxValue / bestStep);
|
|
94
|
-
const ticks: number[] = [];
|
|
95
|
-
for (let i = 0; i <= stepsNeeded; i++) {
|
|
96
|
-
ticks.push(i * bestStep);
|
|
97
|
-
}
|
|
98
|
-
return ticks;
|
|
99
|
-
}, [maxValue]);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
78
|
/**
|
|
103
79
|
* The canonical SVG bar chart over `data: { label, value, color?, leading? }[]` —
|
|
104
80
|
* `orientation` vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels,
|
|
@@ -119,8 +95,10 @@ export function BarChart(props: BarChartProps) {
|
|
|
119
95
|
height: barAreaHeight = 200,
|
|
120
96
|
} = props;
|
|
121
97
|
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
|
122
|
-
|
|
123
|
-
|
|
98
|
+
// The gridlines come from the VALUES, not from their maximum, because an axis
|
|
99
|
+
// over whole things must not print a half — see `axis_ticks`.
|
|
100
|
+
const ticks = useMemo(() => axisTicks(data.map((d) => d.value)), [data]);
|
|
101
|
+
const axisMax = ticks[ticks.length - 1] || maxValue;
|
|
124
102
|
|
|
125
103
|
// The vertical axis prints as many labels as the row fits and thins the rest —
|
|
126
104
|
// the same rule `LineChart` runs, because it is the axis's rule and not one
|
|
@@ -132,6 +110,45 @@ export function BarChart(props: BarChartProps) {
|
|
|
132
110
|
const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
|
|
133
111
|
setLabelsWidth(event.nativeEvent.layout.width);
|
|
134
112
|
}, []);
|
|
113
|
+
|
|
114
|
+
// THE HORIZONTAL ORIENTATION IS MEASURED TOO, AND FOR TWO REASONS AT ONCE.
|
|
115
|
+
//
|
|
116
|
+
// Its label column and its value column are FIXED, so a narrow container
|
|
117
|
+
// spends its whole width on chrome and leaves the plot a few pixels: every
|
|
118
|
+
// bar then renders at its own minimum, and a row of identical stubs is a
|
|
119
|
+
// picture stating that every entity is equal. Measured at 375, a chart with a
|
|
120
|
+
// 184px label column had a 5px track. So `labelWidth` is a REQUEST, never a
|
|
121
|
+
// reservation — it gives way before the plot does, because a trimmed name
|
|
122
|
+
// beside a readable bar still says more than a full name beside a stub.
|
|
123
|
+
//
|
|
124
|
+
// The value axis under it thins by the categorical rule: its ticks are
|
|
125
|
+
// positioned by FRACTION, so a narrow track does not crowd them, it stacks
|
|
126
|
+
// them — five money labels on a short track paint one unreadable smudge, and
|
|
127
|
+
// nothing overflows, errors or clips while it happens. The tick MARKS stay: a
|
|
128
|
+
// 1px line collides with nothing, and it is the label that could not fit.
|
|
129
|
+
const [rowWidth, setRowWidth] = useState(0);
|
|
130
|
+
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
|
|
131
|
+
setRowWidth(event.nativeEvent.layout.width);
|
|
132
|
+
}, []);
|
|
133
|
+
const horizontal = useMemo(() => {
|
|
134
|
+
const chrome = VALUE_COLUMN_WIDTH + LABEL_GAP * 2;
|
|
135
|
+
const label =
|
|
136
|
+
rowWidth <= 0
|
|
137
|
+
? labelWidth
|
|
138
|
+
: Math.min(labelWidth, Math.max(MIN_LABEL_WIDTH, rowWidth - chrome - MIN_TRACK_WIDTH));
|
|
139
|
+
const track = rowWidth <= 0 ? 0 : rowWidth - label - chrome;
|
|
140
|
+
return {
|
|
141
|
+
labelWidth: label,
|
|
142
|
+
// Evenly-spaced ticks from 0 to the axis maximum sit at `i / (count - 1)`
|
|
143
|
+
// — the POINT geometry `axisLabelIndices` already serves for a line's x
|
|
144
|
+
// axis, anchored on the last tick because that is the one setting scale.
|
|
145
|
+
// Keyed on the MEASUREMENT, not on the track: unmeasured shows every
|
|
146
|
+
// label (thinning is what measurement buys), while a measured track of
|
|
147
|
+
// zero is a real answer and `axisLabelIndices` returns the one label that
|
|
148
|
+
// states the scale.
|
|
149
|
+
keptTicks: rowWidth <= 0 ? null : new Set(axisLabelIndices(ticks.length, Math.max(track, 0))),
|
|
150
|
+
};
|
|
151
|
+
}, [rowWidth, labelWidth, ticks.length]);
|
|
135
152
|
// ONE derivation from the ONE measurement — which labels survive AND how wide
|
|
136
153
|
// the box each survivor gets. They are two answers about the same row, and a
|
|
137
154
|
// second pass over the same numbers is exactly how they came to disagree about
|
|
@@ -193,14 +210,14 @@ export function BarChart(props: BarChartProps) {
|
|
|
193
210
|
if (orientation === "horizontal") {
|
|
194
211
|
return (
|
|
195
212
|
<View style={styles.chartContainer}>
|
|
196
|
-
<View style={styles.horizontalChart}>
|
|
213
|
+
<View style={styles.horizontalChart} onLayout={handleRowLayout}>
|
|
197
214
|
{data.map((item, index) => {
|
|
198
215
|
const widthPercent = (item.value / axisMax) * 100;
|
|
199
216
|
const barColor = item.color ?? DEFAULT_BAR_COLOR;
|
|
200
217
|
|
|
201
218
|
return (
|
|
202
219
|
<View key={index} style={styles.horizontalBarContainer}>
|
|
203
|
-
<View style={[styles.horizontalBarLabel, { width: labelWidth }]}>
|
|
220
|
+
<View style={[styles.horizontalBarLabel, { width: horizontal.labelWidth }]}>
|
|
204
221
|
{item.leading !== undefined ? item.leading : null}
|
|
205
222
|
<Text size="sm" numberOfLines={1}>
|
|
206
223
|
{item.label}
|
|
@@ -226,17 +243,19 @@ export function BarChart(props: BarChartProps) {
|
|
|
226
243
|
);
|
|
227
244
|
})}
|
|
228
245
|
<View style={styles.horizontalAxisContainer}>
|
|
229
|
-
<View style={{ width: labelWidth }} />
|
|
246
|
+
<View style={{ width: horizontal.labelWidth }} />
|
|
230
247
|
<View style={styles.horizontalAxisTrack}>
|
|
231
|
-
{
|
|
248
|
+
{ticks.map((tick, index) => (
|
|
232
249
|
<View
|
|
233
250
|
key={index}
|
|
234
251
|
style={[styles.horizontalAxisTick, { left: `${(tick / axisMax) * 100}%` }]}
|
|
235
252
|
>
|
|
236
253
|
<View style={styles.tickMark} />
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
254
|
+
{horizontal.keptTicks === null || horizontal.keptTicks.has(index) ? (
|
|
255
|
+
<Text size="xs" color="muted">
|
|
256
|
+
{formatNumber(tick)}
|
|
257
|
+
</Text>
|
|
258
|
+
) : null}
|
|
240
259
|
</View>
|
|
241
260
|
))}
|
|
242
261
|
</View>
|
|
@@ -252,7 +271,7 @@ export function BarChart(props: BarChartProps) {
|
|
|
252
271
|
<View style={styles.verticalChartWrapper}>
|
|
253
272
|
<View style={styles.verticalChartRow}>
|
|
254
273
|
<View style={[styles.verticalAxis, { height: barAreaHeight }]}>
|
|
255
|
-
{
|
|
274
|
+
{ticks
|
|
256
275
|
.slice()
|
|
257
276
|
.reverse()
|
|
258
277
|
.map((tick, index) => (
|
|
@@ -411,7 +430,7 @@ const styles = StyleSheet.create({
|
|
|
411
430
|
flex: 1,
|
|
412
431
|
},
|
|
413
432
|
horizontalBarValue: {
|
|
414
|
-
width:
|
|
433
|
+
width: VALUE_COLUMN_WIDTH,
|
|
415
434
|
alignItems: "flex-end",
|
|
416
435
|
},
|
|
417
436
|
horizontalBar: {
|
package/src/cell_stack.tsx
CHANGED
|
@@ -11,6 +11,12 @@ export interface CellStackProps {
|
|
|
11
11
|
*
|
|
12
12
|
* One, not several: a stack of three is no longer a value and its annotation,
|
|
13
13
|
* it is a row that wants to be a row.
|
|
14
|
+
*
|
|
15
|
+
* To RESERVE the line on a row that has nothing to say — which is how a column
|
|
16
|
+
* whose cells are sometimes two lines keeps one first-line height down the page
|
|
17
|
+
* (probe 8h-bis) — pass a whitespace string. `" "` is what everybody writes and
|
|
18
|
+
* the web renderer collapses it, so it is normalised to a non-breaking space
|
|
19
|
+
* here rather than in twelve call sites.
|
|
14
20
|
*/
|
|
15
21
|
caption?: ReactNode;
|
|
16
22
|
/**
|
|
@@ -24,6 +30,13 @@ export interface CellStackProps {
|
|
|
24
30
|
* does, so the caption keeps body size beside one.
|
|
25
31
|
*/
|
|
26
32
|
marked?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The identity mark, drawn HERE — an `Avatar`, a `FileBadge`. Sets `marked`
|
|
35
|
+
* and owns the gap between the mark and the pair, so a register's subject
|
|
36
|
+
* column is not one more hand-rolled row of mark + stack with its own idea
|
|
37
|
+
* of the gap. The column still budgets the mark's width (`TableColumn.lead`).
|
|
38
|
+
*/
|
|
39
|
+
leading?: ReactNode;
|
|
27
40
|
/** The subject's rung. The pair takes ONE rung, so the caption follows it. */
|
|
28
41
|
size?: TextSize;
|
|
29
42
|
/** The subject's ink. The caption is always `muted` — it is supporting text. */
|
|
@@ -80,7 +93,8 @@ export function CellStack(props: CellStackProps) {
|
|
|
80
93
|
title,
|
|
81
94
|
caption,
|
|
82
95
|
weight = "regular",
|
|
83
|
-
marked = false,
|
|
96
|
+
marked: markedProp = false,
|
|
97
|
+
leading,
|
|
84
98
|
size = "sm",
|
|
85
99
|
color,
|
|
86
100
|
tabular,
|
|
@@ -90,7 +104,16 @@ export function CellStack(props: CellStackProps) {
|
|
|
90
104
|
testID,
|
|
91
105
|
} = props;
|
|
92
106
|
|
|
93
|
-
|
|
107
|
+
// A caption reserving the line with nothing in it. `" "` says "keep this line"
|
|
108
|
+
// unambiguously and collapses in the web renderer, so the reserved line loses
|
|
109
|
+
// its height and the cell drops to ONE line — while the row height, set by
|
|
110
|
+
// whichever sibling is two lines tall, never moves. The column then puts its
|
|
111
|
+
// first line at two different heights on rows that look identical to every
|
|
112
|
+
// spacing probe. One app wrote it twelve times.
|
|
113
|
+
const line = typeof caption === "string" && caption.length > 0 && caption.trim() === "" ? NBSP : caption;
|
|
114
|
+
const hasCaption = line !== undefined && line !== null && line !== false;
|
|
115
|
+
const hasLeading = leading !== undefined && leading !== null && leading !== false;
|
|
116
|
+
const marked = markedProp || hasLeading;
|
|
94
117
|
// Rule 1 — the caption's rung, from what else is already separating the pair.
|
|
95
118
|
const captionSize: TextSize = weight !== "regular" || marked ? size : "xs";
|
|
96
119
|
// Rule 2 — tight closes a PAIR; `xs` is tight by construction, and a lone line
|
|
@@ -98,8 +121,8 @@ export function CellStack(props: CellStackProps) {
|
|
|
98
121
|
const subjectTight = hasCaption ? "tight" : undefined;
|
|
99
122
|
const captionTight = captionSize === "xs" ? undefined : "tight";
|
|
100
123
|
|
|
101
|
-
|
|
102
|
-
<View style={[{ gap: 0, alignItems: ALIGN[align] }, style]} testID={testID}>
|
|
124
|
+
const stack = (
|
|
125
|
+
<View style={[{ gap: 0, alignItems: ALIGN[align] }, hasLeading ? { flex: 1, minWidth: 0 } : style]} testID={hasLeading ? undefined : testID}>
|
|
103
126
|
{typeof title === "string" || typeof title === "number" ? (
|
|
104
127
|
<Text
|
|
105
128
|
size={size}
|
|
@@ -115,7 +138,7 @@ export function CellStack(props: CellStackProps) {
|
|
|
115
138
|
title
|
|
116
139
|
)}
|
|
117
140
|
{hasCaption
|
|
118
|
-
? typeof
|
|
141
|
+
? typeof line === "string" || typeof line === "number"
|
|
119
142
|
? (
|
|
120
143
|
<Text
|
|
121
144
|
size={captionSize}
|
|
@@ -124,15 +147,28 @@ export function CellStack(props: CellStackProps) {
|
|
|
124
147
|
tabular={tabular}
|
|
125
148
|
numberOfLines={numberOfLines}
|
|
126
149
|
>
|
|
127
|
-
{
|
|
150
|
+
{line}
|
|
128
151
|
</Text>
|
|
129
152
|
)
|
|
130
|
-
:
|
|
153
|
+
: line
|
|
131
154
|
: null}
|
|
132
155
|
</View>
|
|
133
156
|
);
|
|
157
|
+
if (!hasLeading) return stack;
|
|
158
|
+
return (
|
|
159
|
+
<View style={[{ flexDirection: "row", alignItems: "center", gap: LEADING_GAP, minWidth: 0 }, style]} testID={testID}>
|
|
160
|
+
{leading}
|
|
161
|
+
{stack}
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
134
164
|
}
|
|
135
165
|
|
|
166
|
+
const NBSP = "\u00a0";
|
|
167
|
+
|
|
168
|
+
/** Mark to pair \u2014 the ONE gap between an identity mark and the label beside it,
|
|
169
|
+
* wherever the kit draws that pair (`CellStack`, `Matrix`, `StateMatrix`). */
|
|
170
|
+
export const LEADING_GAP = 10;
|
|
171
|
+
|
|
136
172
|
const ALIGN = {
|
|
137
173
|
left: "flex-start",
|
|
138
174
|
right: "flex-end",
|
package/src/chip_group.tsx
CHANGED
|
@@ -147,12 +147,18 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
|
|
|
147
147
|
color={option.iconColor ?? (active ? colors.zinc[900] : colors.zinc[500])}
|
|
148
148
|
/>
|
|
149
149
|
) : null}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
{/* MEDIUM on both, never semibold on the active one.
|
|
151
|
+
|
|
152
|
+
A chip already answers "which one is on" twice — a doubled
|
|
153
|
+
zinc-900 edge and a ring — so the weight was a third signal, and
|
|
154
|
+
the wrong kind: semibold is a separate, genuinely heavier FILE
|
|
155
|
+
and the heading ramp is semibold at every rung, so an active chip
|
|
156
|
+
rendered a filter value at heading weight. State steps up in INK,
|
|
157
|
+
which is what `color` does here. It also made the active chip a
|
|
158
|
+
singleton treatment on every screen carrying one, while the
|
|
159
|
+
sibling that answers the same question — `Tabs` — steps
|
|
160
|
+
regular → medium. One question, one answer. */}
|
|
161
|
+
<Text userSelect="none" size="sm" weight="medium" color={active ? "default" : "muted"}>
|
|
156
162
|
{option.label}
|
|
157
163
|
</Text>
|
|
158
164
|
{option.count != null ? (
|
package/src/danger_zone.tsx
CHANGED
|
@@ -23,24 +23,32 @@ export interface DangerZoneProps {
|
|
|
23
23
|
* a danger heading mark it as a hazard without shouting (the kit's `tint`/`solid`
|
|
24
24
|
* discipline, never a raw hex); the consequence goes in `description`, and the
|
|
25
25
|
* action(s) go in `children`.
|
|
26
|
+
*
|
|
27
|
+
* ONE LEFT EDGE, and it is `Callout`'s anatomy that gets it: the icon sits in an
|
|
28
|
+
* outer ROW beside a column holding title, consequence and actions, so all three
|
|
29
|
+
* derive from the same edge. Put the icon INSIDE the heading row instead — the
|
|
30
|
+
* obvious way to write it — and only the TITLE moves right, so the loudest line
|
|
31
|
+
* in the block sits 22px past the sentence explaining it and the button under
|
|
32
|
+
* both starts somewhere else again. Three edges inside one 16px-padded box, on
|
|
33
|
+
* the one primitive that exists so a hazard is not hand-rolled.
|
|
26
34
|
*/
|
|
27
35
|
export function DangerZone(props: DangerZoneProps) {
|
|
28
36
|
const locale = useLoticsLocale();
|
|
29
37
|
const { title = locale.dangerZone.title, description, children } = props;
|
|
30
38
|
return (
|
|
31
39
|
<View style={styles.zone}>
|
|
32
|
-
<
|
|
33
|
-
|
|
40
|
+
<Icon name="triangle-alert" size={16} color={solid("red")} />
|
|
41
|
+
<View style={styles.body}>
|
|
34
42
|
<Text size="sm" weight="semibold" color="danger">
|
|
35
43
|
{title}
|
|
36
44
|
</Text>
|
|
45
|
+
{description ? (
|
|
46
|
+
<Text size="sm" color="muted">
|
|
47
|
+
{description}
|
|
48
|
+
</Text>
|
|
49
|
+
) : null}
|
|
50
|
+
<View style={styles.actions}>{children}</View>
|
|
37
51
|
</View>
|
|
38
|
-
{description ? (
|
|
39
|
-
<Text size="sm" color="muted">
|
|
40
|
-
{description}
|
|
41
|
-
</Text>
|
|
42
|
-
) : null}
|
|
43
|
-
<View style={styles.actions}>{children}</View>
|
|
44
52
|
</View>
|
|
45
53
|
);
|
|
46
54
|
}
|
|
@@ -49,13 +57,18 @@ const styles = StyleSheet.create({
|
|
|
49
57
|
// A soft danger frame: a low-alpha red hairline over the faintest red wash —
|
|
50
58
|
// the "set apart, not shouting" treatment via the kit's tint discipline.
|
|
51
59
|
zone: {
|
|
52
|
-
|
|
60
|
+
flexDirection: "row",
|
|
61
|
+
alignItems: "flex-start",
|
|
62
|
+
gap: 10,
|
|
53
63
|
padding: 16,
|
|
54
64
|
borderRadius: 10,
|
|
55
65
|
borderWidth: 1,
|
|
56
66
|
borderColor: tint("red", 0.3),
|
|
57
67
|
backgroundColor: tint("red", 0.03),
|
|
58
68
|
},
|
|
59
|
-
|
|
69
|
+
// The column every line in the block starts from — 1px down so the first line
|
|
70
|
+
// of type sits optically level with the 16px mark beside it, exactly as
|
|
71
|
+
// `Callout` does it.
|
|
72
|
+
body: { flex: 1, gap: 8, paddingTop: 1 },
|
|
60
73
|
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
|
|
61
74
|
});
|
package/src/date_filter.tsx
CHANGED
|
@@ -33,6 +33,10 @@ export interface DateFilterLabels extends SegmentLabels {
|
|
|
33
33
|
thisWeek: string;
|
|
34
34
|
thisMonth: string;
|
|
35
35
|
lastMonth: string;
|
|
36
|
+
thisQuarter: string;
|
|
37
|
+
lastQuarter: string;
|
|
38
|
+
thisYear: string;
|
|
39
|
+
lastYear: string;
|
|
36
40
|
from: string;
|
|
37
41
|
to: string;
|
|
38
42
|
/** Accessible name of the field that OPENS this panel (`DateRangeFilterField`). */
|
|
@@ -71,6 +75,14 @@ function presetLabel(id: PresetId, labels: DateFilterLabels): string {
|
|
|
71
75
|
return labels.thisMonth;
|
|
72
76
|
case "last_month":
|
|
73
77
|
return labels.lastMonth;
|
|
78
|
+
case "this_quarter":
|
|
79
|
+
return labels.thisQuarter;
|
|
80
|
+
case "last_quarter":
|
|
81
|
+
return labels.lastQuarter;
|
|
82
|
+
case "this_year":
|
|
83
|
+
return labels.thisYear;
|
|
84
|
+
case "last_year":
|
|
85
|
+
return labels.lastYear;
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -224,12 +236,10 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
224
236
|
(id: PresetId) => {
|
|
225
237
|
// A preset replaces the whole range, so any half-picked start is abandoned.
|
|
226
238
|
setPendingStart(null);
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
239
|
+
// Resolved against today on EVERY press, the active one included: a preset
|
|
240
|
+
// names a period, so re-pressing it re-anchors a range that has gone stale
|
|
241
|
+
// (a panel left open across midnight) rather than doing nothing. Clearing
|
|
242
|
+
// is the footer's Clear button — this list holds no null case.
|
|
233
243
|
const presetValue = getPresetValue(id, new Date());
|
|
234
244
|
onValueChange(presetValue);
|
|
235
245
|
|
|
@@ -240,7 +250,7 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
240
250
|
);
|
|
241
251
|
}
|
|
242
252
|
},
|
|
243
|
-
[onValueChange
|
|
253
|
+
[onValueChange],
|
|
244
254
|
);
|
|
245
255
|
|
|
246
256
|
// `selected` + `role="option"` is the kit's listbox row, and `MenuButton` owns
|