@lotics/ui 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +27 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/avatar.web.tsx +102 -0
  6. package/src/badge.tsx +40 -9
  7. package/src/breakdown.tsx +121 -0
  8. package/src/card.tsx +150 -0
  9. package/src/cell_select.tsx +3 -2
  10. package/src/chip_group.tsx +65 -0
  11. package/src/colors.ts +61 -0
  12. package/src/column_filter.tsx +9 -24
  13. package/src/completion_state.tsx +43 -0
  14. package/src/control_surface.ts +32 -0
  15. package/src/counter.tsx +58 -0
  16. package/src/date_range_filter_field.tsx +44 -12
  17. package/src/detail_row.tsx +45 -0
  18. package/src/dialog.tsx +0 -24
  19. package/src/download.ts +2 -1
  20. package/src/drawer.tsx +94 -2
  21. package/src/empty_state.tsx +37 -0
  22. package/src/file_badge.tsx +27 -4
  23. package/src/file_dropzone.tsx +188 -0
  24. package/src/file_picker.ts +45 -0
  25. package/src/filter_pill.tsx +106 -0
  26. package/src/floating_action_bar.tsx +57 -0
  27. package/src/fonts.css +10 -13
  28. package/src/format_money.ts +38 -0
  29. package/src/heatmap.tsx +153 -0
  30. package/src/icon.tsx +2 -0
  31. package/src/icon_button.tsx +16 -2
  32. package/src/index.css +4 -3
  33. package/src/info_popover.tsx +4 -6
  34. package/src/kpi_card.tsx +19 -6
  35. package/src/kpi_strip.tsx +89 -0
  36. package/src/line_chart.tsx +61 -34
  37. package/src/link_button.tsx +50 -0
  38. package/src/metric.tsx +21 -12
  39. package/src/pagination.tsx +5 -9
  40. package/src/peek.tsx +68 -0
  41. package/src/picker.tsx +13 -1
  42. package/src/picker_menu.tsx +8 -16
  43. package/src/pie_chart.tsx +29 -8
  44. package/src/pill_button.tsx +10 -8
  45. package/src/popover.tsx +14 -4
  46. package/src/pressable_highlight.tsx +10 -1
  47. package/src/pressable_row.tsx +91 -0
  48. package/src/progress_bar.tsx +47 -17
  49. package/src/radio_picker.tsx +20 -9
  50. package/src/range_slider.tsx +185 -0
  51. package/src/remainder_meter.tsx +48 -0
  52. package/src/ring_gauge.tsx +5 -5
  53. package/src/scan_field.tsx +58 -0
  54. package/src/search_input.tsx +12 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. package/src/table_types.ts +0 -47
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "2.4.0",
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
- "./table_picker": "./src/table_picker.tsx"
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
- /** Content of the clickable header (the main area); a chevron is appended. */
10
- header: ReactNode;
11
- /** Collapsible body, revealed when expanded. */
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. The header is a caller-supplied node
36
- * (so it can be as rich as a row of metrics), the chevron is added
37
- * automatically, and `headerRight` holds an action that stays clickable
38
- * without toggling. Controlled via `expanded`/`onToggle`, or uncontrolled via
39
- * `defaultExpanded`. Compose several to build an accordion list.
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={[styles.container, style, expanded ? expandedStyle : null]}>
65
- <View style={styles.headerRow}>
66
- <PressableHighlight
67
- onPress={toggle}
68
- accessibilityRole="button"
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
- trigger: {
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
- headerContent: {
185
+ titleText: {
104
186
  flex: 1,
105
187
  },
106
- body: {
107
- // The trigger's own bottom padding contributes ~8 of the header→body gap.
108
- marginTop: 4,
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?: BadgeColor;
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: color && colors[color] ? colors[color][50] : colors.zinc[100],
28
- borderColor: color && colors[color] ? colors[color][100] : colors.zinc[200],
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: color && colors[color] ? colors[color][900] : undefined }}>
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
  });