@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
@@ -0,0 +1,145 @@
1
+ import { View, type ViewStyle } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Text } from "./text";
4
+ import { useTooltip } from "./tooltip";
5
+
6
+ export interface StepProgressProps {
7
+ /** The stages: pass the NAMES (enables the built-in "4/7 · In" caption
8
+ * and per-segment hover names) or a bare count (bar only). */
9
+ steps: number | string[];
10
+ /** 0-based index of the stage in progress; earlier segments render
11
+ * complete. Pass -1 for "not started", `steps.length` for "complete". */
12
+ current: number;
13
+ /** Accent for the completed/current segments. Defaults to the neutral
14
+ * ink — pass a brand color to theme it. */
15
+ color?: string;
16
+ /** What this progress measures ("Production stages") — adds the xs muted
17
+ * uppercase eyebrow row above the bar, caption right. Omit at card
18
+ * density where the context already names it. */
19
+ title?: string;
20
+ /** Caption override for when the current stage needs prose ("In production") instead of the derived "3/6 · SX". */
21
+ label?: string;
22
+ /** Tone for the caption — `danger` flags a stalled/overdue stage (a stuck
23
+ * production order) in the FIXED caption spot, so the bar stays consistent
24
+ * instead of a floating badge shifting it. */
25
+ captionTone?: "default" | "danger";
26
+ /** Put the caption on its OWN line below a full-width bar (vs the default
27
+ * inline-right, which lets a varying caption shrink the bar). The compact,
28
+ * consistent card/drawer layout — no eyebrow, the bar never shifts. */
29
+ captionBelow?: boolean;
30
+ /** Segment height. Default 10 — matches ProgressBar's track; thinner reads
31
+ * weak. */
32
+ height?: number;
33
+ accessibilityLabel?: string;
34
+ }
35
+
36
+ /**
37
+ * THE compact stage indicator — N equal segments filled through the current
38
+ * stage, with a built-in "4/7 · In" caption when stages are named (hovering
39
+ * a segment names it). For *countable* stages an item walks through (a
40
+ * production line, a checklist, a pipeline) shown at card/list density.
41
+ *
42
+ * Picking a bar: continuous value-vs-max → `ProgressBar`; weighted
43
+ * composition/funnel → `StackedProgressBar`; a full-width wizard header
44
+ * where every milestone label must be visible → `Stepper`; everything
45
+ * stage-countable at card density → this.
46
+ */
47
+ export function StepProgress(props: StepProgressProps) {
48
+ const { steps, current, color = colors.zinc[900], title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
49
+ const captionColor = captionTone === "danger" ? "danger" : "muted";
50
+ const names = typeof steps === "number" ? null : steps;
51
+ const count = Math.max(1, typeof steps === "number" ? steps : steps.length);
52
+ const isComplete = current >= count;
53
+ const safe = Math.min(current, count - 1);
54
+ const caption =
55
+ label ??
56
+ (names
57
+ ? isComplete
58
+ ? `${count}/${count} · Complete`
59
+ : `${Math.max(0, safe + 1)}/${count}${safe >= 0 ? ` · ${names[safe]}` : ""}`
60
+ : undefined);
61
+
62
+ const bar = (
63
+ <View
64
+ accessibilityRole="progressbar"
65
+ accessibilityLabel={accessibilityLabel ?? caption ?? `${Math.max(0, safe + 1)} of ${count}`}
66
+ style={{ flexDirection: "row", gap: 3, flex: 1 }}
67
+ >
68
+ {Array.from({ length: count }, (_, i) => (
69
+ <Segment
70
+ key={i}
71
+ name={names?.[i]}
72
+ color={color}
73
+ height={height}
74
+ // Complete settles ALL segments solid — "finished" reads as
75
+ // accomplished, not faded.
76
+ done={i < safe}
77
+ isCurrent={isComplete || (i === safe && safe >= 0)}
78
+ />
79
+ ))}
80
+ </View>
81
+ );
82
+
83
+ if (title) {
84
+ return (
85
+ <View style={{ gap: 6, flex: 1 }}>
86
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
87
+ <Text size="xs" color="muted" transform="uppercase">
88
+ {title}
89
+ </Text>
90
+ <View style={{ flex: 1 }} />
91
+ {caption ? (
92
+ <Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
93
+ {caption}
94
+ </Text>
95
+ ) : null}
96
+ </View>
97
+ {bar}
98
+ </View>
99
+ );
100
+ }
101
+
102
+ if (!caption) return bar;
103
+ if (captionBelow) {
104
+ return (
105
+ <View style={{ gap: 6, flex: 1 }}>
106
+ {bar}
107
+ <Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
108
+ {caption}
109
+ </Text>
110
+ </View>
111
+ );
112
+ }
113
+ return (
114
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
115
+ {bar}
116
+ <Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
117
+ {caption}
118
+ </Text>
119
+ </View>
120
+ );
121
+ }
122
+
123
+ function Segment(props: { name?: string; color: string; height: number; done: boolean; isCurrent: boolean }) {
124
+ const { name, color, height, done, isCurrent } = props;
125
+ // Hovering a segment names its stage — the bar stays self-explanatory
126
+ // even where the caption is elided. No-ops without a TooltipProvider.
127
+ const tooltip = useTooltip(name);
128
+ return (
129
+ <View
130
+ {...tooltip}
131
+ style={{
132
+ flex: 1,
133
+ height,
134
+ borderRadius: height / 2,
135
+ backgroundColor: done || isCurrent ? color : colors.zinc[100],
136
+ opacity: done && !isCurrent ? 0.4 : 1,
137
+ ...(done || isCurrent
138
+ ? null
139
+ : ({ boxShadow: "inset 0 0 0 1px rgba(38,38,38,0.04)" } as ViewStyle)),
140
+ // Progress that snaps is dead; progress that moves is alive.
141
+ ...({ transition: "background-color 200ms ease-out, opacity 200ms ease-out" } as ViewStyle),
142
+ }}
143
+ />
144
+ );
145
+ }
package/src/stepper.tsx CHANGED
@@ -23,18 +23,23 @@ function statusOf(index: number, current: number): StepStatus {
23
23
  }
24
24
 
25
25
  /**
26
- * Horizontal step / status tracker — a row of milestones with completed,
27
- * current, and upcoming states and a connecting track. For a vertical event
28
- * log use `Timeline` instead.
26
+ * Full-width wizard/milestone header — a row of labeled milestones with
27
+ * completed, current, and upcoming states and a connecting track. Every
28
+ * label is visible; use it where the journey itself is the headline (a
29
+ * record detail, a checkout). At card/list density use `StepProgress`
30
+ * (segments + built-in caption) instead. For a vertical event log use
31
+ * `Timeline`.
29
32
  */
30
33
  export function Stepper(props: StepperProps) {
31
34
  const { steps, current, color = colors.zinc[900], accessibilityLabel } = props;
32
35
  const last = steps.length - 1;
33
36
  const safe = Math.max(0, Math.min(current, last));
37
+ const a11y = accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`;
38
+
34
39
  return (
35
40
  <View
36
41
  accessibilityRole="progressbar"
37
- accessibilityLabel={accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`}
42
+ accessibilityLabel={a11y}
38
43
  style={{ flexDirection: "row" }}
39
44
  >
40
45
  {steps.map((label, i) => {
package/src/table.tsx CHANGED
@@ -1,125 +1,181 @@
1
- import { Fragment, useState } from "react";
2
- import { Pressable, ScrollView, View, StyleSheet } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { Icon } from "@lotics/ui/icon";
5
- import { colors } from "@lotics/ui/colors";
6
- import type { Column, TableProps } from "./table_types";
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ Children,
5
+ cloneElement,
6
+ isValidElement,
7
+ type ReactNode,
8
+ type ReactElement,
9
+ } from "react";
10
+ import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
11
+ import { Text } from "./text";
12
+ import { Divider } from "./divider";
13
+ import { PressableRow } from "./pressable_row";
14
+ import { SortHeader, type SortState } from "./sort_header";
7
15
 
8
- export type { SortDir, TableSort, Column, TableProps } from "./table_types";
9
-
10
- // Native table. RN primitives; a click-to-expand row is a Pressable whose
11
- // `interactive` cells are their own Pressables — RN's responder system grants the
12
- // press to the innermost responder, so an action cell never toggles the row (the
13
- // web DOM-nesting concern doesn't exist here). The detail panel renders below.
16
+ /**
17
+ * One column of a register — its width/flex/align/label/sortability defined ONCE,
18
+ * here, instead of being re-typed in the header band AND every row.
19
+ */
20
+ export interface TableColumn {
21
+ /** Stable id also the `sortKey` when `sortable`. */
22
+ key: string;
23
+ /** Header label (uppercase eyebrow). Omit for a control column (a trailing ⋯). */
24
+ label?: string;
25
+ /** Fixed width in px; omit for a flexible column. */
26
+ width?: number;
27
+ /** Flex grow when no `width` (default 1). */
28
+ flex?: number;
29
+ align?: "left" | "right";
30
+ sortable?: boolean;
31
+ }
14
32
 
15
- const stickyHeader = [0];
16
- const CHEVRON_W = 44;
33
+ interface TableCtx {
34
+ columns: TableColumn[];
35
+ leading: number;
36
+ trailing: number;
37
+ }
38
+ const TableContext = createContext<TableCtx | null>(null);
17
39
 
18
- export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
19
- const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow, onRowPress } = props;
20
- const [internal, setInternal] = useState<Set<string>>(() => new Set());
21
- const expanded = expandedKeys ?? internal;
22
- const expandable = !!renderDetail;
40
+ function colStyle(col: TableColumn): ViewStyle {
41
+ const base: ViewStyle =
42
+ col.width != null ? { width: col.width } : { flex: col.flex ?? 1, minWidth: 0 };
43
+ if (col.align === "right") base.alignItems = "flex-end";
44
+ return base;
45
+ }
23
46
 
24
- const toggle = (key: string, row: TRow) => {
25
- onToggleRow?.(key, row);
26
- if (expandedKeys === undefined) {
27
- setInternal((prev) => {
28
- const next = new Set(prev);
29
- if (next.has(key)) next.delete(key);
30
- else next.add(key);
31
- return next;
32
- });
33
- }
34
- };
47
+ export interface TableProps {
48
+ /** The single source of column layout — shared by the header AND every cell. */
49
+ columns: TableColumn[];
50
+ sort?: SortState | null;
51
+ /** Cycles the sort (none→asc→desc→none) — pair with `cycleSort` in the parent. */
52
+ onSort?: (key: string) => void;
53
+ /** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
54
+ leading?: number;
55
+ /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
56
+ trailing?: number;
57
+ /** The `TableRow`s. */
58
+ children: ReactNode;
59
+ }
35
60
 
36
- const cellWidth = (col: Column<TRow>) => (col.width ? { width: col.width } : styles.flexColumn);
61
+ /**
62
+ * THE columnar register — define `columns` once and the header band + every row's
63
+ * cell widths come from it, so they can't drift (no hand-rolled `W` map, no
64
+ * `<View style={{width}}>` per cell). Renders a full-bleed eyebrow header (a
65
+ * sortable column becomes a `SortHeader`) and its `TableRow` children,
66
+ * `Divider`-separated. Compose `TableRow` / `TableCell` for the body. For a
67
+ * non-columnar list (entity piles, card stacks) use `PressableRow` directly.
68
+ */
69
+ export function Table(props: TableProps) {
70
+ const { columns, sort, onSort, leading = 0, trailing = 0, children } = props;
71
+ const rows = Children.toArray(children).filter(isValidElement);
37
72
 
38
73
  return (
39
- <ScrollView style={styles.container} stickyHeaderIndices={stickyHeader}>
40
- <View style={styles.headerRow}>
41
- {columns.map((col) => {
42
- const sortable = col.sortable && !!onSortChange;
43
- const active = sort?.key === col.key;
44
- const cellStyle = [cellWidth(col), styles.headerCell, col.align === "right" ? styles.alignEnd : null];
45
- const inner = (
46
- <View style={styles.headerInner}>
47
- <Text size="xs" weight="medium" color="muted" transform="uppercase" numberOfLines={1} userSelect="none">
48
- {col.label}
49
- </Text>
50
- {sortable ? (
51
- <Icon
52
- name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
53
- size={14}
54
- color={active ? colors.zinc[700] : colors.zinc[400]}
55
- />
56
- ) : null}
57
- </View>
58
- );
59
- return sortable ? (
60
- <Pressable key={col.key as string} accessibilityRole="button" accessibilityLabel={`Sắp xếp theo ${col.label}`} onPress={() => onSortChange?.(col.key)} style={cellStyle}>
61
- {inner}
62
- </Pressable>
63
- ) : (
64
- <View key={col.key as string} style={cellStyle}>
65
- {inner}
66
- </View>
67
- );
68
- })}
69
- {expandable ? <View style={{ width: CHEVRON_W }} /> : null}
70
- </View>
71
- {rows.filter((r): r is TRow => Boolean(r)).map((row, i) => {
72
- const key = rowKey ? String(row[rowKey]) : String(i);
73
- const isOpen = expanded.has(key);
74
- const extra = rowStyle?.(row);
75
- const cells = columns.map((col) => (
76
- <View key={col.key as string} style={[cellWidth(col), styles.bodyCell, col.align === "right" ? styles.alignEnd : null]}>
77
- {col.renderCell ? col.renderCell({ row, column: col }) : <Text numberOfLines={1}>{row[col.key] != null ? String(row[col.key]) : ""}</Text>}
74
+ <TableContext.Provider value={{ columns, leading, trailing }}>
75
+ <View style={styles.headerBand}>
76
+ {leading > 0 ? <View style={{ width: leading }} /> : null}
77
+ {columns.map((col) => (
78
+ <View key={col.key} style={colStyle(col)}>
79
+ {col.label ? (
80
+ col.sortable && onSort ? (
81
+ <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} />
82
+ ) : (
83
+ <Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
84
+ {col.label}
85
+ </Text>
86
+ )
87
+ ) : null}
78
88
  </View>
79
- ));
80
- return (
81
- <Fragment key={key}>
82
- {expandable ? (
83
- <Pressable
84
- accessibilityRole="button"
85
- accessibilityState={{ expanded: isOpen }}
86
- onPress={() => toggle(key, row)}
87
- style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extra]}
88
- >
89
- {cells}
90
- <View style={styles.chevron}>
91
- <Icon name={isOpen ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
92
- </View>
93
- </Pressable>
94
- ) : onRowPress ? (
95
- <Pressable
96
- accessibilityRole="button"
97
- onPress={() => onRowPress(row)}
98
- style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extra]}
99
- >
100
- {cells}
101
- </Pressable>
102
- ) : (
103
- <View style={[styles.bodyRow, extra]}>{cells}</View>
104
- )}
105
- {expandable && isOpen ? <View style={styles.detail}>{renderDetail!(row)}</View> : null}
106
- </Fragment>
107
- );
108
- })}
109
- </ScrollView>
89
+ ))}
90
+ {trailing > 0 ? <View style={{ width: trailing }} /> : null}
91
+ </View>
92
+ {rows.map((row, i) => (
93
+ <View key={(row as ReactElement).key ?? i}>
94
+ <Divider />
95
+ {row}
96
+ </View>
97
+ ))}
98
+ </TableContext.Provider>
110
99
  );
