@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/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
7
7
|
"./colors": "./src/colors.ts",
|
|
8
8
|
"./mime": "./src/mime.ts",
|
|
9
9
|
"./download": "./src/download.ts",
|
|
10
|
+
"./file_picker": "./src/file_picker.ts",
|
|
10
11
|
"./comments_thread": "./src/comments_thread.tsx",
|
|
11
12
|
"./file_badge": "./src/file_badge.tsx",
|
|
13
|
+
"./file_dropzone": "./src/file_dropzone.tsx",
|
|
12
14
|
"./file_thumbnail": "./src/file_thumbnail.tsx",
|
|
13
15
|
"./file_preview": {
|
|
14
16
|
"react-native": "./src/file_preview.tsx",
|
|
@@ -27,13 +29,18 @@
|
|
|
27
29
|
"./trend_chip": "./src/trend_chip.tsx",
|
|
28
30
|
"./section_card": "./src/section_card.tsx",
|
|
29
31
|
"./kpi_card": "./src/kpi_card.tsx",
|
|
32
|
+
"./kpi_strip": "./src/kpi_strip.tsx",
|
|
33
|
+
"./empty_state": "./src/empty_state.tsx",
|
|
34
|
+
"./format_money": "./src/format_money.ts",
|
|
30
35
|
"./kanban": "./src/kanban/index.ts",
|
|
31
36
|
"./calendar": "./src/calendar/index.ts",
|
|
32
37
|
"./gantt": "./src/gantt/index.ts",
|
|
33
38
|
"./ring_gauge": "./src/ring_gauge.tsx",
|
|
34
|
-
"./alert_row": "./src/alert_row.tsx",
|
|
35
39
|
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
|
36
40
|
"./legend_item": "./src/legend_item.tsx",
|
|
41
|
+
"./breakdown": "./src/breakdown.tsx",
|
|
42
|
+
"./status_grid": "./src/status_grid.tsx",
|
|
43
|
+
"./heatmap": "./src/heatmap.tsx",
|
|
37
44
|
"./trend_footer": "./src/trend_footer.tsx",
|
|
38
45
|
"./spacing": "./src/spacing.ts",
|
|
39
46
|
"./theme": "./src/theme.tsx",
|
|
@@ -64,8 +71,12 @@
|
|
|
64
71
|
"./menu_button": "./src/menu_button.tsx",
|
|
65
72
|
"./menu_list_item": "./src/menu_list_item.tsx",
|
|
66
73
|
"./pressable_highlight": "./src/pressable_highlight.tsx",
|
|
74
|
+
"./pressable_row": "./src/pressable_row.tsx",
|
|
75
|
+
"./floating_action_bar": "./src/floating_action_bar.tsx",
|
|
67
76
|
"./icon_button": "./src/icon_button.tsx",
|
|
68
77
|
"./info_popover": "./src/info_popover.tsx",
|
|
78
|
+
"./peek": "./src/peek.tsx",
|
|
79
|
+
"./action_menu": "./src/action_menu.tsx",
|
|
69
80
|
"./card_select_item": "./src/card_select_item.tsx",
|
|
70
81
|
"./badge": "./src/badge.tsx",
|
|
71
82
|
"./divider": "./src/divider.tsx",
|
|
@@ -86,6 +97,18 @@
|
|
|
86
97
|
"./form_text_input": "./src/form_text_input.tsx",
|
|
87
98
|
"./form_switch": "./src/form_switch.tsx",
|
|
88
99
|
"./pill_button": "./src/pill_button.tsx",
|
|
100
|
+
"./filter_pill": "./src/filter_pill.tsx",
|
|
101
|
+
"./range_slider": "./src/range_slider.tsx",
|
|
102
|
+
"./counter": "./src/counter.tsx",
|
|
103
|
+
"./link_button": "./src/link_button.tsx",
|
|
104
|
+
"./sort_header": "./src/sort_header.tsx",
|
|
105
|
+
"./table": "./src/table.tsx",
|
|
106
|
+
"./detail_row": "./src/detail_row.tsx",
|
|
107
|
+
"./scan_field": "./src/scan_field.tsx",
|
|
108
|
+
"./step_list": "./src/step_list.tsx",
|
|
109
|
+
"./completion_state": "./src/completion_state.tsx",
|
|
110
|
+
"./remainder_meter": "./src/remainder_meter.tsx",
|
|
111
|
+
"./allocation_row": "./src/allocation_row.tsx",
|
|
89
112
|
"./back_button": "./src/back_button.tsx",
|
|
90
113
|
"./container": "./src/container.tsx",
|
|
91
114
|
"./count": "./src/count.tsx",
|
|
@@ -101,12 +124,8 @@
|
|
|
101
124
|
"./card": "./src/card.tsx",
|
|
102
125
|
"./accordion": "./src/accordion.tsx",
|
|
103
126
|
"./stepper": "./src/stepper.tsx",
|
|
127
|
+
"./step_progress": "./src/step_progress.tsx",
|
|
104
128
|
"./tabs": "./src/tabs.tsx",
|
|
105
|
-
"./table": {
|
|
106
|
-
"react-native": "./src/table.tsx",
|
|
107
|
-
"default": "./src/table.web.tsx"
|
|
108
|
-
},
|
|
109
|
-
"./table_types": "./src/table_types.ts",
|
|
110
129
|
"./auto_sizer": "./src/auto_sizer.tsx",
|
|
111
130
|
"./animation_horizontal_slide": "./src/animation_horizontal_slide.tsx",
|
|
112
131
|
"./group_avatar": "./src/group_avatar.tsx",
|
|
@@ -158,7 +177,7 @@
|
|
|
158
177
|
"./grid/data_grid_context": "./src/grid/data_grid_context.ts",
|
|
159
178
|
"./grid/search_highlight": "./src/grid/search_highlight.ts",
|
|
160
179
|
"./column_filter": "./src/column_filter.tsx",
|
|
161
|
-
"./
|
|
180
|
+
"./chip_group": "./src/chip_group.tsx"
|
|
162
181
|
},
|
|
163
182
|
"files": [
|
|
164
183
|
"src"
|
package/src/accordion.tsx
CHANGED
|
@@ -1,56 +1,63 @@
|
|
|
1
|
-
import { ReactNode, useState } from "react";
|
|
1
|
+
import { Children, createContext, isValidElement, ReactNode, useContext, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
3
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
-
import { Icon } from "./icon";
|
|
5
|
+
import { Icon, type IconName } from "./icon";
|
|
6
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
7
|
+
import { Text } from "./text";
|
|
8
|
+
|
|
9
|
+
interface AccordionContextValue {
|
|
10
|
+
expanded: boolean;
|
|
11
|
+
toggle: () => void;
|
|
12
|
+
/** Whether this Accordion has an `AccordionContent` child — without one
|
|
13
|
+
* the header renders as a plain (non-pressable, chevron-less) row. */
|
|
14
|
+
hasContent: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
function useAccordionContext() {
|
|
20
|
+
const context = useContext(AccordionContext);
|
|
21
|
+
if (!context) {
|
|
22
|
+
throw new Error("Accordion components must be used within an Accordion");
|
|
23
|
+
}
|
|
24
|
+
return context;
|
|
25
|
+
}
|
|
7
26
|
|
|
8
27
|
export interface AccordionProps {
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
28
|
+
/** `AccordionHeader` (+ optionally `AccordionContent`). Content is
|
|
29
|
+
* optional — a header-only Accordion is a plain row, letting a list mix
|
|
30
|
+
* expandable and static rows with identical rhythm. */
|
|
12
31
|
children: ReactNode;
|
|
13
|
-
/** Rendered after the chevron, OUTSIDE the toggle — e.g. an action button
|
|
14
|
-
* that must stay clickable without expanding the panel. */
|
|
15
|
-
headerRight?: ReactNode;
|
|
16
32
|
/** Uncontrolled initial state. Ignored when `expanded` is provided. */
|
|
17
33
|
defaultExpanded?: boolean;
|
|
18
34
|
/** Controlled open state — provide together with `onToggle`. */
|
|
19
35
|
expanded?: boolean;
|
|
20
36
|
onToggle?: (expanded: boolean) => void;
|
|
21
|
-
/** Container style, always applied. */
|
|
22
37
|
style?: StyleProp<ViewStyle>;
|
|
23
|
-
/** Merged onto the container while open — e.g. an accent border. */
|
|
24
|
-
expandedStyle?: StyleProp<ViewStyle>;
|
|
25
|
-
/** Merged onto the clickable trigger (after the comfortable-tap-target
|
|
26
|
-
* defaults). Use to bleed the hover highlight full-width while keeping the
|
|
27
|
-
* header label aligned with surrounding content — e.g.
|
|
28
|
-
* `{ marginHorizontal: -12, paddingHorizontal: 12 }` inside a 12px-padded
|
|
29
|
-
* container — or to widen the tap target. */
|
|
30
|
-
triggerStyle?: StyleProp<ViewStyle>;
|
|
31
|
-
accessibilityLabel?: string;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
|
-
* A single expand/collapse disclosure
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
41
|
+
* A single expand/collapse disclosure, composable like the Card/Dialog
|
|
42
|
+
* families:
|
|
43
|
+
*
|
|
44
|
+
* <Accordion expanded={open} onToggle={...}>
|
|
45
|
+
* <AccordionHeader>
|
|
46
|
+
* <AccordionTitle icon="circle-alert" iconColor={colors.red[500]}>
|
|
47
|
+
* Công nợ quá hạn trên 30 ngày
|
|
48
|
+
* </AccordionTitle>
|
|
49
|
+
* <AccordionMeta>286.500.000 ₫</AccordionMeta>
|
|
50
|
+
* <Badge label="6" color="red" />
|
|
51
|
+
* </AccordionHeader>
|
|
52
|
+
* <AccordionContent>…the records behind the number…</AccordionContent>
|
|
53
|
+
* </Accordion>
|
|
54
|
+
*
|
|
55
|
+
* The chevron and press handling come from `AccordionHeader` automatically
|
|
56
|
+
* when content exists. Controlled via `expanded`/`onToggle`, or uncontrolled
|
|
57
|
+
* via `defaultExpanded`. Compose several (with Dividers) to build a list.
|
|
40
58
|
*/
|
|
41
59
|
export function Accordion(props: AccordionProps) {
|
|
42
|
-
const {
|
|
43
|
-
header,
|
|
44
|
-
children,
|
|
45
|
-
headerRight,
|
|
46
|
-
defaultExpanded = false,
|
|
47
|
-
expanded: controlled,
|
|
48
|
-
onToggle,
|
|
49
|
-
style,
|
|
50
|
-
expandedStyle,
|
|
51
|
-
triggerStyle,
|
|
52
|
-
accessibilityLabel,
|
|
53
|
-
} = props;
|
|
60
|
+
const { children, defaultExpanded = false, expanded: controlled, onToggle, style } = props;
|
|
54
61
|
const [internal, setInternal] = useState(defaultExpanded);
|
|
55
62
|
const expanded = controlled ?? internal;
|
|
56
63
|
|
|
@@ -60,51 +67,127 @@ export function Accordion(props: AccordionProps) {
|
|
|
60
67
|
onToggle?.(next);
|
|
61
68
|
};
|
|
62
69
|
|
|
70
|
+
const hasContent = Children.toArray(children).some((c) => isValidElement(c) && c.type === AccordionContent);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<AccordionContext.Provider value={{ expanded, toggle, hasContent }}>
|
|
74
|
+
<View style={style}>{children}</View>
|
|
75
|
+
</AccordionContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AccordionHeaderProps {
|
|
80
|
+
/** Usually `AccordionTitle` (+ optionally `AccordionMeta`, a Badge, or any
|
|
81
|
+
* right-side node). The band is a layout slot. */
|
|
82
|
+
children: ReactNode;
|
|
83
|
+
accessibilityLabel?: string;
|
|
84
|
+
style?: StyleProp<ViewStyle>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The always-visible row. Pressable with an auto chevron (down/up) when the
|
|
89
|
+
* Accordion has `AccordionContent`; a plain row with identical rhythm when
|
|
90
|
+
* it doesn't.
|
|
91
|
+
*/
|
|
92
|
+
export function AccordionHeader(props: AccordionHeaderProps) {
|
|
93
|
+
const { children, accessibilityLabel, style } = props;
|
|
94
|
+
const { expanded, toggle, hasContent } = useAccordionContext();
|
|
95
|
+
|
|
96
|
+
if (!hasContent) {
|
|
97
|
+
return <View style={[styles.headerRow, style]}>{children}</View>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<PressableHighlight
|
|
102
|
+
onPress={toggle}
|
|
103
|
+
accessibilityRole="button"
|
|
104
|
+
accessibilityState={{ expanded }}
|
|
105
|
+
accessibilityLabel={accessibilityLabel}
|
|
106
|
+
style={[styles.headerRow, styles.pressable, style]}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
<Icon name={expanded ? "chevron-up" : "chevron-down"} size={16} color={colors.zinc[400]} />
|
|
110
|
+
</PressableHighlight>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface AccordionTitleProps {
|
|
115
|
+
children: ReactNode;
|
|
116
|
+
/** Leading glyph — the row's category/severity marker. */
|
|
117
|
+
icon?: IconName;
|
|
118
|
+
/** Full-strength accent for the icon (e.g. colors.red[500]). */
|
|
119
|
+
iconColor?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Title slot for AccordionHeader — optional leading icon + sm label. Grows
|
|
123
|
+
* to push siblings (meta, badges) to the right edge. */
|
|
124
|
+
export function AccordionTitle(props: AccordionTitleProps) {
|
|
63
125
|
return (
|
|
64
|
-
<View style={
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
accessibilityState={{ expanded }}
|
|
70
|
-
accessibilityLabel={accessibilityLabel}
|
|
71
|
-
style={[styles.trigger, triggerStyle]}
|
|
72
|
-
>
|
|
73
|
-
<View style={styles.headerContent}>{header}</View>
|
|
74
|
-
<Icon name={expanded ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
|
|
75
|
-
</PressableHighlight>
|
|
76
|
-
{headerRight}
|
|
77
|
-
</View>
|
|
78
|
-
{expanded ? <AnimationFadeIn style={styles.body}>{children}</AnimationFadeIn> : null}
|
|
126
|
+
<View style={styles.title}>
|
|
127
|
+
{props.icon ? <Icon name={props.icon} size={16} color={props.iconColor ?? colors.zinc[500]} /> : null}
|
|
128
|
+
<Text size="sm" numberOfLines={1} style={styles.titleText}>
|
|
129
|
+
{props.children}
|
|
130
|
+
</Text>
|
|
79
131
|
</View>
|
|
80
132
|
);
|
|
81
133
|
}
|
|
82
134
|
|
|
135
|
+
export interface AccordionMetaProps {
|
|
136
|
+
children: ReactNode;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Right-side context for AccordionHeader — an amount, date, or short hint.
|
|
140
|
+
* xs muted, tabular for numerals. */
|
|
141
|
+
export function AccordionMeta(props: AccordionMetaProps) {
|
|
142
|
+
return (
|
|
143
|
+
<Text size="xs" color="muted" tabular>
|
|
144
|
+
{props.children}
|
|
145
|
+
</Text>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface AccordionContentProps {
|
|
150
|
+
/** Anything — record rows, key-value pairs, free text, actions. */
|
|
151
|
+
children: ReactNode;
|
|
152
|
+
style?: StyleProp<ViewStyle>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The collapsible body, revealed in place under the header. Plain by
|
|
156
|
+
* design — content indents to align under the title, no tinted well; the
|
|
157
|
+
* chevron + position already say "inside". Compose rows/dividers/actions
|
|
158
|
+
* directly. */
|
|
159
|
+
export function AccordionContent(props: AccordionContentProps) {
|
|
160
|
+
const { expanded } = useAccordionContext();
|
|
161
|
+
if (!expanded) return null;
|
|
162
|
+
return <AnimationFadeIn style={[styles.content, props.style]}>{props.children}</AnimationFadeIn>;
|
|
163
|
+
}
|
|
164
|
+
|
|
83
165
|
const styles = StyleSheet.create({
|
|
84
|
-
container: {
|
|
85
|
-
borderRadius: 12,
|
|
86
|
-
},
|
|
87
166
|
headerRow: {
|
|
88
167
|
flexDirection: "row",
|
|
89
168
|
alignItems: "center",
|
|
90
169
|
gap: 12,
|
|
170
|
+
paddingVertical: 12,
|
|
171
|
+
// ≥40px press target; the height doubles as the list's row rhythm.
|
|
172
|
+
minHeight: 44,
|
|
91
173
|
},
|
|
92
|
-
|
|
174
|
+
pressable: {
|
|
175
|
+
borderRadius: 8,
|
|
176
|
+
paddingHorizontal: 8,
|
|
177
|
+
marginHorizontal: -8,
|
|
178
|
+
},
|
|
179
|
+
title: {
|
|
93
180
|
flex: 1,
|
|
94
181
|
flexDirection: "row",
|
|
95
182
|
alignItems: "center",
|
|
96
183
|
gap: 12,
|
|
97
|
-
// A bare row is a too-small tap target and gives PressableHighlight's
|
|
98
|
-
// hover/press tint no room to read as a button. Pad vertically for a
|
|
99
|
-
// comfortable target and round the corners so the tint is a pill.
|
|
100
|
-
paddingVertical: 8,
|
|
101
|
-
borderRadius: 8,
|
|
102
184
|
},
|
|
103
|
-
|
|
185
|
+
titleText: {
|
|
104
186
|
flex: 1,
|
|
105
187
|
},
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
188
|
+
// Aligns under the title text (16px icon + 12px gap = 28).
|
|
189
|
+
content: {
|
|
190
|
+
paddingLeft: 28,
|
|
191
|
+
paddingBottom: 10,
|
|
109
192
|
},
|
|
110
193
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { IconButton } from "./icon_button";
|
|
4
|
+
import { MenuButton } from "./menu_button";
|
|
5
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
6
|
+
import type { PopoverAlign, PopoverSide } from "./popover";
|
|
7
|
+
import type { IconName } from "./icon";
|
|
8
|
+
|
|
9
|
+
export interface ActionMenuItem {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
icon?: IconName;
|
|
13
|
+
/** Destructive styling — still confirm destructive actions per the
|
|
14
|
+
* Action Layout rules before executing. */
|
|
15
|
+
danger?: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
onPress: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ActionMenuProps {
|
|
21
|
+
/** The row's actions, in priority order. Destructive items last. */
|
|
22
|
+
items: ActionMenuItem[];
|
|
23
|
+
/** Announced name for the ⋯ trigger ("Hành động cho SR-2026-0081"). */
|
|
24
|
+
accessibilityLabel: string;
|
|
25
|
+
side?: PopoverSide;
|
|
26
|
+
align?: PopoverAlign;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The ⋯ overflow menu for a list row — the MINIMUM door a row may have.
|
|
31
|
+
* Every listed record must be actionable somehow: primary press → workspace
|
|
32
|
+
* Drawer; read-only drill → Accordion/Peek; everything else → this menu.
|
|
33
|
+
* A row with none of those is furniture.
|
|
34
|
+
*/
|
|
35
|
+
export function ActionMenu(props: ActionMenuProps) {
|
|
36
|
+
const { items, accessibilityLabel, side = "bottom", align = "end" } = props;
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Popover open={open} onOpenChange={setOpen} side={side} align={align}>
|
|
41
|
+
<PopoverTrigger>
|
|
42
|
+
<IconButton icon="ellipsis" accessibilityLabel={accessibilityLabel} />
|
|
43
|
+
</PopoverTrigger>
|
|
44
|
+
<PopoverContent style={styles.content} disableBodyScroll>
|
|
45
|
+
<View style={styles.list}>
|
|
46
|
+
{items.map((item) => (
|
|
47
|
+
<MenuButton
|
|
48
|
+
key={item.key}
|
|
49
|
+
icon={item.icon}
|
|
50
|
+
title={item.label}
|
|
51
|
+
danger={item.danger}
|
|
52
|
+
disabled={item.disabled}
|
|
53
|
+
onPress={() => {
|
|
54
|
+
setOpen(false);
|
|
55
|
+
item.onPress();
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</View>
|
|
60
|
+
</PopoverContent>
|
|
61
|
+
</Popover>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const styles = StyleSheet.create({
|
|
66
|
+
content: {
|
|
67
|
+
minWidth: 200,
|
|
68
|
+
},
|
|
69
|
+
list: {
|
|
70
|
+
gap: 2,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { NumberInput } from "./number_input";
|
|
6
|
+
|
|
7
|
+
export interface AllocationRowProps {
|
|
8
|
+
/** The target's primary line — an invoice no., an order, a cost centre. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Secondary muted line — an issue date, a reference. */
|
|
11
|
+
sublabel?: string;
|
|
12
|
+
/** The most that can be allocated here — the target's outstanding amount. The
|
|
13
|
+
* input clamps to [0, cap]. */
|
|
14
|
+
cap: number;
|
|
15
|
+
value: number;
|
|
16
|
+
onValueChange: (n: number) => void;
|
|
17
|
+
/** Format the cap display (the raw input shows numbers). */
|
|
18
|
+
format?: (n: number) => string;
|
|
19
|
+
/** Right-of-label node — an age `Badge`, a status. */
|
|
20
|
+
trailing?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One target in an allocation — its label, the cap it can absorb, and a bounded
|
|
25
|
+
* input for how much to put here with an "apply in full" shortcut. The atom of
|
|
26
|
+
* cash application / stock allocation / cost distribution: a source amount is
|
|
27
|
+
* split across many of these until a `RemainderMeter` reads zero.
|
|
28
|
+
*/
|
|
29
|
+
export function AllocationRow(props: AllocationRowProps) {
|
|
30
|
+
const { label, sublabel, cap, value, onValueChange, format = (n) => n.toLocaleString(), trailing } = props;
|
|
31
|
+
const full = value >= cap;
|
|
32
|
+
return (
|
|
33
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, minHeight: 60 }}>
|
|
34
|
+
<View style={{ flex: 1, gap: 0 }}>
|
|
35
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
36
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{label}</Text>
|
|
37
|
+
{trailing}
|
|
38
|
+
</View>
|
|
39
|
+
{sublabel ? <Text size="xs" color="muted">{sublabel}</Text> : null}
|
|
40
|
+
</View>
|
|
41
|
+
<Text size="sm" color="muted" tabular style={{ width: 128, textAlign: "right" }}>{`${format(cap)} due`}</Text>
|
|
42
|
+
<View style={{ width: 150 }}>
|
|
43
|
+
<NumberInput
|
|
44
|
+
value={value || null}
|
|
45
|
+
onValueChange={(n) => onValueChange(Math.max(0, Math.min(cap, n ?? 0)))}
|
|
46
|
+
min={0}
|
|
47
|
+
max={cap}
|
|
48
|
+
accessibilityLabel={`Allocate to ${label}`}
|
|
49
|
+
/>
|
|
50
|
+
</View>
|
|
51
|
+
<Button title={full ? "Clear" : "Full"} color="muted" onPress={() => onValueChange(full ? 0 : cap)} />
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/badge.tsx
CHANGED
|
@@ -1,38 +1,59 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
|
-
import { colors } from "./colors";
|
|
4
|
+
import { colors, type ColorName } from "./colors";
|
|
5
5
|
import { useTooltip } from "./tooltip";
|
|
6
6
|
|
|
7
|
-
/** Only palette colors with shade scales (exclude border, shadow, etc.) */
|
|
8
|
-
export type BadgeColor = Exclude<keyof typeof colors, "border" | "border_shadow" | "background" | "shadow" | "black" | "white">;
|
|
9
|
-
|
|
10
7
|
interface BadgeProps {
|
|
11
8
|
label?: string;
|
|
12
|
-
color?:
|
|
9
|
+
color?: ColorName;
|
|
10
|
+
/**
|
|
11
|
+
* The status-indicator weight:
|
|
12
|
+
* - "tonal" (default): a filled pill — a record's prominent STATUS field (a
|
|
13
|
+
* register's Status column, a drawer header). For categorical status, not
|
|
14
|
+
* a metric value: a percentage/amount is a number, never a chip.
|
|
15
|
+
* - "dot": pill-less — a colored dot + label, the LIGHT indicator for
|
|
16
|
+
* legends, secondary/inline status, or a metric's quality cue (a
|
|
17
|
+
* healthy-vs-thin margin) where a filled chip would be too heavy.
|
|
18
|
+
* (StatusGrid/StatusLegend keep their own dot — their color is raw hex,
|
|
19
|
+
* coupled to the grid cells' tint, a separate data-viz concern.)
|
|
20
|
+
*/
|
|
21
|
+
variant?: "tonal" | "dot";
|
|
13
22
|
style?: StyleProp<ViewStyle>;
|
|
14
23
|
tooltip?: string;
|
|
15
24
|
userSelect?: "none" | "auto";
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export function Badge(props: BadgeProps) {
|
|
19
|
-
const { label, color, style, tooltip, userSelect = "none" } = props;
|
|
28
|
+
const { label, color, variant = "tonal", style, tooltip, userSelect = "none" } = props;
|
|
20
29
|
const tooltipProps = useTooltip(tooltip);
|
|
30
|
+
const hue = color && colors[color] ? colors[color] : null;
|
|
31
|
+
|
|
32
|
+
if (variant === "dot") {
|
|
33
|
+
return (
|
|
34
|
+
<View style={[styles.dotRow, style]} {...tooltipProps}>
|
|
35
|
+
<View style={[styles.dot, { backgroundColor: hue ? hue[500] : colors.zinc[400] }]} />
|
|
36
|
+
<Text size="sm" numberOfLines={1} userSelect={userSelect} weight="medium">
|
|
37
|
+
{label}
|
|
38
|
+
</Text>
|
|
39
|
+
</View>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
21
42
|
|
|
22
43
|
return (
|
|
23
44
|
<View
|
|
24
45
|
style={[
|
|
25
46
|
styles.base,
|
|
26
47
|
{
|
|
27
|
-
backgroundColor:
|
|
28
|
-
borderColor:
|
|
48
|
+
backgroundColor: hue ? hue[50] : colors.zinc[100],
|
|
49
|
+
borderColor: hue ? hue[100] : colors.zinc[200],
|
|
29
50
|
borderWidth: 1,
|
|
30
51
|
},
|
|
31
52
|
style,
|
|
32
53
|
]}
|
|
33
54
|
{...tooltipProps}
|
|
34
55
|
>
|
|
35
|
-
<Text numberOfLines={1} userSelect={userSelect} weight="medium" style={{ color:
|
|
56
|
+
<Text numberOfLines={1} userSelect={userSelect} weight="medium" style={{ color: hue ? hue[900] : undefined }}>
|
|
36
57
|
{label}
|
|
37
58
|
</Text>
|
|
38
59
|
</View>
|
|
@@ -48,4 +69,14 @@ const styles = StyleSheet.create({
|
|
|
48
69
|
alignItems: "center",
|
|
49
70
|
gap: 4,
|
|
50
71
|
},
|
|
72
|
+
dotRow: {
|
|
73
|
+
flexDirection: "row",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
gap: 6,
|
|
76
|
+
},
|
|
77
|
+
dot: {
|
|
78
|
+
width: 6,
|
|
79
|
+
height: 6,
|
|
80
|
+
borderRadius: 999,
|
|
81
|
+
},
|
|
51
82
|
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, withAlpha } from "./colors";
|
|
3
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
4
|
+
import { StackedProgressBar } from "./stacked_progress_bar";
|
|
5
|
+
import { Text } from "./text";
|
|
6
|
+
|
|
7
|
+
export interface BreakdownItem {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
value: number;
|
|
11
|
+
/** Segment accent — one hue family per dimension reads best. */
|
|
12
|
+
color: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BreakdownProps {
|
|
16
|
+
items: BreakdownItem[];
|
|
17
|
+
/** The drilled-in segment. While set, the other segments dim. */
|
|
18
|
+
selectedKey?: string | null;
|
|
19
|
+
/** Press a segment to drill into it (press again to clear). Omit to
|
|
20
|
+
* render an informational breakdown. */
|
|
21
|
+
onSelect?: (key: string | null) => void;
|
|
22
|
+
formatValue?: (n: number) => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultFormat = (n: number): string => n.toLocaleString("en-US");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Composition browser for LARGE populations — the answer to "what is my
|
|
29
|
+
* stock made of, and let me drill into a slice". A stacked bar shows the
|
|
30
|
+
* shares; below it, one pressable row per segment (swatch · label · count ·
|
|
31
|
+
* share). Selecting a segment dims the rest and (in the host) filters the
|
|
32
|
+
* register underneath — the facet pattern for 10,000-record monitoring.
|
|
33
|
+
* Several Breakdowns side by side (by type, by status, by site) compose a
|
|
34
|
+
* full faceted overview; selections combine in the host's filter state.
|
|
35
|
+
*/
|
|
36
|
+
export function Breakdown(props: BreakdownProps) {
|
|
37
|
+
const { items, selectedKey = null, onSelect, formatValue = defaultFormat } = props;
|
|
38
|
+
const total = items.reduce((sum, item) => sum + item.value, 0);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<View style={styles.container}>
|
|
42
|
+
<StackedProgressBar
|
|
43
|
+
height={10}
|
|
44
|
+
total={total}
|
|
45
|
+
segments={items.map((item) => ({
|
|
46
|
+
key: item.key,
|
|
47
|
+
value: item.value,
|
|
48
|
+
color: selectedKey && selectedKey !== item.key ? withAlpha(item.color, 0.25) : item.color,
|
|
49
|
+
}))}
|
|
50
|
+
/>
|
|
51
|
+
<View>
|
|
52
|
+
{items.map((item) => {
|
|
53
|
+
const share = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
|
54
|
+
const selected = selectedKey === item.key;
|
|
55
|
+
const dimmed = selectedKey !== null && !selected;
|
|
56
|
+
const row = (
|
|
57
|
+
<>
|
|
58
|
+
<View style={[styles.swatch, { backgroundColor: dimmed ? withAlpha(item.color, 0.3) : item.color }]} />
|
|
59
|
+
<Text size="sm" color={dimmed ? "muted" : "default"} numberOfLines={1} style={styles.label}>
|
|
60
|
+
{item.label}
|
|
61
|
+
</Text>
|
|
62
|
+
<Text size="sm" weight={selected ? "semibold" : "regular"} color={dimmed ? "muted" : "default"} tabular>
|
|
63
|
+
{formatValue(item.value)}
|
|
64
|
+
</Text>
|
|
65
|
+
<Text size="xs" color="muted" tabular align="right" style={styles.share}>
|
|
66
|
+
{share}%
|
|
67
|
+
</Text>
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
return onSelect ? (
|
|
71
|
+
<PressableHighlight
|
|
72
|
+
key={item.key}
|
|
73
|
+
accessibilityRole="button"
|
|
74
|
+
accessibilityState={{ selected }}
|
|
75
|
+
accessibilityLabel={`${item.label}: ${formatValue(item.value)}`}
|
|
76
|
+
onPress={() => onSelect(selected ? null : item.key)}
|
|
77
|
+
style={[styles.row, styles.pressable, selected ? styles.selected : null]}
|
|
78
|
+
>
|
|
79
|
+
{row}
|
|
80
|
+
</PressableHighlight>
|
|
81
|
+
) : (
|
|
82
|
+
<View key={item.key} style={styles.row}>
|
|
83
|
+
{row}
|
|
84
|
+
</View>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</View>
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const styles = StyleSheet.create({
|
|
93
|
+
container: {
|
|
94
|
+
gap: 12,
|
|
95
|
+
},
|
|
96
|
+
row: {
|
|
97
|
+
flexDirection: "row",
|
|
98
|
+
alignItems: "center",
|
|
99
|
+
gap: 10,
|
|
100
|
+
minHeight: 36,
|
|
101
|
+
},
|
|
102
|
+
pressable: {
|
|
103
|
+
borderRadius: 8,
|
|
104
|
+
paddingHorizontal: 8,
|
|
105
|
+
marginHorizontal: -8,
|
|
106
|
+
},
|
|
107
|
+
selected: {
|
|
108
|
+
backgroundColor: colors.zinc[100],
|
|
109
|
+
},
|
|
110
|
+
swatch: {
|
|
111
|
+
width: 8,
|
|
112
|
+
height: 8,
|
|
113
|
+
borderRadius: 999,
|
|
114
|
+
},
|
|
115
|
+
label: {
|
|
116
|
+
flex: 1,
|
|
117
|
+
},
|
|
118
|
+
share: {
|
|
119
|
+
width: 36,
|
|
120
|
+
},
|
|
121
|
+
});
|