@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
package/src/popover.tsx CHANGED
@@ -519,7 +519,7 @@ export function PopoverContent(props: PopoverContentProps) {
519
519
  tabIndex={small ? undefined : -1}
520
520
  style={{
521
521
  position: "fixed",
522
- padding: 8,
522
+ padding: 12,
523
523
  borderTopLeftRadius: 16,
524
524
  borderTopRightRadius: 16,
525
525
  borderBottomLeftRadius: small ? 0 : 16,
@@ -595,15 +595,25 @@ export function PopoverContent(props: PopoverContentProps) {
595
595
  export interface PopoverFooterProps {
596
596
  children: React.ReactNode;
597
597
  showDivider?: boolean;
598
+ /** Action Layout rules: the closure/commit action sits RIGHT (`end`,
599
+ * the default); utility-left + commit-right → `space-between`;
600
+ * informational/custom-width footers → `start`. */
601
+ align?: "start" | "end" | "space-between";
598
602
  }
599
603
 
600
604
  export function PopoverFooter(props: PopoverFooterProps) {
601
- const { children, showDivider = true } = props;
605
+ const { children, showDivider = true, align = "end" } = props;
606
+ const justifyContent =
607
+ align === "end" ? "flex-end" : align === "space-between" ? "space-between" : "flex-start";
602
608
 
603
609
  return (
604
- <View>
610
+ // Pull out to the popover's edges (counteract its 12px inset) so the divider
611
+ // spans full width; the action row then re-insets to align with the body.
612
+ <View style={{ marginHorizontal: -12, marginTop: 12 }}>
605
613
  {showDivider && <Divider />}
606
- <View style={{ padding: 8 }}>{children}</View>
614
+ <View style={{ paddingHorizontal: 12, paddingTop: 12, flexDirection: "row", alignItems: "center", gap: 8, justifyContent }}>
615
+ {children}
616
+ </View>
607
617
  </View>
608
618
  );
609
619
  }
@@ -32,6 +32,14 @@ export interface PressableHighlightProps extends PressableProps {
32
32
  * explicitly because the base React Native `PressableProps` type omits it.
33
33
  */
34
34
  onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
35
+ /**
36
+ * Pass "none" on row/card surfaces: a pressable surface is a button, not a
37
+ * text-selection surface — drag jitter on selectable text starts a
38
+ * selection and can swallow the click. Exposed as a prop because RN types
39
+ * only carry `userSelect` on TextStyle, while react-native-web applies it
40
+ * to any element.
41
+ */
42
+ userSelect?: "auto" | "none";
35
43
  }
36
44
 
37
45
  /**
@@ -47,6 +55,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
47
55
  tooltip,
48
56
  tooltipSide = "top",
49
57
  onPress,
58
+ userSelect,
50
59
  ...restPressableProps
51
60
  } = props;
52
61
  const tooltipProps = useTooltip({
@@ -71,7 +80,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
71
80
  const hovered = (state as { hovered?: boolean }).hovered;
72
81
  return [
73
82
  {
74
- ...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
83
+ ...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
75
84
  },
76
85
  {
77
86
  backgroundColor: pressed ? colors.zinc["200"] : hovered ? colors.zinc["100"] : null,
@@ -0,0 +1,91 @@
1
+ import { ReactNode, useState } from "react";
2
+ import { Pressable, StyleProp, StyleSheet, ViewStyle } from "react-native";
3
+ import { colors } from "./colors";
4
+
5
+ export interface PressableRowProps {
6
+ onPress: () => void;
7
+ /** The open/selected record — paints the persistent highlight. */
8
+ selected?: boolean;
9
+ /** Part of a multi-select set — paints a persistent blue tint, distinct from
10
+ * `selected` (the open record). The hover / open / press wash overrides it, so
11
+ * it's the resting state of a ticked row in a bulk-select register. */
12
+ marked?: boolean;
13
+ /**
14
+ * - "bleed" (default): the standalone REGISTER row — px-20, square wash to
15
+ * the card edges, separated by full-bleed `Divider`s. The one pattern for
16
+ * top-level record lists.
17
+ * - "inset": a rounded, contained row — the wash is a rounded pill pulled in
18
+ * from the edge (radius 8, marginH -8). Lives inside a padded container (an
19
+ * `Accordion`'s drill-down rows, or a gap-separated selectable list) so the
20
+ * wash insets from the card edge instead of bleeding to it. Use when rows are
21
+ * GAP-separated rather than `Divider`-separated.
22
+ */
23
+ variant?: "bleed" | "inset";
24
+ /** Cells + trailing controls. Nested pressables (CTAs, checkboxes, ⋯)
25
+ * claim their own presses; put the accessible door inside — a plain
26
+ * `Pressable` with role="button" + "Open …" label around the row body. */
27
+ children: ReactNode;
28
+ /** Layout only (gap, minHeight overrides). The surface owns its press
29
+ * states — never pass backgroundColor for hover/selected. */
30
+ style?: StyleProp<ViewStyle>;
31
+ }
32
+
33
+ /**
34
+ * THE pressable-row surface for record lists: a role-less, non-focusable press
35
+ * target whose hover wash spans the ENTIRE row — including over nested controls
36
+ * (CTAs, ⋯, checkboxes) — because it tracks hover via DOM mouseenter/mouseleave
37
+ * rather than Pressability hover (react-native-web releases a parent
38
+ * pressable's hover to the innermost nested pressable, which would stop the
39
+ * wash short of trailing controls, and is why this can't be hand-rolled from
40
+ * `PressableHighlight`). The surface carries no button role (a button must not
41
+ * contain interactive descendants); the row body inside is the accessible door.
42
+ * `variant` picks bleed (registers) or inset (Accordion drill-down rows).
43
+ */
44
+ export function PressableRow(props: PressableRowProps) {
45
+ const { onPress, selected = false, marked = false, variant = "bleed", children, style } = props;
46
+ const [hovered, setHovered] = useState(false);
47
+
48
+ // RN's types don't declare mouse handlers; react-native-web forwards them
49
+ // to the DOM element (same boundary cast PressableHighlight uses).
50
+ const mouseProps = {
51
+ onMouseEnter: () => setHovered(true),
52
+ onMouseLeave: () => setHovered(false),
53
+ } as object;
54
+
55
+ return (
56
+ <Pressable
57
+ onPress={onPress}
58
+ focusable={false}
59
+ {...mouseProps}
60
+ style={({ pressed }) => [
61
+ styles.row,
62
+ variant === "inset" ? styles.inset : styles.bleed,
63
+ {
64
+ backgroundColor: pressed ? colors.zinc[200] : selected ? colors.zinc[100] : hovered ? colors.zinc[100] : marked ? colors.blue[50] : undefined,
65
+ },
66
+ style,
67
+ ]}
68
+ >
69
+ {children}
70
+ </Pressable>
71
+ );
72
+ }
73
+
74
+ const styles = StyleSheet.create({
75
+ row: {
76
+ flexDirection: "row",
77
+ alignItems: "center",
78
+ gap: 12,
79
+ ...({ cursor: "pointer", userSelect: "none", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
80
+ },
81
+ // Square, full-bleed — lines up with the Dividers between register rows.
82
+ bleed: {
83
+ paddingHorizontal: 20,
84
+ },
85
+ // Rounded, pulled in from the edge — matches the Accordion's inset rows.
86
+ inset: {
87
+ borderRadius: 8,
88
+ paddingHorizontal: 8,
89
+ marginHorizontal: -8,
90
+ },
91
+ });
@@ -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}