@lotics/ui 2.4.0 → 2.5.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/package.json +27 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -0
- package/src/avatar.web.tsx +102 -0
- package/src/badge.tsx +40 -9
- package/src/breakdown.tsx +121 -0
- package/src/card.tsx +150 -0
- package/src/cell_select.tsx +3 -2
- package/src/chip_group.tsx +65 -0
- package/src/colors.ts +61 -0
- package/src/column_filter.tsx +9 -24
- package/src/completion_state.tsx +43 -0
- package/src/control_surface.ts +32 -0
- package/src/counter.tsx +58 -0
- package/src/date_range_filter_field.tsx +44 -12
- package/src/detail_row.tsx +45 -0
- package/src/dialog.tsx +0 -24
- package/src/download.ts +2 -1
- package/src/drawer.tsx +94 -2
- package/src/empty_state.tsx +37 -0
- package/src/file_badge.tsx +27 -4
- package/src/file_dropzone.tsx +188 -0
- package/src/file_picker.ts +45 -0
- package/src/filter_pill.tsx +106 -0
- package/src/floating_action_bar.tsx +57 -0
- package/src/fonts.css +10 -13
- package/src/format_money.ts +38 -0
- package/src/heatmap.tsx +153 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +16 -2
- package/src/index.css +4 -3
- package/src/info_popover.tsx +4 -6
- package/src/kpi_card.tsx +19 -6
- package/src/kpi_strip.tsx +89 -0
- package/src/line_chart.tsx +61 -34
- package/src/link_button.tsx +50 -0
- package/src/metric.tsx +21 -12
- package/src/pagination.tsx +5 -9
- package/src/peek.tsx +68 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +8 -16
- package/src/pie_chart.tsx +29 -8
- package/src/pill_button.tsx +10 -8
- package/src/popover.tsx +14 -4
- package/src/pressable_highlight.tsx +10 -1
- package/src/pressable_row.tsx +91 -0
- package/src/progress_bar.tsx +47 -17
- package/src/radio_picker.tsx +20 -9
- package/src/range_slider.tsx +185 -0
- package/src/remainder_meter.tsx +48 -0
- package/src/ring_gauge.tsx +5 -5
- package/src/scan_field.tsx +58 -0
- package/src/search_input.tsx +12 -0
- package/src/sort_header.tsx +102 -0
- package/src/stacked_progress_bar.tsx +51 -16
- package/src/status_grid.tsx +187 -0
- package/src/step_list.tsx +128 -0
- package/src/step_progress.tsx +145 -0
- package/src/stepper.tsx +9 -4
- package/src/table.tsx +168 -112
- package/src/text.tsx +15 -0
- package/src/text_utils.ts +10 -0
- package/src/timeline.tsx +90 -57
- package/src/trend_footer.tsx +2 -2
- package/src/alert_row.tsx +0 -81
- package/src/table.web.tsx +0 -235
- package/src/table_picker.tsx +0 -305
- package/src/table_types.ts +0 -47
package/src/line_chart.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { View, StyleSheet, LayoutChangeEvent } from "react-native";
|
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { useMemo, useState, useCallback } from "react";
|
|
5
|
-
import Svg, {
|
|
5
|
+
import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
|
|
6
6
|
|
|
7
7
|
export interface LineChartPoint {
|
|
8
8
|
x: string | number;
|
|
@@ -41,44 +41,49 @@ export function LineChart(props: LineChartProps) {
|
|
|
41
41
|
|
|
42
42
|
const paddingTop = 8;
|
|
43
43
|
const paddingBottom = 8;
|
|
44
|
-
const
|
|
44
|
+
const paddingLeft = 6;
|
|
45
|
+
const paddingRight = 10;
|
|
45
46
|
|
|
47
|
+
// Nice domain AROUND the data — a 16–18% series must not be drawn against
|
|
48
|
+
// a 0–20 axis (it flattens into a sliver at the top). The baseline snaps
|
|
49
|
+
// to 0 only when the data actually lives near 0.
|
|
46
50
|
const yAxisTicks = useMemo(() => {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
51
|
+
const ys = data.map((d) => d.y);
|
|
52
|
+
const lo = Math.min(...ys, 0 === ys.length ? 0 : Infinity);
|
|
53
|
+
const hi = Math.max(...ys, lo);
|
|
54
|
+
const span = hi - lo || Math.abs(hi) || 1;
|
|
55
|
+
let min = lo - span * 0.2;
|
|
56
|
+
let max = hi + span * 0.2;
|
|
57
|
+
if (lo >= 0 && lo <= span) min = Math.max(0, min === lo ? 0 : min);
|
|
58
|
+
if (lo >= 0 && min < 0) min = 0;
|
|
59
|
+
const rawStep = (max - min) / 4 || 1;
|
|
60
|
+
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
|
61
|
+
const norm = rawStep / mag;
|
|
62
|
+
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
|
|
63
|
+
min = Math.floor(min / step) * step;
|
|
64
|
+
max = Math.ceil(max / step) * step;
|
|
58
65
|
const ticks: number[] = [];
|
|
59
|
-
for (let
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
return { ticks, max: niceMax };
|
|
66
|
+
for (let t = min; t <= max + step / 1e6; t += step) ticks.push(Number(t.toFixed(10)));
|
|
67
|
+
return { ticks, min, max };
|
|
63
68
|
}, [data]);
|
|
64
69
|
|
|
65
|
-
const maxValue = yAxisTicks
|
|
70
|
+
const { min: minValue, max: maxValue } = yAxisTicks;
|
|
66
71
|
|
|
67
72
|
const drawnPoints = useMemo(() => {
|
|
68
73
|
if (data.length === 0 || chartWidth === 0) return [];
|
|
69
74
|
|
|
70
|
-
const availableWidth = chartWidth - paddingRight;
|
|
75
|
+
const availableWidth = chartWidth - paddingLeft - paddingRight;
|
|
71
76
|
const availableHeight = chartHeight - paddingTop - paddingBottom;
|
|
77
|
+
const range = maxValue - minValue || 1;
|
|
72
78
|
|
|
73
79
|
return data.map((point, index) => {
|
|
74
80
|
const x =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const y = paddingTop + availableHeight - (point.y / maxValue) * availableHeight;
|
|
81
|
+
paddingLeft +
|
|
82
|
+
(data.length > 1 ? (index / (data.length - 1)) * availableWidth : availableWidth / 2);
|
|
83
|
+
const y = paddingTop + availableHeight - ((point.y - minValue) / range) * availableHeight;
|
|
79
84
|
return { x, y, raw: point };
|
|
80
85
|
});
|
|
81
|
-
}, [data, chartWidth, maxValue, chartHeight]);
|
|
86
|
+
}, [data, chartWidth, minValue, maxValue, chartHeight]);
|
|
82
87
|
|
|
83
88
|
const polylinePoints = drawnPoints.map((p) => `${p.x},${p.y}`).join(" ");
|
|
84
89
|
|
|
@@ -126,34 +131,55 @@ export function LineChart(props: LineChartProps) {
|
|
|
126
131
|
<View style={[styles.svgContainer, { height: chartHeight }]} onLayout={handleLayout}>
|
|
127
132
|
{chartWidth > 0 && (
|
|
128
133
|
<Svg width={chartWidth} height={chartHeight}>
|
|
134
|
+
<Defs>
|
|
135
|
+
{/* soft area wash under the line — depth without decoration */}
|
|
136
|
+
<LinearGradient id="lineChartArea" x1="0" y1="0" x2="0" y2="1">
|
|
137
|
+
<Stop offset="0" stopColor={lineColor} stopOpacity={0.16} />
|
|
138
|
+
<Stop offset="1" stopColor={lineColor} stopOpacity={0.01} />
|
|
139
|
+
</LinearGradient>
|
|
140
|
+
</Defs>
|
|
129
141
|
{yAxisTicks.ticks.map((tick, i) => {
|
|
130
142
|
const availableHeight = chartHeight - paddingTop - paddingBottom;
|
|
131
|
-
const
|
|
143
|
+
const range = maxValue - minValue || 1;
|
|
144
|
+
const y = paddingTop + availableHeight - ((tick - minValue) / range) * availableHeight;
|
|
132
145
|
return (
|
|
133
146
|
<Line
|
|
134
147
|
key={i}
|
|
135
|
-
x1={
|
|
148
|
+
x1={paddingLeft}
|
|
136
149
|
y1={y}
|
|
137
150
|
x2={chartWidth - paddingRight}
|
|
138
151
|
y2={y}
|
|
139
|
-
stroke={colors.zinc[200]}
|
|
152
|
+
stroke={i === 0 ? colors.zinc[200] : colors.zinc[100]}
|
|
140
153
|
strokeWidth={1}
|
|
141
154
|
/>
|
|
142
155
|
);
|
|
143
156
|
})}
|
|
157
|
+
{drawnPoints.length > 1 && (
|
|
158
|
+
<Polygon
|
|
159
|
+
points={`${polylinePoints} ${drawnPoints[drawnPoints.length - 1].x},${chartHeight - paddingBottom} ${drawnPoints[0].x},${chartHeight - paddingBottom}`}
|
|
160
|
+
fill="url(#lineChartArea)"
|
|
161
|
+
stroke="none"
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
144
164
|
{drawnPoints.length > 1 && (
|
|
145
165
|
<Polyline
|
|
146
166
|
points={polylinePoints}
|
|
147
167
|
fill="none"
|
|
148
168
|
stroke={lineColor}
|
|
149
|
-
strokeWidth={2}
|
|
169
|
+
strokeWidth={2.5}
|
|
150
170
|
strokeLinejoin="round"
|
|
151
171
|
strokeLinecap="round"
|
|
152
172
|
/>
|
|
153
173
|
)}
|
|
154
|
-
{drawnPoints.map((point, index) =>
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
{drawnPoints.map((point, index) => {
|
|
175
|
+
const isLast = index === drawnPoints.length - 1;
|
|
176
|
+
return isLast ? (
|
|
177
|
+
// the latest value is the story — emphasize its point
|
|
178
|
+
<Circle key={index} cx={point.x} cy={point.y} r={4.5} fill={lineColor} stroke={colors.white} strokeWidth={2} />
|
|
179
|
+
) : (
|
|
180
|
+
<Circle key={index} cx={point.x} cy={point.y} r={3} fill={colors.white} stroke={lineColor} strokeWidth={1.5} />
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
157
183
|
</Svg>
|
|
158
184
|
)}
|
|
159
185
|
</View>
|
|
@@ -198,13 +224,14 @@ const styles = StyleSheet.create({
|
|
|
198
224
|
gap: 4,
|
|
199
225
|
},
|
|
200
226
|
yAxis: {
|
|
201
|
-
width:
|
|
227
|
+
width: 48,
|
|
202
228
|
justifyContent: "space-between",
|
|
203
|
-
alignItems: "flex-
|
|
229
|
+
alignItems: "flex-end",
|
|
204
230
|
paddingVertical: 4,
|
|
231
|
+
paddingRight: 4,
|
|
205
232
|
},
|
|
206
233
|
yAxisLabel: {
|
|
207
|
-
textAlign: "
|
|
234
|
+
textAlign: "right",
|
|
208
235
|
},
|
|
209
236
|
svgContainer: {
|
|
210
237
|
flex: 1,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
4
|
+
|
|
5
|
+
export interface LinkButtonProps {
|
|
6
|
+
title: string;
|
|
7
|
+
onPress: () => void;
|
|
8
|
+
/** Defaults to `title`. */
|
|
9
|
+
accessibilityLabel?: string;
|
|
10
|
+
testID?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A quiet inline action — "Clear", "Select all", "Show more" — styled as a GHOST
|
|
15
|
+
* button: dark medium text with no chrome at rest and a soft rounded hover wash,
|
|
16
|
+
* compact (not the 40px `Button`). The low-emphasis sibling of `Button` for
|
|
17
|
+
* utility actions in a popover footer or a list's select-all row; it never
|
|
18
|
+
* competes with a real/commit button, and reads more premium than an underlined
|
|
19
|
+
* link or a filled/muted Button.
|
|
20
|
+
*/
|
|
21
|
+
export function LinkButton(props: LinkButtonProps) {
|
|
22
|
+
const { title, onPress, accessibilityLabel, testID } = props;
|
|
23
|
+
return (
|
|
24
|
+
<PressableHighlight
|
|
25
|
+
testID={testID}
|
|
26
|
+
onPress={onPress}
|
|
27
|
+
accessibilityRole="button"
|
|
28
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
29
|
+
userSelect="none"
|
|
30
|
+
style={styles.btn}
|
|
31
|
+
>
|
|
32
|
+
<Text size="sm" weight="medium">
|
|
33
|
+
{title}
|
|
34
|
+
</Text>
|
|
35
|
+
</PressableHighlight>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const styles = StyleSheet.create({
|
|
40
|
+
// The hover wash needs horizontal padding, but the negative margin cancels it
|
|
41
|
+
// for POSITIONING — so the text sits flush with the surrounding content's left
|
|
42
|
+
// edge (the option rows above, the editor body) while the wash bleeds outward.
|
|
43
|
+
btn: {
|
|
44
|
+
paddingVertical: 6,
|
|
45
|
+
paddingHorizontal: 8,
|
|
46
|
+
marginHorizontal: -8,
|
|
47
|
+
borderRadius: 8,
|
|
48
|
+
alignSelf: "flex-start",
|
|
49
|
+
},
|
|
50
|
+
});
|
package/src/metric.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { View, StyleSheet, type TextStyle } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
+
import { formatCompactNumber, formatMoney } from "./format_money";
|
|
4
5
|
import { useMemo } from "react";
|
|
5
6
|
|
|
6
7
|
export type MetricFormat = "currency" | "number" | "percentage" | "none";
|
|
@@ -19,6 +20,11 @@ export type MetricSize = "sm" | "md" | "lg" | "hero";
|
|
|
19
20
|
|
|
20
21
|
export interface MetricProps {
|
|
21
22
|
value: number | string | null | undefined;
|
|
23
|
+
/** Abbreviate large numeric values for display density: ≥1 tỷ → "1,28 tỷ",
|
|
24
|
+
* ≥1 triệu → "486 tr" (vi convention; other locales fall back to compact
|
|
25
|
+
* notation). For stat strips/cards where the full figure lives in the
|
|
26
|
+
* table below — never for the table itself. */
|
|
27
|
+
compact?: boolean;
|
|
22
28
|
previousValue?: number | string | null | undefined;
|
|
23
29
|
format?: MetricFormat;
|
|
24
30
|
currency?: string;
|
|
@@ -59,26 +65,27 @@ export function Metric(props: MetricProps) {
|
|
|
59
65
|
value,
|
|
60
66
|
previousValue,
|
|
61
67
|
format,
|
|
62
|
-
currency = "
|
|
68
|
+
currency = "VND",
|
|
63
69
|
locale,
|
|
64
70
|
emptyLabel = "-",
|
|
65
71
|
tone = "default",
|
|
66
72
|
size = "md",
|
|
73
|
+
compact = false,
|
|
67
74
|
} = props;
|
|
68
75
|
|
|
69
76
|
const displayValue = useMemo(() => {
|
|
77
|
+
// vi-VN by default on every branch — "18,4%", not "18.4%". A system
|
|
78
|
+
// locale fallback makes the same dashboard render differently per
|
|
79
|
+
// machine.
|
|
80
|
+
const resolvedLocale = locale ?? "vi-VN";
|
|
70
81
|
if (value === null || value === undefined) return emptyLabel;
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (format === "number"
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
if (format === "percentage" && typeof value === "number") {
|
|
78
|
-
return `${value.toLocaleString(locale)}%`;
|
|
79
|
-
}
|
|
82
|
+
if (typeof value !== "number") return String(value);
|
|
83
|
+
if (format === "currency") return formatMoney(value, { locale: resolvedLocale, currency, compact });
|
|
84
|
+
if (compact && Math.abs(value) >= 1_000_000) return formatCompactNumber(value, resolvedLocale);
|
|
85
|
+
if (format === "number") return value.toLocaleString(resolvedLocale);
|
|
86
|
+
if (format === "percentage") return `${value.toLocaleString(resolvedLocale)}%`;
|
|
80
87
|
return String(value);
|
|
81
|
-
}, [value, format, currency, locale, emptyLabel]);
|
|
88
|
+
}, [value, format, currency, locale, emptyLabel, compact]);
|
|
82
89
|
|
|
83
90
|
const trend = useMemo(() => {
|
|
84
91
|
if (previousValue === undefined || previousValue === null) return null;
|
|
@@ -139,8 +146,10 @@ export function Metric(props: MetricProps) {
|
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
const styles = StyleSheet.create({
|
|
149
|
+
// No flex grow/shrink: the value keeps its intrinsic width so that in a
|
|
150
|
+
// wrap-row (KPICard's value + trend chip) the CHIP drops to the next line
|
|
151
|
+
// when space runs out — never the number breaking mid-value.
|
|
142
152
|
container: {
|
|
143
|
-
flex: 1,
|
|
144
153
|
justifyContent: "center",
|
|
145
154
|
alignItems: "flex-start",
|
|
146
155
|
gap: 4,
|
package/src/pagination.tsx
CHANGED
|
@@ -2,7 +2,6 @@ import * as React from "react";
|
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { IconButton } from "./icon_button";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
-
import { colors } from "./colors";
|
|
6
5
|
|
|
7
6
|
export interface PaginationProps {
|
|
8
7
|
/** 0-indexed. */
|
|
@@ -22,9 +21,11 @@ export interface PaginationProps {
|
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
* Pagination
|
|
26
|
-
* right.
|
|
27
|
-
*
|
|
24
|
+
* Pagination control: range summary on the left, page indicator + arrows on the
|
|
25
|
+
* right. A pure control — it owns no border, background, or padding. The
|
|
26
|
+
* container that frames it (a `CardFooter`, a grid footer, a bordered band)
|
|
27
|
+
* supplies the divider and insets, so the same control composes anywhere without
|
|
28
|
+
* doubling up a border.
|
|
28
29
|
*/
|
|
29
30
|
export function Pagination(props: PaginationProps): React.ReactNode {
|
|
30
31
|
const { page, pageSize, rowCount, hasMore, total, loading, onPageChange } = props;
|
|
@@ -78,11 +79,6 @@ const styles = StyleSheet.create({
|
|
|
78
79
|
flexDirection: "row",
|
|
79
80
|
alignItems: "center",
|
|
80
81
|
gap: 12,
|
|
81
|
-
paddingVertical: 8,
|
|
82
|
-
paddingHorizontal: 12,
|
|
83
|
-
borderTopWidth: 1,
|
|
84
|
-
borderTopColor: colors.border,
|
|
85
|
-
backgroundColor: colors.background,
|
|
86
82
|
},
|
|
87
83
|
summary: { flex: 1 },
|
|
88
84
|
controls: {
|
package/src/peek.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
4
|
+
import type { PopoverSide, PopoverAlign } from "./popover";
|
|
5
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
6
|
+
|
|
7
|
+
export interface PeekProps {
|
|
8
|
+
/** The inline reference that becomes the trigger — a customer name, a
|
|
9
|
+
* record id, a member chip. Rendered inside a PressableHighlight (hover
|
|
10
|
+
* wash signals pressability without restyling the text). */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/** The instant detail revealed on press — compose Text/Divider/Button
|
|
13
|
+
* rows. Keep it a summary with ONE action to the full record; a peek
|
|
14
|
+
* that needs scrolling wanted to be a screen. */
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
/** Announced name for the trigger ("Hồ sơ KOMASPEC VIỆT NAM"). */
|
|
17
|
+
accessibilityLabel: string;
|
|
18
|
+
side?: PopoverSide;
|
|
19
|
+
align?: PopoverAlign;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Drill-down for inline references — press a name/id where it appears and
|
|
24
|
+
* get its details in a popover, without leaving the screen. The companion
|
|
25
|
+
* to the count drill-downs (KPIStrip `onPress`, Accordion rows): counts
|
|
26
|
+
* open the records behind a number, Peek opens the record behind a
|
|
27
|
+
* reference. Use it on every entity mention that has more to say —
|
|
28
|
+
* customer names, order ids, member names, linked records.
|
|
29
|
+
*/
|
|
30
|
+
export function Peek(props: PeekProps) {
|
|
31
|
+
const { children, content, accessibilityLabel, side = "bottom", align = "start" } = props;
|
|
32
|
+
return (
|
|
33
|
+
<Popover side={side} align={align}>
|
|
34
|
+
<PopoverTrigger>
|
|
35
|
+
<PressableHighlight
|
|
36
|
+
accessibilityRole="button"
|
|
37
|
+
accessibilityLabel={accessibilityLabel}
|
|
38
|
+
style={styles.trigger}
|
|
39
|
+
// 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
|
|
40
|
+
hitSlop={4}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</PressableHighlight>
|
|
44
|
+
</PopoverTrigger>
|
|
45
|
+
<PopoverContent style={styles.content} disableBodyScroll>
|
|
46
|
+
{content}
|
|
47
|
+
</PopoverContent>
|
|
48
|
+
</Popover>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const styles = StyleSheet.create({
|
|
53
|
+
// Inline bleed at the 32px chip register (matches Badge scale, fits inside
|
|
54
|
+
// text rows without inflating them); negative margins absorb the extra
|
|
55
|
+
// height so the line's layout never shifts. hitSlop keeps the 40px target.
|
|
56
|
+
trigger: {
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
minHeight: 32,
|
|
60
|
+
borderRadius: 8,
|
|
61
|
+
paddingHorizontal: 8,
|
|
62
|
+
marginHorizontal: -8,
|
|
63
|
+
marginVertical: -4,
|
|
64
|
+
},
|
|
65
|
+
content: {
|
|
66
|
+
width: 320,
|
|
67
|
+
},
|
|
68
|
+
});
|
package/src/picker.tsx
CHANGED
|
@@ -33,6 +33,10 @@ export type PickerOnClose<T extends string, MULTI extends boolean> = MULTI exten
|
|
|
33
33
|
export interface PickerProps<T extends string = string, MULTI extends boolean = false> {
|
|
34
34
|
options?: (PickerOption<T> | undefined | false)[];
|
|
35
35
|
placeholder?: string;
|
|
36
|
+
/** Accessible name for the control. Required in spirit whenever the picker
|
|
37
|
+
* has no visible label next to it (e.g. an unlabeled toolbar filter) — the
|
|
38
|
+
* selected option's text describes the value, not the control. */
|
|
39
|
+
accessibilityLabel?: string;
|
|
36
40
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
37
41
|
style?: StyleProp<ViewStyle>;
|
|
38
42
|
testID?: string;
|
|
@@ -70,6 +74,7 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
70
74
|
includeEmptyOption,
|
|
71
75
|
onValueChange,
|
|
72
76
|
placeholder,
|
|
77
|
+
accessibilityLabel,
|
|
73
78
|
style,
|
|
74
79
|
disabled = false,
|
|
75
80
|
autoFocus = false,
|
|
@@ -105,6 +110,7 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
105
110
|
<RNPicker
|
|
106
111
|
ref={pickerRef}
|
|
107
112
|
testID={testID}
|
|
113
|
+
accessibilityLabel={accessibilityLabel}
|
|
108
114
|
// Empty selection maps to "" (the placeholder option's value), never
|
|
109
115
|
// undefined — otherwise the native <select> goes uncontrolled and a
|
|
110
116
|
// programmatic reset to null leaves the prior DOM selection in place.
|
|
@@ -144,6 +150,7 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
|
144
150
|
options = [],
|
|
145
151
|
value,
|
|
146
152
|
placeholder,
|
|
153
|
+
accessibilityLabel,
|
|
147
154
|
multi = false as MULTI,
|
|
148
155
|
renderOptionContent,
|
|
149
156
|
includeEmptyOption,
|
|
@@ -188,6 +195,7 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
|
188
195
|
renderOptionContent={renderOptionContent}
|
|
189
196
|
selectedItems={selectedItems}
|
|
190
197
|
placeholder={placeholder}
|
|
198
|
+
accessibilityLabel={accessibilityLabel}
|
|
191
199
|
disabled={disabled}
|
|
192
200
|
/>
|
|
193
201
|
</PopoverTrigger>
|
|
@@ -244,6 +252,7 @@ function PickerTrigger<T extends string>({
|
|
|
244
252
|
renderOptionContent,
|
|
245
253
|
selectedItems,
|
|
246
254
|
placeholder,
|
|
255
|
+
accessibilityLabel,
|
|
247
256
|
disabled = false,
|
|
248
257
|
}: {
|
|
249
258
|
ref?: React.Ref<View>;
|
|
@@ -254,6 +263,7 @@ function PickerTrigger<T extends string>({
|
|
|
254
263
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
255
264
|
selectedItems: PickerOption<T>[];
|
|
256
265
|
placeholder?: string;
|
|
266
|
+
accessibilityLabel?: string;
|
|
257
267
|
disabled?: boolean;
|
|
258
268
|
}) {
|
|
259
269
|
const hasSelection = selectedItems.length > 0;
|
|
@@ -266,8 +276,10 @@ function PickerTrigger<T extends string>({
|
|
|
266
276
|
// the trigger drops out of the tab order and Enter/Space can't open it.
|
|
267
277
|
// `button` makes it tab-focusable and maps keyboard activation to onPress;
|
|
268
278
|
// `expanded` announces open/closed to assistive tech. The accessible name
|
|
269
|
-
// comes from
|
|
279
|
+
// comes from `accessibilityLabel` when given (an unlabeled control), else
|
|
280
|
+
// the visible selection/placeholder text below.
|
|
270
281
|
accessibilityRole="button"
|
|
282
|
+
accessibilityLabel={accessibilityLabel}
|
|
271
283
|
accessibilityState={{ expanded: open, disabled }}
|
|
272
284
|
style={[styles.pressable, open && styles.opened, disabled && styles.disabled, style]}
|
|
273
285
|
onPress={!disabled ? onPress : undefined}
|
package/src/picker_menu.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { StyleSheet, View, ScrollView,
|
|
1
|
+
import { StyleSheet, View, ScrollView, TextInput } from "react-native";
|
|
2
2
|
import { useState, useCallback, useMemo, useRef } from "react";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { Icon } from "./icon";
|
|
6
6
|
import { Checkbox } from "./checkbox";
|
|
7
|
-
import { Spacer } from "./spacer";
|
|
8
7
|
import { MenuButton } from "./menu_button";
|
|
8
|
+
import { LinkButton } from "./link_button";
|
|
9
9
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
10
|
import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
11
11
|
import { useScreenSize } from "./use_screen_size";
|
|
@@ -311,17 +311,8 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
311
311
|
</ScrollView>
|
|
312
312
|
{showLinks && (
|
|
313
313
|
<View style={styles.selectAllContainer}>
|
|
314
|
-
{showSelectAllLink &&
|
|
315
|
-
|
|
316
|
-
<Text color="zinc-500">{selectAllLabel}</Text>
|
|
317
|
-
</Pressable>
|
|
318
|
-
)}
|
|
319
|
-
<Spacer horizontal size={16} />
|
|
320
|
-
{showDeselectAllLink && (
|
|
321
|
-
<Pressable onPress={handleDeselectAll}>
|
|
322
|
-
<Text color="zinc-500">{deselectAllLabel}</Text>
|
|
323
|
-
</Pressable>
|
|
324
|
-
)}
|
|
314
|
+
{showSelectAllLink && <LinkButton title={selectAllLabel} onPress={handleSelectAll} />}
|
|
315
|
+
{showDeselectAllLink && <LinkButton title={deselectAllLabel} onPress={handleDeselectAll} />}
|
|
325
316
|
</View>
|
|
326
317
|
)}
|
|
327
318
|
</View>
|
|
@@ -339,9 +330,10 @@ const styles = StyleSheet.create({
|
|
|
339
330
|
selectAllContainer: {
|
|
340
331
|
flexDirection: "row",
|
|
341
332
|
alignItems: "center",
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
333
|
+
gap: 16,
|
|
334
|
+
paddingVertical: 4,
|
|
335
|
+
// Matches a MenuButton option's 8px text inset, so the bleeding-wash
|
|
336
|
+
// LinkButtons line up under the option labels above.
|
|
345
337
|
paddingHorizontal: 8,
|
|
346
338
|
borderTopWidth: 1,
|
|
347
339
|
borderTopColor: colors.border,
|
package/src/pie_chart.tsx
CHANGED
|
@@ -35,6 +35,8 @@ export interface PieChartProps {
|
|
|
35
35
|
showLegend?: boolean;
|
|
36
36
|
formatNumber?: (n: number) => string;
|
|
37
37
|
emptyLabel?: string;
|
|
38
|
+
/** Caption under the center total. Pass `null` to hide the center. */
|
|
39
|
+
centerLabel?: string | null;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export function PieChart(props: PieChartProps) {
|
|
@@ -44,6 +46,7 @@ export function PieChart(props: PieChartProps) {
|
|
|
44
46
|
showLegend = true,
|
|
45
47
|
formatNumber = defaultFormatNumber,
|
|
46
48
|
emptyLabel = "No data",
|
|
49
|
+
centerLabel = "Total",
|
|
47
50
|
} = props;
|
|
48
51
|
|
|
49
52
|
const [isVerticalLayout, setIsVerticalLayout] = useState(false);
|
|
@@ -66,7 +69,7 @@ export function PieChart(props: PieChartProps) {
|
|
|
66
69
|
|
|
67
70
|
const center = size / 2;
|
|
68
71
|
const outerRadius = size / 2 - 4;
|
|
69
|
-
const innerRadius = outerRadius * 0.
|
|
72
|
+
const innerRadius = outerRadius * 0.66;
|
|
70
73
|
const drawn: { path: string; color: string }[] = [];
|
|
71
74
|
let currentAngle = -Math.PI / 2;
|
|
72
75
|
|
|
@@ -113,14 +116,25 @@ export function PieChart(props: PieChartProps) {
|
|
|
113
116
|
return (
|
|
114
117
|
<View style={styles.chartContainer} onLayout={handleLayout}>
|
|
115
118
|
<View style={[styles.chartWrapper, isVerticalLayout && styles.chartWrapperVertical]}>
|
|
116
|
-
<View style={styles.pieContainer}>
|
|
119
|
+
<View style={[styles.pieContainer, { width: size, height: size }]}>
|
|
117
120
|
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
118
121
|
<G>
|
|
119
122
|
{pieSlices.map((slice, index) => (
|
|
120
|
-
|
|
123
|
+
// white seams separate the slices — segments, not a color wheel
|
|
124
|
+
<Path key={index} d={slice.path} fill={slice.color} stroke={colors.white} strokeWidth={2} />
|
|
121
125
|
))}
|
|
122
126
|
</G>
|
|
123
127
|
</Svg>
|
|
128
|
+
{centerLabel !== null ? (
|
|
129
|
+
<View style={styles.center} pointerEvents="none">
|
|
130
|
+
<Text size="lg" weight="semibold" tabular>
|
|
131
|
+
{formatNumber(total)}
|
|
132
|
+
</Text>
|
|
133
|
+
<Text size="xs" color="muted">
|
|
134
|
+
{centerLabel}
|
|
135
|
+
</Text>
|
|
136
|
+
</View>
|
|
137
|
+
) : null}
|
|
124
138
|
</View>
|
|
125
139
|
{showLegend && (
|
|
126
140
|
<View style={[styles.legend, isVerticalLayout && styles.legendVertical]}>
|
|
@@ -132,9 +146,9 @@ export function PieChart(props: PieChartProps) {
|
|
|
132
146
|
<Text size="sm" numberOfLines={1} weight="medium" style={styles.legendLabel}>
|
|
133
147
|
{item.label}
|
|
134
148
|
</Text>
|
|
135
|
-
<Text style={styles.legendValue}>{formatNumber(item.value)}</Text>
|
|
136
|
-
<Text color="zinc-500" style={styles.legendPercentage}>
|
|
137
|
-
|
|
149
|
+
<Text size="sm" tabular style={styles.legendValue}>{formatNumber(item.value)}</Text>
|
|
150
|
+
<Text size="sm" color="zinc-500" tabular style={styles.legendPercentage}>
|
|
151
|
+
{percentage.toFixed(0)}%
|
|
138
152
|
</Text>
|
|
139
153
|
</View>
|
|
140
154
|
);
|
|
@@ -163,6 +177,13 @@ const styles = StyleSheet.create({
|
|
|
163
177
|
},
|
|
164
178
|
pieContainer: {
|
|
165
179
|
flexShrink: 0,
|
|
180
|
+
alignItems: "center",
|
|
181
|
+
justifyContent: "center",
|
|
182
|
+
},
|
|
183
|
+
center: {
|
|
184
|
+
position: "absolute",
|
|
185
|
+
alignItems: "center",
|
|
186
|
+
gap: 0,
|
|
166
187
|
},
|
|
167
188
|
legend: {
|
|
168
189
|
flex: 1,
|
|
@@ -187,10 +208,10 @@ const styles = StyleSheet.create({
|
|
|
187
208
|
},
|
|
188
209
|
legendValue: {
|
|
189
210
|
minWidth: 40,
|
|
190
|
-
textAlign: "
|
|
211
|
+
textAlign: "right",
|
|
191
212
|
},
|
|
192
213
|
legendPercentage: {
|
|
193
|
-
minWidth:
|
|
214
|
+
minWidth: 40,
|
|
194
215
|
textAlign: "right",
|
|
195
216
|
},
|
|
196
217
|
});
|
package/src/pill_button.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Ref } from "react";
|
|
2
|
-
import { colors } from "./colors";
|
|
3
2
|
import { IconButton } from "./icon_button";
|
|
4
3
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
|
+
import { pillSurfaceStyle } from "./control_surface";
|
|
5
5
|
import { StyleSheet, View } from "react-native";
|
|
6
6
|
|
|
7
7
|
interface PillButtonProps {
|
|
@@ -19,11 +19,15 @@ export function PillButton(props: PillButtonProps) {
|
|
|
19
19
|
return (
|
|
20
20
|
<View ref={ref}>
|
|
21
21
|
{onPress ? (
|
|
22
|
-
<PressableHighlight
|
|
22
|
+
<PressableHighlight
|
|
23
|
+
testID={testID}
|
|
24
|
+
onPress={onPress}
|
|
25
|
+
style={(state) => [pillSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
|
|
26
|
+
>
|
|
23
27
|
{children}
|
|
24
28
|
</PressableHighlight>
|
|
25
29
|
) : (
|
|
26
|
-
<View style={[styles.
|
|
30
|
+
<View style={[pillSurfaceStyle({}), styles.pillLayout, onDismiss && styles.pillWithDismiss]}>{children}</View>
|
|
27
31
|
)}
|
|
28
32
|
{onDismiss && (
|
|
29
33
|
<View style={styles.dismissButton}>
|
|
@@ -41,15 +45,13 @@ export function PillButton(props: PillButtonProps) {
|
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
const styles = StyleSheet.create({
|
|
44
|
-
|
|
48
|
+
// Surface (height/border/radius/white + hover) comes from pillSurfaceStyle;
|
|
49
|
+
// PillButton owns only its row layout.
|
|
50
|
+
pillLayout: {
|
|
45
51
|
flexDirection: "row",
|
|
46
52
|
alignItems: "center",
|
|
47
53
|
gap: 4,
|
|
48
|
-
height: 40,
|
|
49
54
|
paddingHorizontal: 12,
|
|
50
|
-
borderRadius: 999,
|
|
51
|
-
borderWidth: 1,
|
|
52
|
-
borderColor: colors.border,
|
|
53
55
|
},
|
|
54
56
|
pillWithDismiss: {
|
|
55
57
|
paddingRight: 38,
|