111
100
  }
112
101
 
102
+ export interface TableRowProps {
103
+ /** Opens the record (the drawer). The door + the full-row surface both call it. */
104
+ onPress?: () => void;
105
+ selected?: boolean;
106
+ /** Accessible name for the door (the keyboard-focusable open target). */
107
+ accessibilityLabel?: string;
108
+ /** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
109
+ leading?: ReactNode;
110
+ /** Outside the door, AFTER the cells (a ⋯ menu / a button) — width = Table `trailing`. */
111
+ trailing?: ReactNode;
112
+ /** Min row height. Default 52. */
113
+ minHeight?: number;
114
+ /** The `TableCell`s, one per column, in column order. */
115
+ children: ReactNode;
116
+ }
117
+
118
+ /**
119
+ * A register row: a full-bleed `PressableRow` (its hover wash spans the whole row,
120
+ * incl. the leading/trailing controls) wrapping the door — an inner
121
+ * `role="button"` `Pressable` (the keyboard-accessible open) around the cells —
122
+ * with optional `leading`/`trailing` slots OUTSIDE the door so their own presses
123
+ * win. Cells map to `Table`'s columns by position.
124
+ */
125
+ export function TableRow(props: TableRowProps) {
126
+ const { onPress, selected, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
127
+ const ctx = useContext(TableContext);
128
+ if (!ctx) throw new Error("TableRow must be used within a Table");
129
+
130
+ const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[]).map(
131
+ (cell, i) => cloneElement(cell, { _column: ctx.columns[i] }),
132
+ );
133
+
134
+ return (
135
+ <PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
136
+ {ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
137
+ <Pressable
138
+ accessibilityRole={onPress ? "button" : undefined}
139
+ accessibilityLabel={accessibilityLabel}
140
+ onPress={onPress}
141
+ style={[styles.door, { minHeight }]}
142
+ >
143
+ {cells}
144
+ </Pressable>
145
+ {ctx.trailing > 0 ? <View style={{ width: ctx.trailing, alignItems: "flex-end" }}>{trailing}</View> : null}
146
+ </PressableRow>
147
+ );
148
+ }
149
+
150
+ export interface TableCellProps {
151
+ children: ReactNode;
152
+ /** @internal — injected by `TableRow` from the column at this cell's position. */
153
+ _column?: TableColumn;
154
+ }
155
+
156
+ /** One cell — its width/align come from the column `TableRow` injects by position. */
157
+ export function TableCell(props: TableCellProps) {
158
+ const { children, _column } = props;
159
+ return <View style={_column ? colStyle(_column) : undefined}>{children}</View>;
160
+ }
161
+
162
+ const noop = () => {};
163
+
113
164
  const styles = StyleSheet.create({
114
- container: { flex: 1, marginHorizontal: -8 },
115
- headerRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, backgroundColor: colors.white },
116
- headerCell: { minHeight: 40, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10 },
117
- headerInner: { flexDirection: "row", alignItems: "center", gap: 4 },
118
- bodyRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, alignItems: "stretch", backgroundColor: colors.white },
119
- rowPressed: { backgroundColor: colors.zinc[50] },
120
- bodyCell: { minHeight: 44, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 12 },
121
- chevron: { width: CHEVRON_W, alignItems: "center", justifyContent: "center" },
122
- detail: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 18, backgroundColor: colors.zinc[50], borderBottomWidth: 1, borderBottomColor: colors.border },
123
- alignEnd: { alignItems: "flex-end" },
124
- flexColumn: { flex: 1 },
165
+ headerBand: {
166
+ paddingHorizontal: 20,
167
+ paddingVertical: 10,
168
+ flexDirection: "row",
169
+ alignItems: "center",
170
+ gap: 14,
171
+ },
172
+ row: {
173
+ gap: 14,
174
+ },
175
+ door: {
176
+ flex: 1,
177
+ flexDirection: "row",
178
+ alignItems: "center",
179
+ gap: 14,
180
+ },
125
181
  });
