@lotics/ui 2.4.1 → 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 (68) 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/badge.tsx +40 -9
  6. package/src/breakdown.tsx +121 -0
  7. package/src/card.tsx +150 -0
  8. package/src/cell_select.tsx +3 -2
  9. package/src/chip_group.tsx +65 -0
  10. package/src/colors.ts +61 -0
  11. package/src/column_filter.tsx +9 -24
  12. package/src/completion_state.tsx +43 -0
  13. package/src/control_surface.ts +32 -0
  14. package/src/counter.tsx +58 -0
  15. package/src/date_range_filter_field.tsx +44 -12
  16. package/src/detail_row.tsx +45 -0
  17. package/src/dialog.tsx +0 -24
  18. package/src/download.ts +2 -1
  19. package/src/drawer.tsx +94 -2
  20. package/src/empty_state.tsx +37 -0
  21. package/src/file_badge.tsx +27 -4
  22. package/src/file_dropzone.tsx +188 -0
  23. package/src/file_picker.ts +45 -0
  24. package/src/filter_pill.tsx +106 -0
  25. package/src/floating_action_bar.tsx +57 -0
  26. package/src/fonts.css +10 -13
  27. package/src/format_money.ts +38 -0
  28. package/src/heatmap.tsx +153 -0
  29. package/src/icon.tsx +2 -0
  30. package/src/icon_button.tsx +16 -2
  31. package/src/index.css +4 -3
  32. package/src/info_popover.tsx +4 -6
  33. package/src/kpi_card.tsx +19 -6
  34. package/src/kpi_strip.tsx +89 -0
  35. package/src/line_chart.tsx +61 -34
  36. package/src/link_button.tsx +50 -0
  37. package/src/metric.tsx +21 -12
  38. package/src/pagination.tsx +5 -9
  39. package/src/peek.tsx +68 -0
  40. package/src/picker.tsx +13 -1
  41. package/src/picker_menu.tsx +8 -16
  42. package/src/pie_chart.tsx +29 -8
  43. package/src/pill_button.tsx +10 -8
  44. package/src/popover.tsx +14 -4
  45. package/src/pressable_highlight.tsx +10 -1
  46. package/src/pressable_row.tsx +91 -0
  47. package/src/progress_bar.tsx +47 -17
  48. package/src/radio_picker.tsx +20 -9
  49. package/src/range_slider.tsx +185 -0
  50. package/src/remainder_meter.tsx +48 -0
  51. package/src/ring_gauge.tsx +5 -5
  52. package/src/scan_field.tsx +58 -0
  53. package/src/search_input.tsx +12 -0
  54. package/src/sort_header.tsx +102 -0
  55. package/src/stacked_progress_bar.tsx +51 -16
  56. package/src/status_grid.tsx +187 -0
  57. package/src/step_list.tsx +128 -0
  58. package/src/step_progress.tsx +145 -0
  59. package/src/stepper.tsx +9 -4
  60. package/src/table.tsx +168 -112
  61. package/src/text.tsx +15 -0
  62. package/src/text_utils.ts +10 -0
  63. package/src/timeline.tsx +90 -57
  64. package/src/trend_footer.tsx +2 -2
  65. package/src/alert_row.tsx +0 -81
  66. package/src/table.web.tsx +0 -235
  67. package/src/table_picker.tsx +0 -305
  68. package/src/table_types.ts +0 -47
@@ -1,4 +1,4 @@
1
- import { View, StyleSheet } from "react-native";
1
+ import { View, StyleSheet, type ViewStyle } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
 
@@ -7,15 +7,28 @@ export type ProgressBarFormat = "percentage" | "fraction" | "none";
7
7
  export interface ProgressBarProps {
8
8
  value: number;
9
9
  max: number;
10
+ /** What this bar measures ("Đã nhận", "Tiến độ giao") — the xs muted
11
+ * uppercase eyebrow above the bar. Omit inside a band that already names
12
+ * it. */
13
+ title?: string;
14
+ /** Caption above-right of the bar: `percentage` → "50%", `fraction` →
15
+ * "1.250 / 2.500 · 50%". */
10
16
  format?: ProgressBarFormat;
11
17
  color?: string;
12
18
  completeColor?: string;
13
19
  }
