@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.
Files changed (69) hide show
  1. package/package.json +27 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/avatar.web.tsx +102 -0
  6. package/src/badge.tsx +40 -9
  7. package/src/breakdown.tsx +121 -0
  8. package/src/card.tsx +150 -0
  9. package/src/cell_select.tsx +3 -2
  10. package/src/chip_group.tsx +65 -0
  11. package/src/colors.ts +61 -0
  12. package/src/column_filter.tsx +9 -24
  13. package/src/completion_state.tsx +43 -0
  14. package/src/control_surface.ts +32 -0
  15. package/src/counter.tsx +58 -0
  16. package/src/date_range_filter_field.tsx +44 -12
  17. package/src/detail_row.tsx +45 -0
  18. package/src/dialog.tsx +0 -24
  19. package/src/download.ts +2 -1
  20. package/src/drawer.tsx +94 -2
  21. package/src/empty_state.tsx +37 -0
  22. package/src/file_badge.tsx +27 -4
  23. package/src/file_dropzone.tsx +188 -0
  24. package/src/file_picker.ts +45 -0
  25. package/src/filter_pill.tsx +106 -0
  26. package/src/floating_action_bar.tsx +57 -0
  27. package/src/fonts.css +10 -13
  28. package/src/format_money.ts +38 -0
  29. package/src/heatmap.tsx +153 -0
  30. package/src/icon.tsx +2 -0
  31. package/src/icon_button.tsx +16 -2
  32. package/src/index.css +4 -3
  33. package/src/info_popover.tsx +4 -6
  34. package/src/kpi_card.tsx +19 -6
  35. package/src/kpi_strip.tsx +89 -0
  36. package/src/line_chart.tsx +61 -34
  37. package/src/link_button.tsx +50 -0
  38. package/src/metric.tsx +21 -12
  39. package/src/pagination.tsx +5 -9
  40. package/src/peek.tsx +68 -0
  41. package/src/picker.tsx +13 -1
  42. package/src/picker_menu.tsx +8 -16
  43. package/src/pie_chart.tsx +29 -8
  44. package/src/pill_button.tsx +10 -8
  45. package/src/popover.tsx +14 -4
  46. package/src/pressable_highlight.tsx +10 -1
  47. package/src/pressable_row.tsx +91 -0
  48. package/src/progress_bar.tsx +47 -17
  49. package/src/radio_picker.tsx +20 -9
  50. package/src/range_slider.tsx +185 -0
  51. package/src/remainder_meter.tsx +48 -0
  52. package/src/ring_gauge.tsx +5 -5
  53. package/src/scan_field.tsx +58 -0
  54. package/src/search_input.tsx +12 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. package/src/table_types.ts +0 -47
@@ -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, { Polyline, Circle, Line } from "react-native-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 paddingRight = 8;
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 rawMax = Math.max(...data.map((d) => d.y), 1);
48
- const magnitude = Math.pow(10, Math.floor(Math.log10(rawMax)));
49
- const normalized = rawMax / magnitude;
50
- let niceMax: number;
51
- if (normalized <= 1) niceMax = magnitude;
52
- else if (normalized <= 2) niceMax = 2 * magnitude;
53
- else if (normalized <= 5) niceMax = 5 * magnitude;
54
- else niceMax = 10 * magnitude;
55
-
56
- const tickCount = 4;
57
- const step = niceMax / tickCount;
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 i = 0; i <= tickCount; i++) {
60
- ticks.push(i * step);
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.max;
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
- data.length > 1
76
- ? (index / (data.length - 1)) * availableWidth
77
- : availableWidth / 2;
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 y = paddingTop + availableHeight - (tick / maxValue) * availableHeight;
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={0}
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
- <Circle key={index} cx={point.x} cy={point.y} r={3} fill={lineColor} />
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: 56,
227
+ width: 48,
202
228
  justifyContent: "space-between",
203
- alignItems: "flex-start",
229
+ alignItems: "flex-end",
204
230
  paddingVertical: 4,
231
+ paddingRight: 4,
205
232
  },
206
233
  yAxisLabel: {
207
- textAlign: "left",
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 = "USD",
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 (format === "currency" && typeof value === "number") {
72
- return value.toLocaleString(locale, { style: "currency", currency });
73
- }
74
- if (format === "number" && typeof value === "number") {
75
- return value.toLocaleString(locale);
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,
@@ -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 footer: summary on the left, page indicator + arrows on the
26
- * right. Self-containedhas its own border-top so it composes as a footer
27
- * inside DataGrid OR as a standalone control elsewhere.
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 the visible selection/placeholder text below.
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}
@@ -1,11 +1,11 @@
1
- import { StyleSheet, View, ScrollView, Pressable, TextInput } from "react-native";
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
- <Pressable onPress={handleSelectAll}>
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
- justifyContent: "space-between",
343
- gap: 4,
344
- paddingVertical: 8,
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.6;
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
- <Path key={index} d={slice.path} fill={slice.color} />
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
- ({percentage.toFixed(1)}%)
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: "left",
211
+ textAlign: "right",
191
212
  },
192
213
  legendPercentage: {
193
- minWidth: 48,
214
+ minWidth: 40,
194
215
  textAlign: "right",
195
216
  },
196
217
  });
@@ -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 testID={testID} onPress={onPress} style={[styles.pill, onDismiss && styles.pillWithDismiss]}>
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.pill, onDismiss && styles.pillWithDismiss]}>{children}</View>
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
- pill: {
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,