@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.
- 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/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/progress_bar.tsx
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
49
|
+
{title || caption ? (
|
|
37
50
|
<View style={styles.header}>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
63
|
+
) : null}
|
|
64
|
+
<View style={styles.track}>
|
|
44
65
|
<View
|
|
45
66
|
style={[
|
|
46
|
-
styles.
|
|
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
|
-
|
|
85
|
+
alignItems: "baseline",
|
|
86
|
+
gap: 12,
|
|
65
87
|
},
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
100
|
+
fill: {
|
|
73
101
|
height: "100%",
|
|
74
|
-
borderRadius:
|
|
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
|
});
|
package/src/radio_picker.tsx
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/src/ring_gauge.tsx
CHANGED
|
@@ -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
|
|
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 =
|
|
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[
|
|
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="
|
|
61
|
+
<Text size="md" weight="semibold">
|
|
62
62
|
{label}
|
|
63
63
|
</Text>
|
|
64
64
|
{caption ? (
|
|
65
|
-
<Text size="
|
|
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
|
+
});
|
package/src/search_input.tsx
CHANGED
|
@@ -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
|
+
});
|