@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/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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ImageContentFit, ImageSource } from "expo-image";
|
|
2
|
+
import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
|
|
6
|
+
interface AvatarProps {
|
|
7
|
+
size?: number;
|
|
8
|
+
source?: ImageSource;
|
|
9
|
+
name?: string;
|
|
10
|
+
style?: StyleProp<ViewStyle | ImageStyle>;
|
|
11
|
+
contentFit?: ImageContentFit;
|
|
12
|
+
/**
|
|
13
|
+
* When true, the avatar announces its `name` to assistive tech. Default
|
|
14
|
+
* false because avatars almost always appear adjacent to the name text —
|
|
15
|
+
* announcing the image as well would double-read. Pass `announce` when the
|
|
16
|
+
* avatar is standalone (with no visible name nearby).
|
|
17
|
+
*/
|
|
18
|
+
announce?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Web Avatar. The native `avatar.tsx` renders through `expo-image`, a native
|
|
23
|
+
* module that pulls expo's runtime into the graph and fails to load under
|
|
24
|
+
* pure-web bundlers (Vite dev throws on its CJS interop; vitest can't resolve
|
|
25
|
+
* expo's winter runtime). On web, `react-native`'s `Image` (→ react-native-web
|
|
26
|
+
* → `<img>`) renders the same circular avatar with none of that cost. The prop
|
|
27
|
+
* surface is identical — `ImageSource`/`ImageContentFit` are kept as erased
|
|
28
|
+
* type-only imports so no expo-image module is ever loaded.
|
|
29
|
+
*/
|
|
30
|
+
export function Avatar(props: AvatarProps) {
|
|
31
|
+
const { source, size = 32, name = "Unknown", style, contentFit, announce } = props;
|
|
32
|
+
const decorative = !announce;
|
|
33
|
+
|
|
34
|
+
if (!source || !source.uri) {
|
|
35
|
+
return (
|
|
36
|
+
<View
|
|
37
|
+
accessible={!decorative}
|
|
38
|
+
accessibilityLabel={decorative ? undefined : name}
|
|
39
|
+
accessibilityElementsHidden={decorative}
|
|
40
|
+
importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
|
|
41
|
+
aria-hidden={decorative || undefined}
|
|
42
|
+
style={[
|
|
43
|
+
styles.base,
|
|
44
|
+
{ backgroundColor: colors.blue["600"], width: size, height: size },
|
|
45
|
+
style,
|
|
46
|
+
]}
|
|
47
|
+
>
|
|
48
|
+
{/* Initials are a visual shorthand for the name; the accessible name is
|
|
49
|
+
on the container so the SR does not read "HM" in addition. */}
|
|
50
|
+
<Text
|
|
51
|
+
userSelect="none"
|
|
52
|
+
size="xs"
|
|
53
|
+
weight="medium"
|
|
54
|
+
color="inverted"
|
|
55
|
+
accessibilityElementsHidden
|
|
56
|
+
importantForAccessibility="no-hide-descendants"
|
|
57
|
+
aria-hidden
|
|
58
|
+
>
|
|
59
|
+
{getInitials(name, size)}
|
|
60
|
+
</Text>
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Image
|
|
67
|
+
accessibilityLabel={decorative ? undefined : name}
|
|
68
|
+
accessibilityElementsHidden={decorative}
|
|
69
|
+
importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
|
|
70
|
+
style={[styles.base, { width: size, height: size }, style as ImageStyle]}
|
|
71
|
+
source={{ uri: source.uri }}
|
|
72
|
+
resizeMode={contentFit === "contain" ? "contain" : "cover"}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getInitials(name: string, size?: number): string {
|
|
78
|
+
let initials = 2;
|
|
79
|
+
|
|
80
|
+
if (size && size <= 32) {
|
|
81
|
+
initials = 1;
|
|
82
|
+
|
|
83
|
+
if (name.length <= 2) {
|
|
84
|
+
return name;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return name
|
|
89
|
+
.split(" ")
|
|
90
|
+
.map((c) => c.charAt(0).toUpperCase())
|
|
91
|
+
.slice(0, initials)
|
|
92
|
+
.join("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const styles = StyleSheet.create({
|
|
96
|
+
base: {
|
|
97
|
+
borderRadius: 999,
|
|
98
|
+
justifyContent: "center",
|
|
99
|
+
alignItems: "center",
|
|
100
|
+
userSelect: "none",
|
|
101
|
+
},
|
|
102
|
+
});
|
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
|
});
|