package/src/text.tsx CHANGED
@@ -20,6 +20,12 @@ export interface TextProps {
20
20
  userSelect?: TextUserSelect;
21
21
  transform?: TextTransform;
22
22
  decoration?: TextDecorationLine;
23
+ /**
24
+ * Fixed-width (tabular) numerals. Set on every free-standing numeral —
25
+ * table cells, counts, money, dates — so digits align across rows and
26
+ * columns instead of jittering on proportional glyphs.
27
+ */
28
+ tabular?: boolean;
23
29
  style?: RNTextProps["style"];
24
30
  onPress?: () => void;
25
31
  /** ID used to target this element from `accessibilityLabelledBy` / `aria-describedby`. */
@@ -62,6 +68,7 @@ export function Text(props: TextProps) {
62
68
  weight = "regular",
63
69
  numberOfLines,
64
70
  decoration,
71
+ tabular,
65
72
  transform,
66
73
  onPress,
67
74
  style,
@@ -99,6 +106,7 @@ export function Text(props: TextProps) {
99
106
  styles[userSelect],
100
107
  styles[align],
101
108
  decoration && styles[decoration],
109
+ tabular && styles.tabular,
102
110
  transform && styles[transform],
103
111
  style,
104
112
  ]}
@@ -186,9 +194,16 @@ const styles = StyleSheet.create({
186
194
  userSelect: "none",
187
195
  },
188
196
 
197
+ tabular: {
198
+ fontVariant: ["tabular-nums"],
199
+ },
200
+
189
201
  // Text transform styles
190
202
  uppercase: {
191
203
  textTransform: "uppercase",
204
+ // Uppercase needs positive tracking to read as designed, not shouted —
205
+ // the standard micro-cap/eyebrow treatment (stat labels, column headers).
206
+ letterSpacing: 0.6,
192
207
  },
193
208
  lowercase: {
194
209
  textTransform: "lowercase",
package/src/text_utils.ts CHANGED
@@ -6,6 +6,8 @@ export type TextColor =
6
6
  | "muted"
7
7
  | "inverted"
8
8
  | "danger"
9
+ | "warning"
10
+ | "success"
9
11
  | "zinc-900"
10
12
  | "zinc-700"
11
13
  | "zinc-500";
@@ -24,8 +26,16 @@ export function getTextColor(color?: TextColor): string {
24
26
  return colors.zinc["500"];
25
27
  case "inverted":
26
28
  return colors.white;
29
+ // Valence set — the lightest status weight: a state WORD whose meaning is in
30
+ // the word, colored for reinforcement (never color-only). Red sits darker at
31
+ // 900; green/amber use 700 to stay recognizably their hue while clearing AA on
32
+ // white (600 would fail).
27
33
  case "danger":
28
34
  return colors.red["900"];
35
+ case "warning":
36
+ return colors.amber["700"];
37
+ case "success":
38
+ return colors.emerald["700"];
29
39
  default:
30
40
  return colors.zinc[900];
31
41
  }