@lotics/ui 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -0
- package/src/avatar.web.tsx +102 -0
- package/src/badge.tsx +40 -9
- package/src/breakdown.tsx +121 -0
- package/src/card.tsx +150 -0
- package/src/cell_select.tsx +3 -2
- package/src/chip_group.tsx +65 -0
- package/src/colors.ts +61 -0
- package/src/column_filter.tsx +9 -24
- package/src/completion_state.tsx +43 -0
- package/src/control_surface.ts +32 -0
- package/src/counter.tsx +58 -0
- package/src/date_range_filter_field.tsx +44 -12
- package/src/detail_row.tsx +45 -0
- package/src/dialog.tsx +0 -24
- package/src/download.ts +2 -1
- package/src/drawer.tsx +94 -2
- package/src/empty_state.tsx +37 -0
- package/src/file_badge.tsx +27 -4
- package/src/file_dropzone.tsx +188 -0
- package/src/file_picker.ts +45 -0
- package/src/filter_pill.tsx +106 -0
- package/src/floating_action_bar.tsx +57 -0
- package/src/fonts.css +10 -13
- package/src/format_money.ts +38 -0
- package/src/heatmap.tsx +153 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +16 -2
- package/src/index.css +4 -3
- package/src/info_popover.tsx +4 -6
- package/src/kpi_card.tsx +19 -6
- package/src/kpi_strip.tsx +89 -0
- package/src/line_chart.tsx +61 -34
- package/src/link_button.tsx +50 -0
- package/src/metric.tsx +21 -12
- package/src/pagination.tsx +5 -9
- package/src/peek.tsx +68 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +8 -16
- package/src/pie_chart.tsx +29 -8
- package/src/pill_button.tsx +10 -8
- package/src/popover.tsx +14 -4
- package/src/pressable_highlight.tsx +10 -1
- package/src/pressable_row.tsx +91 -0
- package/src/progress_bar.tsx +47 -17
- package/src/radio_picker.tsx +20 -9
- package/src/range_slider.tsx +185 -0
- package/src/remainder_meter.tsx +48 -0
- package/src/ring_gauge.tsx +5 -5
- package/src/scan_field.tsx +58 -0
- package/src/search_input.tsx +12 -0
- package/src/sort_header.tsx +102 -0
- package/src/stacked_progress_bar.tsx +51 -16
- package/src/status_grid.tsx +187 -0
- package/src/step_list.tsx +128 -0
- package/src/step_progress.tsx +145 -0
- package/src/stepper.tsx +9 -4
- package/src/table.tsx +168 -112
- package/src/text.tsx +15 -0
- package/src/text_utils.ts +10 -0
- package/src/timeline.tsx +90 -57
- package/src/trend_footer.tsx +2 -2
- package/src/alert_row.tsx +0 -81
- package/src/table.web.tsx +0 -235
- package/src/table_picker.tsx +0 -305
- package/src/table_types.ts +0 -47
package/src/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:
|
|
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
|
-
|
|
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={{
|
|
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
|
+
});
|
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}
|