14
20
 
21
+ /**
22
+ * Continuous value-vs-max meter. Shares the labeled-meter anatomy with
23
+ * `StepProgress`/`StackedProgressBar`: optional title eyebrow left, derived
24
+ * caption right, 8px track below. Countable stages → `StepProgress`;
25
+ * weighted composition → `StackedProgressBar`.
26
+ */
15
27
  export function ProgressBar(props: ProgressBarProps) {
16
28
  const {
17
29
  value,
18
30
  max,
31
+ title,
19
32
  format = "percentage",
20
33
  color = colors.blue["500"],
21
34
  completeColor = colors.green["500"],
@@ -24,26 +37,34 @@ export function ProgressBar(props: ProgressBarProps) {
24
37
  const percentage = Math.min(100, Math.max(0, (value / max) * 100));
25
38
  const isComplete = percentage >= 100;
26
39
 
27
- const label =
40
+ const caption =
28
41
  format === "fraction"
29
- ? `${value} / ${max}`
42
+ ? `${value.toLocaleString("vi-VN")} / ${max.toLocaleString("vi-VN")} · ${Math.round(percentage)}%`
30
43
  : format === "percentage"
31
44
  ? `${Math.round(percentage)}%`
32
45
  : null;
33
46
 
34
47
  return (
35
48
  <View style={styles.container}>
36
- {label !== null && (
49
+ {title || caption ? (
37
50
  <View style={styles.header}>
38
- <Text size="sm" color="muted">
39
- {label}
40
- </Text>
51
+ {title ? (
52
+ <Text size="xs" color="muted" transform="uppercase">
53
+ {title}
54
+ </Text>
55
+ ) : null}
56
+ <View style={styles.spacer} />
57
+ {caption ? (
58
+ <Text size="xs" color="muted" tabular>
59
+ {caption}
60
+ </Text>
61
+ ) : null}
41
62
  </View>
42
- )}
43
- <View style={styles.barBackground}>
63
+ ) : null}
64
+ <View style={styles.track}>
44
65
  <View
45
66
  style={[
46
- styles.barFill,
67
+ styles.fill,
47
68
  {
48
69
  width: `${percentage}%`,
49
70
  backgroundColor: isComplete ? completeColor : color,
@@ -61,16 +82,25 @@ const styles = StyleSheet.create({
61
82
  },
62
83
  header: {
63
84
  flexDirection: "row",
64
- justifyContent: "flex-end",
85
+ alignItems: "baseline",
86
+ gap: 12,
65
87
  },
66
- barBackground: {
67
- height: 8,
68
- borderRadius: 4,
69
- backgroundColor: colors.gray["100"],
88
+ spacer: {
89
+ flex: 1,
90
+ },
91
+ // 10px — substantial enough to read as a meter, not a hairline.
92
+ track: {
93
+ height: 10,
94
+ borderRadius: 5,
95
+ backgroundColor: colors.zinc["100"],
70
96
  overflow: "hidden",
97
+ // Hairline inner edge — the track reads as a groove, not a gray strip.
98
+ ...({ boxShadow: "inset 0 0 0 1px rgba(38,38,38,0.04)" } as ViewStyle),
71
99
  },
72
- barFill: {
100
+ fill: {
73
101
  height: "100%",
74
- borderRadius: 4,
102
+ borderRadius: 5,
103
+ // Progress that snaps is dead; progress that moves is alive.
104
+ ...({ transition: "width 200ms ease-out, background-color 200ms ease-out" } as ViewStyle),
75
105
  },
76
106
  });
@@ -22,10 +22,15 @@ export interface RadioPickerProps<T extends string | number | symbol> {
22
22
  options: RadioPickerOption<T>[];
23
23
  value: T;
24
24
  onValueChange: (value: T) => void;
25
+ /** "column" (default) stacks full-width rows — right when options carry
26
+ * descriptions. "row" wraps compact options inline — right for short,
27
+ * description-less choices (a quick-entry form), where a six-option
28
+ * column would push the rest of the form below the fold. */
29
+ direction?: "column" | "row";
25
30
  }
26
31
 
27
32
  export function RadioPicker<T extends string | number | symbol>(props: RadioPickerProps<T>) {
28
- const { accessibilityLabel, options, value, onValueChange } = props;
33
+ const { accessibilityLabel, options, value, onValueChange, direction = "column" } = props;
29
34
  const itemRefs = useRef<Array<View | null>>([]);
30
35
 
31
36
  // Roving tabindex: arrow keys move focus between options and select, matching
@@ -65,7 +70,11 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
65
70
  const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
66
71
 
67
72
  return (
68
- <View accessibilityRole="radiogroup" accessibilityLabel={accessibilityLabel}>
73
+ <View
74
+ accessibilityRole="radiogroup"
75
+ accessibilityLabel={accessibilityLabel}
76
+ style={direction === "row" ? { flexDirection: "row", flexWrap: "wrap", gap: 4 } : undefined}
77
+ >
69
78
  {options.map((option, index) => (
70
79
  <RadioOption
71
80
  ref={(node: View | null) => {
@@ -76,6 +85,7 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
76
85
  value={option.value}
77
86
  description={option.description}
78
87
  testID={option.testID}
88
+ compact={direction === "row"}
79
89
  selected={value === option.value}
80
90
  isTabStop={index === tabStopIndex}
81
91
  onSelect={() => onValueChange(option.value)}
@@ -89,13 +99,14 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
89
99
  function RadioOption<T extends string | number | symbol>(
90
100
  props: RadioPickerOption<T> & {
91
101
  ref: (node: View | null) => void;
102
+ compact: boolean;
92
103
  selected: boolean;
93
104
  isTabStop: boolean;
94
105
  onSelect: () => void;
95
106
  onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
96
107
  },
97
108
  ) {
98
- const { ref, label, description, selected, isTabStop, onSelect, value, testID, onKeyDown } = props;
109
+ const { ref, label, description, compact, selected, isTabStop, onSelect, value, testID, onKeyDown } = props;
99
110
 
100
111
  const handlePress = useCallback(() => {
101
112
  onSelect();
@@ -108,9 +119,9 @@ function RadioOption<T extends string | number | symbol>(
108
119
  style={{
109
120
  flexDirection: "row",
110
121
  alignItems: "center",
111
- padding: 12,
122
+ padding: compact ? 8 : 12,
112
123
  borderRadius: 8,
113
- gap: 16,
124
+ gap: compact ? 8 : 16,
114
125
  }}
115
126
  onPress={handlePress}
116
127
  accessibilityRole="radio"
@@ -122,8 +133,8 @@ function RadioOption<T extends string | number | symbol>(
122
133
  >
123
134
  <View
124
135
  style={{
125
- width: 28,
126
- height: 28,
136
+ width: compact ? 20 : 28,
137
+ height: compact ? 20 : 28,
127
138
  borderRadius: 999,
128
139
  borderWidth: 1,
129
140
  borderColor: colors.border,
@@ -132,10 +143,10 @@ function RadioOption<T extends string | number | symbol>(
132
143
  alignItems: "center",
133
144
  }}
134
145
  >
135
- {selected && <Icon name="check" color={getTextColor("inverted")} />}
146
+ {selected && <Icon name="check" size={compact ? 14 : 24} color={getTextColor("inverted")} />}
136
147
  </View>
137
148
  <View>
138
- <Text>{label}</Text>
149
+ <Text size={compact ? "sm" : undefined}>{label}</Text>
139
150
  {!!description && <Text color="muted">{description}</Text>}
140
151
  </View>
141
152
  </PressableHighlight>
@@ -0,0 +1,185 @@
1
+ import { useRef, useState } from "react";
2
+ import { StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewStyle } from "react-native";
3
+ import { Text } from "./text";
4
+ import { colors } from "./colors";
5
+
6
+ const THUMB = 20;
7
+ const R = THUMB / 2;
8
+
9
+ export interface RangeSliderProps {
10
+ min: number;
11
+ max: number;
12
+ step?: number;
13
+ /** The controlled [low, high] selection. */
14
+ value: [number, number];
15
+ onValueChange: (value: [number, number]) => void;
16
+ /** Render the edge labels (money, units). Default: the bare number. */
17
+ format?: (value: number) => string;
18
+ /** Filled-track / thumb accent. Default the neutral ink. */
19
+ color?: string;
20
+ /** Base name — the thumbs announce "{label} minimum" / "{label} maximum". */
21
+ accessibilityLabel: string;
22
+ }
23
+
24
+ type Thumb = "low" | "high";
25
+
26
+ /**
27
+ * A short human summary of a range selection for a `FilterPill` preview —
28
+ * "5tr–10tr", or "≥ 5tr" / "≤ 10tr" when one end sits at its bound, and
29
+ * `undefined` at the full range (no active filter). Mirrors `columnFilterSummary`.
30
+ */
31
+ export function rangeSummary(
32
+ value: [number, number],
33
+ format: (n: number) => string = String,
34
+ bounds?: [number, number],
35
+ ): string | undefined {
36
+ const [low, high] = value;
37
+ if (!bounds) return `${format(low)}–${format(high)}`;
38
+ const atMin = low <= bounds[0];
39
+ const atMax = high >= bounds[1];
40
+ if (atMin && atMax) return undefined;
41
+ if (atMin) return `≤ ${format(high)}`;
42
+ if (atMax) return `≥ ${format(low)}`;
43
+ return `${format(low)}–${format(high)}`;
44
+ }
45
+
46
+ /**
47
+ * A dual-thumb range slider — drag either end to bound a continuous number
48
+ * (a price range, an amount, a weight). Drag works on web and native (the RN
49
+ * Responder system), and each thumb is a keyboard-operable ARIA slider
50
+ * (arrow keys step it, clamped against the other). For a small discrete count
51
+ * use `Counter`; for a single free value use `NumberInput`.
52
+ */
53
+ export function RangeSlider(props: RangeSliderProps) {
54
+ const { min, max, step = 1, value, onValueChange, format, color = colors.zinc[900], accessibilityLabel } = props;
55
+ const [low, high] = value;
56
+ const [width, setWidth] = useState(0);
57
+ const [dragging, setDragging] = useState<Thumb | null>(null);
58
+ const active = useRef<Thumb | null>(null);
59
+
60
+ const fmt = (n: number) => (format ? format(n) : String(n));
61
+ const span = max - min || 1;
62
+ const usable = Math.max(0, width - THUMB); // the track inset by a thumb radius at each end
63
+ const pct = (v: number) => (v - min) / span;
64
+ const x = (v: number) => pct(v) * usable; // thumb box left, in px — never crosses the edge
65
+
66
+ const snap = (raw: number) => {
67
+ const stepped = Math.round((raw - min) / step) * step + min;
68
+ return Math.min(max, Math.max(min, stepped));
69
+ };
70
+
71
+ // A thumb never crosses the other — low stays ≤ high.
72
+ const setThumb = (thumb: Thumb, v: number) => {
73
+ if (thumb === "low") onValueChange([Math.min(v, high), high]);
74
+ else onValueChange([low, Math.max(v, low)]);
75
+ };
76
+
77
+ // locationX is relative to the track; the usable range maps [R, width−R] → [0,1].
78
+ const fromX = (thumb: Thumb, locationX: number) =>
79
+ setThumb(thumb, snap(min + Math.min(1, Math.max(0, (locationX - R) / (usable || 1))) * span));
80
+
81
+ const closerThumb = (locationX: number): Thumb => {
82
+ const f = Math.min(1, Math.max(0, (locationX - R) / (usable || 1)));
83
+ const v = min + f * span;
84
+ return Math.abs(v - low) <= Math.abs(v - high) ? "low" : "high";
85
+ };
86
+
87
+ const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);
88
+
89
+ // The track is the responder; grant picks the nearer thumb. We refuse
90
+ // termination so a parent ScrollView can't steal the drag on a vertical wobble.
91
+ const responder = {
92
+ onStartShouldSetResponder: () => true,
93
+ onMoveShouldSetResponder: () => true,
94
+ onResponderTerminationRequest: () => false,
95
+ onResponderGrant: (e: GestureResponderEvent) => {
96
+ const t = closerThumb(e.nativeEvent.locationX);
97
+ active.current = t;
98
+ setDragging(t);
99
+ fromX(t, e.nativeEvent.locationX);
100
+ },
101
+ onResponderMove: (e: GestureResponderEvent) => {
102
+ if (active.current) fromX(active.current, e.nativeEvent.locationX);
103
+ },
104
+ onResponderRelease: () => { active.current = null; setDragging(null); },
105
+ onResponderTerminate: () => { active.current = null; setDragging(null); },
106
+ };
107
+
108
+ // Web keyboard handler, forwarded by react-native-web (cast like the mouse
109
+ // handlers elsewhere — RN's View types don't declare it).
110
+ const keyProps = (thumb: Thumb) =>
111
+ ({
112
+ onKeyDown: (e: { key: string; preventDefault: () => void }) => {
113
+ const d = e.key === "ArrowRight" || e.key === "ArrowUp" ? step : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -step : 0;
114
+ if (d === 0) return;
115
+ e.preventDefault();
116
+ setThumb(thumb, snap((thumb === "low" ? low : high) + d));
117
+ },
118
+ }) as object;
119
+
120
+ return (
121
+ <View style={styles.container}>
122
+ <View style={styles.labels}>
123
+ <Text size="sm" color="muted" tabular>{fmt(low)}</Text>
124
+ <Text size="sm" color="muted" tabular>{fmt(high)}</Text>
125
+ </View>
126
+ <View style={styles.trackArea} onLayout={onLayout} {...responder}>
127
+ <View style={styles.trackBg} />
128
+ <View style={[styles.trackFill, { left: x(low) + R, width: Math.max(0, x(high) - x(low)), backgroundColor: color }]} />
129
+ {(["low", "high"] as const).map((thumb) => {
130
+ const v = thumb === "low" ? low : high;
131
+ return (
132
+ <View
133
+ key={thumb}
134
+ accessibilityRole="adjustable"
135
+ accessibilityLabel={`${accessibilityLabel} ${thumb === "low" ? "minimum" : "maximum"}`}
136
+ accessibilityValue={{ min, max, now: v }}
137
+ focusable
138
+ style={[styles.thumb, { left: x(v), borderColor: color }, dragging === thumb && styles.thumbActive]}
139
+ {...keyProps(thumb)}
140
+ />
141
+ );
142
+ })}
143
+ </View>
144
+ </View>
145
+ );
146
+ }
147
+
148
+ const styles = StyleSheet.create({
149
+ container: { gap: 10, minWidth: 240 },
150
+ labels: { flexDirection: "row", justifyContent: "space-between" },
151
+ trackArea: {
152
+ height: 28,
153
+ justifyContent: "center",
154
+ ...({ cursor: "pointer", touchAction: "none" } as ViewStyle),
155
+ },
156
+ trackBg: {
157
+ position: "absolute",
158
+ left: R,
159
+ right: R,
160
+ top: 11,
161
+ height: 6,
162
+ borderRadius: 999,
163
+ backgroundColor: colors.zinc[200],
164
+ },
165
+ trackFill: {
166
+ position: "absolute",
167
+ top: 11,
168
+ height: 6,
169
+ borderRadius: 999,
170
+ },
171
+ thumb: {
172
+ position: "absolute",
173
+ top: 4,
174
+ width: THUMB,
175
+ height: THUMB,
176
+ borderRadius: 999,
177
+ backgroundColor: colors.white,
178
+ borderWidth: 2,
179
+ ...({ cursor: "pointer", boxShadow: "0 1px 3px rgba(38,38,38,0.18)" } as ViewStyle),
180
+ },
181
+ thumbActive: {
182
+ // a soft halo while dragging — no size change, so the thumb never shifts
183
+ ...({ boxShadow: "0 1px 3px rgba(38,38,38,0.18), 0 0 0 6px rgba(38,38,38,0.10)" } as ViewStyle),
184
+ },
185
+ });
@@ -0,0 +1,48 @@
1
+ import { StyleSheet, View, type DimensionValue } from "react-native";
2
+ import { colors, solid } from "./colors";
3
+ import { Text } from "./text";
4
+
5
+ export interface RemainderMeterProps {
6
+ /** The source amount being distributed — the payment, the available stock. */
7
+ total: number;
8
+ /** How much has been allocated across the targets so far. */
9
+ allocated: number;
10
+ /** Format amounts (money / units). Defaults to a plain locale string. */
11
+ format?: (n: number) => string;
12
+ }
13
+
14
+ /**
15
+ * The heart of an allocation — how much of a SOURCE has been distributed and
16
+ * how much is left. Three states the operator reads at a glance: UNDER (a
17
+ * remainder still to place), EXACT (fully applied — green), OVER (over-allocated
18
+ * — red, the commit is blocked). Pair with `AllocationRow`s that split the source
19
+ * down until this reads zero. Reused by cash application, stock allocation, cost
20
+ * distribution, budgeting.
21
+ */
22
+ export function RemainderMeter(props: RemainderMeterProps) {
23
+ const { total, allocated, format = (n) => n.toLocaleString() } = props;
24
+ const remainder = total - allocated;
25
+ const state = remainder > 0 ? "under" : remainder < 0 ? "over" : "exact";
26
+ const pct: DimensionValue = `${total <= 0 ? 0 : Math.min(100, (allocated / total) * 100)}%`;
27
+ const barColor = state === "over" ? solid("red") : state === "exact" ? solid("emerald") : solid("blue");
28
+ const right =
29
+ state === "exact" ? "Fully applied" : state === "over" ? `Over by ${format(-remainder)}` : `${format(remainder)} unapplied`;
30
+ return (
31
+ <View style={{ gap: 8 }}>
32
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
33
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{`${format(allocated)} of ${format(total)} applied`}</Text>
34
+ <Text size="sm" weight="medium" tabular color={state === "over" ? "danger" : state === "exact" ? "success" : "default"}>
35
+ {right}
36
+ </Text>
37
+ </View>
38
+ <View style={styles.track}>
39
+ <View style={[styles.fill, { width: pct, backgroundColor: barColor }]} />
40
+ </View>
41
+ </View>
42
+ );
43
+ }
44
+
45
+ const styles = StyleSheet.create({
46
+ track: { height: 8, borderRadius: 999, backgroundColor: colors.zinc[200], overflow: "hidden" },
47
+ fill: { height: "100%", borderRadius: 999 },
48
+ });
@@ -11,7 +11,7 @@ export interface RingGaugeProps {
11
11
  caption?: string;
12
12
  /** Diameter in px. Default 128. */
13
13
  size?: number;
14
- /** Ring stroke width in px. Default 12. */
14
+ /** Ring stroke width in px. Default 14 — thinner reads weak. */
15
15
  thickness?: number;
16
16
  /** Arc color. Default teal accent. */
17
17
  color?: string;
@@ -29,7 +29,7 @@ export interface RingGaugeProps {
29
29
  * (`rotate(-90)`) and grows clockwise via `strokeDasharray`.
30
30
  */
31
31
  export function RingGauge(props: RingGaugeProps) {
32
- const { value, label, caption, size = 140, thickness = 10, color = colors.teal[600] } = props;
32
+ const { value, label, caption, size = 140, thickness = 14, color = colors.teal[600] } = props;
33
33
  const clamped = Math.max(0, Math.min(100, value));
34
34
  const center = size / 2;
35
35
  const radius = (size - thickness) / 2;
@@ -40,7 +40,7 @@ export function RingGauge(props: RingGaugeProps) {
40
40
  <View style={{ alignItems: "center", gap: 12 }}>
41
41
  <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
42
42
  <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ position: "absolute" }}>
43
- <circle cx={center} cy={center} r={radius} fill="none" stroke={colors.zinc[200]} strokeWidth={thickness} />
43
+ <circle cx={center} cy={center} r={radius} fill="none" stroke={colors.zinc[100]} strokeWidth={thickness} />
44
44
  <circle
45
45
  cx={center}
46
46
  cy={center}
@@ -58,11 +58,11 @@ export function RingGauge(props: RingGaugeProps) {
58
58
  </Text>
59
59
  </View>
60
60
  <View style={{ alignItems: "center", gap: 2 }}>
61
- <Text size="sm" weight="medium">
61
+ <Text size="md" weight="semibold">
62
62
  {label}
63
63
  </Text>
64
64
  {caption ? (
65
- <Text size="xs" color="muted">
65
+ <Text size="sm" color="muted">
66
66
  {caption}
67
67
  </Text>
68
68
  ) : null}
@@ -0,0 +1,58 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors, solid } from "./colors";
3
+ import { Text } from "./text";
4
+ import { TextInputField } from "./text_input_field";
5
+
6
+ export type ScanStatus = "idle" | "match" | "mismatch";
7
+
8
+ export interface ScanFieldProps {
9
+ value: string;
10
+ onChangeText: (text: string) => void;
11
+ placeholder?: string;
12
+ /** Verify state — drives the border colour, the leading glyph, and the inline
13
+ * message. Stays "idle" until the consumer compares the scan to the expected
14
+ * code and reports the result. */
15
+ status?: ScanStatus;
16
+ /** Inline confirmation shown when `status` is "match". */
17
+ matchHint?: string;
18
+ /** Inline correction shown when `status` is "mismatch". */
19
+ mismatchHint?: string;
20
+ /** Fired on Enter — a scan gun sends a return after the code, so this is the
21
+ * "scanned" signal (also a manual submit). */
22
+ onScan?: () => void;
23
+ accessibilityLabel?: string;
24
+ }
25
+
26
+ /**
27
+ * The scan/verify input for operations work — scan (or type) a code to confirm
28
+ * you're at the right bin, on the right item, handling the right tote. Unlike a
29
+ * plain field it carries a VERIFY state: the border, the leading glyph, and an
30
+ * inline message go green on a match and red on a mismatch, giving the operator a
31
+ * go / no-go before they act. Reused across pick / pack / receive / count / ship.
32
+ */
33
+ export function ScanField(props: ScanFieldProps) {
34
+ const { value, onChangeText, placeholder, status = "idle", matchHint, mismatchHint, onScan, accessibilityLabel } = props;
35
+ const borderColor = status === "match" ? solid("emerald") : status === "mismatch" ? solid("red") : colors.zinc[300];
36
+ return (
37
+ <View style={{ gap: 6 }}>
38
+ <TextInputField
39
+ value={value}
40
+ onChangeText={onChangeText}
41
+ onSubmitEditing={onScan}
42
+ placeholder={placeholder}
43
+ icon={status === "match" ? "circle-check" : "scan"}
44
+ accessibilityLabel={accessibilityLabel}
45
+ style={[styles.field, { borderColor }]}
46
+ />
47
+ {status === "match" && matchHint ? <Text size="xs" color="success">{matchHint}</Text> : null}
48
+ {status === "mismatch" && mismatchHint ? <Text size="xs" color="danger">{mismatchHint}</Text> : null}
49
+ </View>
50
+ );
51
+ }
52
+
53
+ const styles = StyleSheet.create({
54
+ field: {
55
+ borderRadius: 10,
56
+ backgroundColor: colors.white,
57
+ },
58
+ });
@@ -8,6 +8,7 @@ import {
8
8
  View,
9
9
  type ViewStyle,
10
10
  } from "react-native";
11
+ import { colors } from "./colors";
11
12
  import { TextInputField } from "./text_input_field";
12
13
 
13
14
  type SearchInputProps = Omit<
@@ -55,7 +56,18 @@ export function SearchInput(props: SearchInputProps) {
55
56
  }
56
57
 
57
58
  const styles = StyleSheet.create({
59
+ // Search reads as a view-control by SHAPE (rounded pill + the leading
60
+ // search glyph). White fill pops against the zinc-50 canvas where most
61
+ // toolbars live; a zinc-300 border (one step up from the form-input
62
+ // hairline) keeps it defined on white cards too, where the fill alone
63
+ // would vanish. 40px (TextInputField default) — the system control height
64
+ // every band aligns to.
58
65
  pill: {
59
66
  borderRadius: 999,
67
+ backgroundColor: colors.white,
68
+ // A thin 1px resting border (TextInputField's default width) — emphasis on
69
+ // interaction comes from the input's own focus outline, so the pill stays
70
+ // quiet at rest. zinc-300 keeps it defined on white cards.
71
+ borderColor: colors.zinc[300],
60
72
  },
61
73
  });
@@ -0,0 +1,102 @@
1
+ import { StyleSheet, type ViewStyle } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Icon } from "./icon";
4
+ import { colors } from "./colors";
5
+ import { PressableHighlight } from "./pressable_highlight";
6
+
7
+ export type SortDir = "asc" | "desc";
8
+ export interface SortState {
9
+ key: string;
10
+ dir: SortDir;
11
+ }
12
+
13
+ /**
14
+ * Cycle a SINGLE-column sort on press: none → asc → desc → none (the third
15
+ * toggle clears it). The parent holds one `SortState | null`.
16
+ */
17
+ export function cycleSort(current: SortState | null, key: string): SortState | null {
18
+ if (current?.key !== key) return { key, dir: "asc" };
19
+ if (current.dir === "asc") return { key, dir: "desc" };
20
+ return null;
21
+ }
22
+
23
+ /**
24
+ * Order a COPY of `items` by the active column. `getValue` maps (item, key) to a
25
+ * comparable (lowercase strings for case-insensitive order). Returns `items`
26
+ * unchanged when nothing is sorted.
27
+ */
28
+ export function sortBy<T>(
29
+ items: T[],
30
+ sort: SortState | null,
31
+ getValue: (item: T, key: string) => string | number,
32
+ ): T[] {
33
+ if (!sort) return items;
34
+ const dir = sort.dir === "asc" ? 1 : -1;
35
+ return [...items].sort((a, b) => {
36
+ const va = getValue(a, sort.key);
37
+ const vb = getValue(b, sort.key);
38
+ return va < vb ? -dir : va > vb ? dir : 0;
39
+ });
40
+ }
41
+
42
+ export interface SortHeaderProps {
43
+ label: string;
44
+ sortKey: string;
45
+ sort: SortState | null;
46
+ onSort: (key: string) => void;
47
+ /** Right-align for numeric columns — the arrow then sits LEFT of the label. */
48
+ align?: "left" | "right";
49
+ style?: ViewStyle;
50
+ }
51
+
52
+ /**
53
+ * A sortable column header — the eyebrow label's pressable sibling. Press
54
+ * cycles a single-column sort (none → asc → desc → none) via `cycleSort`,
55
+ * showing a direction chevron when active; the parent orders rows with `sortBy`.
56
+ * One sort at a time. The hover wash bleeds (negative margin) so the label stays
57
+ * flush with the column content beneath it.
58
+ */
59
+ export function SortHeader(props: SortHeaderProps) {
60
+ const { label, sortKey, sort, onSort, align = "left", style } = props;
61
+ const active = sort?.key === sortKey;
62
+ const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
63
+ const dirText = active ? (sort.dir === "asc" ? ", ascending" : ", descending") : "";
64
+
65
+ return (
66
+ <PressableHighlight
67
+ accessibilityRole="button"
68
+ accessibilityLabel={`Sort by ${label}${dirText}`}
69
+ onPress={() => onSort(sortKey)}
70
+ style={[styles.header, align === "right" ? styles.right : null, style]}
71
+ >
72
+ {align === "right" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
73
+ <Text
74
+ size="xs"
75
+ color={active ? "default" : "muted"}
76
+ weight={active ? "medium" : "regular"}
77
+ transform="uppercase"
78
+ numberOfLines={1}
79
+ >
80
+ {label}
81
+ </Text>
82
+ {align === "left" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
83
+ </PressableHighlight>
84
+ );
85
+ }
86
+
87
+ const styles = StyleSheet.create({
88
+ // Negative margin so the wash bleeds while the label stays flush with the
89
+ // column content below (same idea as LinkButton).
90
+ header: {
91
+ flexDirection: "row",
92
+ alignItems: "center",
93
+ gap: 4,
94
+ paddingVertical: 4,
95
+ paddingHorizontal: 6,
96
+ marginHorizontal: -6,
97
+ borderRadius: 6,
98
+ },
99
+ right: {
100
+ justifyContent: "flex-end",
101
+ },
102
+ });