@lotics/ui 7.19.3 → 9.0.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 (75) hide show
  1. package/AGENTS.md +298 -159
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +2 -2
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_dieline.tsx +3 -3
  7. package/examples/tpl_documents.tsx +790 -0
  8. package/examples/tpl_item_list.tsx +1015 -124
  9. package/examples/tpl_lookup.tsx +37 -10
  10. package/examples/tpl_pick.tsx +3 -3
  11. package/examples/tpl_pivot.tsx +1 -1
  12. package/examples/tpl_record.tsx +1354 -0
  13. package/examples/tpl_report.tsx +7 -7
  14. package/examples/tpl_rollup.tsx +6 -6
  15. package/examples/tpl_shifts.tsx +2 -2
  16. package/examples/tpl_statements.tsx +221 -0
  17. package/examples/tpl_stock.tsx +7 -7
  18. package/examples/tpl_task_board.tsx +48 -32
  19. package/examples/tpl_tasks.tsx +47 -47
  20. package/examples/tpl_tower.tsx +2 -2
  21. package/package.json +9 -9
  22. package/src/agent_run.tsx +1 -1
  23. package/src/capture_row.tsx +59 -0
  24. package/src/change_review.tsx +732 -236
  25. package/src/checklist.tsx +104 -0
  26. package/src/chip.tsx +12 -3
  27. package/src/composer.tsx +22 -1
  28. package/src/confidence.tsx +1 -1
  29. package/src/control_surface.ts +0 -14
  30. package/src/detail_row.tsx +137 -10
  31. package/src/finding.tsx +112 -80
  32. package/src/floating_action_bar.tsx +1 -1
  33. package/src/inline_date_picker.tsx +8 -3
  34. package/src/inline_edit.tsx +40 -10
  35. package/src/inline_member_select.tsx +3 -0
  36. package/src/inline_number_input.tsx +5 -2
  37. package/src/inline_select.tsx +8 -3
  38. package/src/inline_tag_select.tsx +140 -0
  39. package/src/inline_text_input.tsx +5 -2
  40. package/src/inline_time_picker.tsx +5 -2
  41. package/src/ledger.tsx +220 -0
  42. package/src/locale.tsx +43 -16
  43. package/src/progress_bar.tsx +32 -1
  44. package/src/record_summary.tsx +101 -0
  45. package/src/section_heading.tsx +16 -8
  46. package/src/sources.tsx +8 -5
  47. package/src/suggestion_chip.tsx +47 -0
  48. package/src/use_section_nav.test.ts +69 -0
  49. package/src/use_section_nav.ts +59 -0
  50. package/examples/tpl_assistant.tsx +0 -174
  51. package/examples/tpl_billing.tsx +0 -344
  52. package/examples/tpl_briefing.tsx +0 -121
  53. package/examples/tpl_compare.tsx +0 -133
  54. package/examples/tpl_crosscheck.tsx +0 -120
  55. package/examples/tpl_detail.tsx +0 -232
  56. package/examples/tpl_directory.tsx +0 -260
  57. package/examples/tpl_draft.tsx +0 -163
  58. package/examples/tpl_extract.tsx +0 -193
  59. package/examples/tpl_intake.tsx +0 -206
  60. package/examples/tpl_match.tsx +0 -134
  61. package/examples/tpl_order.tsx +0 -482
  62. package/examples/tpl_quick.tsx +0 -211
  63. package/examples/tpl_ratedesk.tsx +0 -386
  64. package/examples/tpl_record_plain.tsx +0 -259
  65. package/examples/tpl_settings.tsx +0 -178
  66. package/examples/tpl_timeline.tsx +0 -244
  67. package/examples/tpl_triage.tsx +0 -112
  68. package/examples/tpl_wizard.tsx +0 -223
  69. package/src/discrepancy.tsx +0 -114
  70. package/src/match_sides.tsx +0 -79
  71. package/src/record_fields.tsx +0 -68
  72. package/src/review_card.tsx +0 -172
  73. package/src/scored_option.tsx +0 -138
  74. package/src/spec_list.tsx +0 -81
  75. package/src/triage_row.tsx +0 -99
package/src/ledger.tsx ADDED
@@ -0,0 +1,220 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { Divider } from "./divider";
5
+ import { Link } from "./link";
6
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
7
+ import { PressableHighlight } from "./pressable_highlight";
8
+ import { useLoticsLocale } from "./locale";
9
+ import { Text } from "./text";
10
+
11
+ // The record-level money list — a compact financial STATEMENT for one record's
12
+ // drawer/section: charge/receipt GROUPS with their sums in the group headers,
13
+ // every figure on ONE right-aligned tabular column, closed by a divider-set
14
+ // emphasized total. A row with `peek` is a door: pressing it floats the fee's
15
+ // PARTICULARS (basis, who charged it, the invoice it landed on) in an anchored
16
+ // popover — the list stays flat and scannable, the depth is on demand. A row
17
+ // with `reference` carries a trailing link instead (an issued invoice, a
18
+ // receipt no.). No bars, no charts — the numbers are the interface.
19
+ //
20
+ // <Ledger formatValue={formatMoney}>
21
+ // <LedgerGroup label="Charges" total={total}>
22
+ // <LedgerRow label="Service fee" value={fee} peek={<FeeParticulars … />} />
23
+ // <LedgerRow label="Levy" value={levy} reference={{ label: "INV-0414", onPress }} />
24
+ // </LedgerGroup>
25
+ // <LedgerGroup label="Received" total={-received}>
26
+ // <LedgerRow label="Deposit" meta="03/06 · Cash" value={-1_550_000} tone="success" />
27
+ // </LedgerGroup>
28
+ // <LedgerTotal label="Outstanding" value={due} tone="danger" zeroLabel="Paid in full" />
29
+ // </Ledger>
30
+
31
+ interface LedgerContextValue {
32
+ format: (n: number) => string;
33
+ }
34
+
35
+ const LedgerContext = createContext<LedgerContextValue | null>(null);
36
+
37
+ function useLedger(): LedgerContextValue {
38
+ const ctx = useContext(LedgerContext);
39
+ if (!ctx) throw new Error("Ledger components must be used within a Ledger");
40
+ return ctx;
41
+ }
42
+
43
+ /** Signed display: negatives render as "− <abs>" (a receipt against charges).
44
+ * `n + 0` normalizes -0 (a zero receipts sum passed negated) to plain 0. */
45
+ function signed(format: (n: number) => string, n: number) {
46
+ return n < 0 ? `− ${format(-n)}` : format(n + 0);
47
+ }
48
+
49
+ export interface LedgerProps {
50
+ /** Money formatting shared by every row, sum, and total (e.g. `formatMoney`). */
51
+ formatValue: (n: number) => string;
52
+ children: ReactNode;
53
+ style?: StyleProp<ViewStyle>;
54
+ }
55
+
56
+ export function Ledger(props: LedgerProps) {
57
+ const { formatValue, children, style } = props;
58
+ return (
59
+ <LedgerContext.Provider value={{ format: formatValue }}>
60
+ <View style={[styles.ledger, style]}>{children}</View>
61
+ </LedgerContext.Provider>
62
+ );
63
+ }
64
+
65
+ export interface LedgerGroupProps {
66
+ /** The group name ("Charges", "Received") — xs muted, with the sum opposite. */
67
+ label: string;
68
+ /** The group's sum, shown right-aligned in the header. Sign renders as
69
+ * "−" (pass receipts negative). Omit to leave the header sumless. */
70
+ total?: number;
71
+ children: ReactNode;
72
+ }
73
+
74
+ /** One side of the ledger — a labelled run of rows with its sum in the header. */
75
+ export function LedgerGroup(props: LedgerGroupProps) {
76
+ const { label, total, children } = props;
77
+ const { format } = useLedger();
78
+ return (
79
+ <View style={styles.group}>
80
+ <View style={styles.row}>
81
+ <Text size="xs" weight="medium" color="muted" style={styles.grow}>
82
+ {label}
83
+ </Text>
84
+ {total != null ? (
85
+ <Text size="xs" color="muted" tabular>
86
+ {signed(format, total)}
87
+ </Text>
88
+ ) : null}
89
+ </View>
90
+ {children}
91
+ </View>
92
+ );
93
+ }
94
+
95
+ export interface LedgerRowProps {
96
+ /** The line's name — what was charged / received. */
97
+ label: string;
98
+ /** Inline context after the label — a date · method, a basis, a period. */
99
+ meta?: string;
100
+ /** The amount. Negative renders "− <abs>" (a receipt). */
101
+ value: number;
102
+ /** Valence of the amount (a receipt reads success). */
103
+ tone?: "default" | "success" | "danger";
104
+ /** The line's PARTICULARS — set it and the whole row becomes a pressable
105
+ * door that floats this node in an anchored popover. Put any links (the
106
+ * invoice, the tariff) INSIDE the peek: a row is never a button holding
107
+ * another button, so `reference` is ignored while `peek` is set. */
108
+ peek?: ReactNode;
109
+ /** Peek popover width. Default 300. */
110
+ peekWidth?: number;
111
+ /** A trailing reference link (an issued invoice, a receipt no.) for a row
112
+ * WITHOUT `peek`. */
113
+ reference?: { label: string; onPress: () => void };
114
+ /** Accessible name for a peekable row. Defaults to "<label> details". */
115
+ accessibilityLabel?: string;
116
+ }
117
+
118
+ /** One money line. Static by default; a door to its particulars with `peek`. */
119
+ export function LedgerRow(props: LedgerRowProps) {
120
+ const { label, meta, value, tone = "default", peek, peekWidth = 300, reference, accessibilityLabel } = props;
121
+ const rowDetails = useLoticsLocale().ledger.rowDetails;
122
+ const { format } = useLedger();
123
+ const content = (
124
+ <>
125
+ <Text size="sm" numberOfLines={1} style={styles.shrink}>
126
+ {label}
127
+ </Text>
128
+ {meta ? (
129
+ <Text size="xs" color="muted" numberOfLines={1}>
130
+ {meta}
131
+ </Text>
132
+ ) : null}
133
+ <View style={styles.grow} />
134
+ {!peek && reference ? (
135
+ <Link size="xs" onPress={reference.onPress} accessibilityLabel={reference.label}>
136
+ {reference.label}
137
+ </Link>
138
+ ) : null}
139
+ <Text size="sm" tabular color={tone === "default" ? undefined : tone}>
140
+ {signed(format, value)}
141
+ </Text>
142
+ </>
143
+ );
144
+ if (!peek) {
145
+ return <View style={styles.row}>{content}</View>;
146
+ }
147
+ return (
148
+ <Popover side="bottom" align="end">
149
+ <PopoverTrigger>
150
+ <PressableHighlight
151
+ focusRing
152
+ accessibilityRole="button"
153
+ accessibilityLabel={accessibilityLabel ?? rowDetails(label)}
154
+ style={(state) => [styles.row, styles.rowPress, state.hovered ? styles.rowHovered : null]}
155
+ >
156
+ {content}
157
+ </PressableHighlight>
158
+ </PopoverTrigger>
159
+ <PopoverContent style={{ width: peekWidth }} disableBodyScroll>
160
+ {peek}
161
+ </PopoverContent>
162
+ </Popover>
163
+ );
164
+ }
165
+
166
+ export interface LedgerTotalProps {
167
+ /** The closing question the ledger answers ("Outstanding", "Balance"). */
168
+ label: string;
169
+ value: number;
170
+ /** Valence of the closing figure. */
171
+ tone?: "default" | "success" | "danger";
172
+ /** Rendered instead of the figure when `value` is 0 ("Paid in full") — in
173
+ * success tone, since a settled ledger is the good outcome. */
174
+ zeroLabel?: string;
175
+ }
176
+
177
+ /** The divider-set closing line — the ledger's ONE emphasized number. */
178
+ export function LedgerTotal(props: LedgerTotalProps) {
179
+ const { label, value, tone = "default", zeroLabel } = props;
180
+ const { format } = useLedger();
181
+ const settled = value === 0 && zeroLabel != null;
182
+ return (
183
+ <View style={styles.total}>
184
+ <Divider />
185
+ <View style={styles.row}>
186
+ <Text size="sm" weight="semibold" style={styles.grow}>
187
+ {label}
188
+ </Text>
189
+ <Text size="lg" weight="semibold" tabular color={settled ? "success" : tone === "default" ? undefined : tone}>
190
+ {settled ? zeroLabel : signed(format, value)}
191
+ </Text>
192
+ </View>
193
+ </View>
194
+ );
195
+ }
196
+
197
+ const styles = StyleSheet.create({
198
+ // Outdent by the rows' 8px inset (the ListItem/Timeline technique) so row text +
199
+ // the money column align with the section heading and the container edges;
200
+ // the pressable door's wash bleeds into the gutter instead of squeezing text.
201
+ ledger: { gap: 12, marginHorizontal: -8 },
202
+ group: { gap: 4 },
203
+ // EVERY line (group header, rows, the total) shares the 8px text inset, so
204
+ // labels and the money column sit on one edge whether a row peeks or not —
205
+ // the pressable door's wash simply fills the same padded box.
206
+ row: {
207
+ flexDirection: "row",
208
+ alignItems: "center",
209
+ gap: 10,
210
+ minHeight: 28,
211
+ paddingHorizontal: 8,
212
+ },
213
+ rowPress: {
214
+ borderRadius: 8,
215
+ },
216
+ rowHovered: { backgroundColor: colors.zinc[50] },
217
+ grow: { flexGrow: 1, flexShrink: 1 },
218
+ shrink: { flexShrink: 1 },
219
+ total: { gap: 6 },
220
+ });
package/src/locale.tsx CHANGED
@@ -2,10 +2,10 @@ import { createContext, useContext, type ReactNode } from "react";
2
2
  import { type PaginationLabels } from "./pagination";
3
3
  import { type SortHeaderLabels } from "./sort_header";
4
4
  import { type ConfidenceLabels } from "./confidence";
5
- import { type FindingLabels } from "./finding";
6
5
  import { type RemainderMeterLabels } from "./remainder_meter";
7
6
  import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
8
7
  import { type GalleryLabels } from "./file_preview_types";
8
+ import { type FindingLabels } from "./finding";
9
9
 
10
10
  /**
11
11
  * The kit's localizable strings, one slice per string-bearing component. A
@@ -36,27 +36,40 @@ export interface LoticsLocale {
36
36
  formField: { optional: string };
37
37
  /** `Drawer`: the record prev/next + close controls (screen-reader names). */
38
38
  drawer: { previous: string; next: string; close: string };
39
+ /** The `Inline*` editor family: the shared save-error line and the
40
+ * text-editor Save/Cancel tooltips. */
41
+ inline: { saveError: string; save: string; cancel: string };
42
+ /** `Ledger`: the screen-reader name of a peekable row. */
43
+ ledger: { rowDetails: (label: string) => string };
44
+ /** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
45
+ sectionHeading: { info: string };
46
+ /** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
47
+ chip: { remove: string };
48
+ /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
49
+ suggestionChip: { add: (label: string) => string; dismiss: string };
39
50
  /** `Confidence`: the full level phrase ("High confidence" …). */
40
51
  confidence: ConfidenceLabels;
41
52
  /** `Finding`: the severity badge word ("Critical" …). */
42
- finding: FindingLabels;
43
53
  /** `RemainderMeter`: the applied / remaining / over / exact captions. */
44
54
  remainderMeter: Required<RemainderMeterLabels>;
45
55
  /** `ChangeReview` (+ `ChangeReviewActions`): the review-card chrome — the
46
56
  * Keep/Drop verdicts, Undo, the Applied/Discarded tags, the kept-counter,
47
57
  * the title, and the commit-bar Accept-all / Discard. */
58
+ finding: FindingLabels;
48
59
  changeReview: {
49
60
  title: string;
50
61
  accept: string;
51
62
  reject: string;
52
63
  undo: string;
53
- applied: string;
54
- discarded: string;
55
64
  keptCount: (kept: number, total: number) => string;
56
65
  acceptAll: string;
57
66
  discard: string;
58
67
  apply: string;
59
- applyKept: string;
68
+ recommended: string;
69
+ customValue: string;
70
+ opAdd: string;
71
+ opEdit: string;
72
+ opRemove: string;
60
73
  };
61
74
  /** `DateRangeFilterField` (and the `DateFilter` panel it wraps): presets,
62
75
  * from/to, the footer Clear/Done, the trigger placeholder, and the time-field
@@ -88,26 +101,33 @@ export const en: LoticsLocale = {
88
101
  filterChip: { clear: "Clear" },
89
102
  formField: { optional: "Optional" },
90
103
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
104
+ inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
105
+ ledger: { rowDetails: (label) => `${label} details` },
106
+ sectionHeading: { info: "About this data" },
107
+ chip: { remove: "Remove" },
108
+ suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
91
109
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
92
- finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track" },
93
110
  remainderMeter: {
94
111
  applied: (allocated, total) => `${allocated} of ${total} applied`,
95
112
  remaining: (remainder) => `${remainder} unapplied`,
96
113
  over: (amount) => `Over by ${amount}`,
97
114
  exact: "Fully applied",
98
115
  },
116
+ finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track", difference: "Difference" },
99
117
  changeReview: {
100
118
  title: "Suggested edits",
101
119
  accept: "Keep",
102
120
  reject: "Drop",
103
121
  undo: "Undo",
104
- applied: "Applied",
105
- discarded: "Discarded",
106
122
  keptCount: (kept, total) => `${kept} of ${total} kept`,
107
- acceptAll: "Accept all",
123
+ acceptAll: "Keep all",
108
124
  discard: "Discard",
109
125
  apply: "Apply",
110
- applyKept: "Apply kept",
126
+ recommended: "AI pick",
127
+ customValue: "Type another value",
128
+ opAdd: "Add",
129
+ opEdit: "Edit",
130
+ opRemove: "Delete",
111
131
  },
112
132
  dateRange: {
113
133
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
@@ -157,26 +177,33 @@ export const vi: LoticsLocale = {
157
177
  filterChip: { clear: "Xóa" },
158
178
  formField: { optional: "Tùy chọn" },
159
179
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
180
+ inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
181
+ ledger: { rowDetails: (label) => `Chi tiết ${label}` },
182
+ sectionHeading: { info: "Giải thích dữ liệu" },
183
+ chip: { remove: "Xóa" },
184
+ suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
160
185
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
161
- finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ" },
162
186
  remainderMeter: {
163
187
  applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
164
188
  remaining: (remainder) => `Còn ${remainder}`,
165
189
  over: (amount) => `Vượt ${amount}`,
166
190
  exact: "Đã phân bổ đủ",
167
191
  },
192
+ finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ", difference: "Chênh lệch" },
168
193
  changeReview: {
169
194
  title: "Đề xuất chỉnh sửa",
170
195
  accept: "Giữ",
171
196
  reject: "Bỏ",
172
197
  undo: "Hoàn tác",
173
- applied: "Đã áp dụng",
174
- discarded: "Đã bỏ",
175
- keptCount: (kept, total) => `Giữ ${kept}/${total}`,
198
+ keptCount: (kept, total) => `Đã giữ ${kept}/${total}`,
176
199
  acceptAll: "Giữ tất cả",
177
- discard: "Hủy",
200
+ discard: "Bỏ hết",
178
201
  apply: "Áp dụng",
179
- applyKept: "Áp dụng mục đã giữ",
202
+ recommended: "AI đề xuất",
203
+ customValue: "Nhập giá trị khác",
204
+ opAdd: "Thêm",
205
+ opEdit: "Sửa",
206
+ opRemove: "Xóa",
180
207
  },
181
208
  dateRange: {
182
209
  year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
@@ -16,6 +16,11 @@ export interface ProgressBarProps {
16
16
  format?: ProgressBarFormat;
17
17
  color?: string;
18
18
  completeColor?: string;
19
+ /** COMPACT: one row — the track (flex) with a plain sm tabular count beside
20
+ * it ("4/5", or "80%" under format="percentage"). For cells, headings, and
21
+ * peek triggers where the stacked title-over-track anatomy is too tall and
22
+ * a floating caption reads misaligned. `title` is ignored. */
23
+ compact?: boolean;
19
24
  }
20
25
 
21
26
  /**
@@ -32,11 +37,29 @@ export function ProgressBar(props: ProgressBarProps) {
32
37
  format = "percentage",
33
38
  color = colors.blue["500"],
34
39
  completeColor = colors.green["500"],
40
+ compact = false,
35
41
  } = props;
36
42
 
37
- const percentage = Math.min(100, Math.max(0, (value / max) * 100));
43
+ const percentage = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
38
44
  const isComplete = percentage >= 100;
39
45
 
46
+ if (compact) {
47
+ const label =
48
+ format === "percentage" ? `${Math.round(percentage)}%` : `${value.toLocaleString("vi-VN")}/${max.toLocaleString("vi-VN")}`;
49
+ return (
50
+ <View style={styles.compactRow}>
51
+ <View style={[styles.track, styles.compactTrack]}>
52
+ <View style={[styles.fill, { width: `${percentage}%`, backgroundColor: isComplete ? completeColor : color }]} />
53
+ </View>
54
+ {format === "none" ? null : (
55
+ <Text size="sm" tabular color={isComplete ? "success" : "muted"}>
56
+ {label}
57
+ </Text>
58
+ )}
59
+ </View>
60
+ );
61
+ }
62
+
40
63
  const caption =
41
64
  format === "fraction"
42
65
  ? `${value.toLocaleString("vi-VN")} / ${max.toLocaleString("vi-VN")} · ${Math.round(percentage)}%`
@@ -80,6 +103,14 @@ const styles = StyleSheet.create({
80
103
  container: {
81
104
  gap: 6,
82
105
  },
106
+ compactRow: {
107
+ flexDirection: "row",
108
+ alignItems: "center",
109
+ gap: 8,
110
+ },
111
+ compactTrack: {
112
+ flex: 1,
113
+ },
83
114
  header: {
84
115
  flexDirection: "row",
85
116
  alignItems: "baseline",
@@ -0,0 +1,101 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+
5
+ export interface RecordSummaryMetric {
6
+ /** Caption above the number ("Outstanding", "Total value"). */
7
+ label: string;
8
+ /** The headline figure — rendered lg semibold tabular. */
9
+ value: string;
10
+ /** Valence of the figure (and its note) — the summary's ONE permitted accent. */
11
+ tone?: "default" | "danger" | "warning" | "success";
12
+ /** One short qualifier under the number ("Overdue 25 days"). */
13
+ note?: string;
14
+ }
15
+
16
+ export interface RecordSummaryProps {
17
+ /** The record's id/name — xl semibold, tabular so ids align across records. */
18
+ title: string;
19
+ /** Heading level of the title (`role="header"` + aria-level). Default 1 —
20
+ * the record page's one h1; pass 2 when the band sits inside a surface
21
+ * that already owns the h1 (a drawer with its own titled header). */
22
+ level?: 1 | 2 | 3;
23
+ /** Identity qualifiers on the title line ("HCM → Hamburg · Export · FCL") —
24
+ * sm muted; wraps under the title when narrow. */
25
+ subtitle?: string;
26
+ /** A status chip (a `Badge`) or any small node right after the title. */
27
+ status?: ReactNode;
28
+ /** ONE headline number with valence — pinned to the summary's top right.
29
+ * More than one figure belongs in a KPI strip, not the record header. */
30
+ metric?: RecordSummaryMetric;
31
+ }
32
+
33
+ /**
34
+ * The identity band of a single record's detail screen or drawer: ONE row —
35
+ * title · subtitle · status chip, with an optional headline metric pinned
36
+ * right. It exists to stop hand-rolled record headers drifting: mixed type
37
+ * scales, several competing figures, color noise.
38
+ *
39
+ * Color discipline: the header stays neutral; `metric.tone` is the one accent.
40
+ * The record's FIELDS never live in the header — compose them as
41
+ * `DetailTable`s in the sections below (`SectionHeading` + `Divider` rhythm) —
42
+ * see `examples/tpl_record.tsx`.
43
+ */
44
+ export function RecordSummary(props: RecordSummaryProps) {
45
+ const { title, subtitle, status, metric, level = 1 } = props;
46
+ return (
47
+ <View style={styles.identityRow}>
48
+ <View style={styles.identity}>
49
+ <View style={styles.titleRow}>
50
+ <Text size="xl" weight="semibold" tabular level={level}>
51
+ {title}
52
+ </Text>
53
+ {status ?? null}
54
+ </View>
55
+ {subtitle ? (
56
+ <Text size="sm" color="muted">
57
+ {subtitle}
58
+ </Text>
59
+ ) : null}
60
+ </View>
61
+ {metric ? (
62
+ <View style={styles.metric}>
63
+ <Text size="xs" color="muted" weight="medium" align="right">
64
+ {metric.label}
65
+ </Text>
66
+ <Text size="lg" weight="semibold" tabular align="right" color={metric.tone === "default" ? undefined : metric.tone}>
67
+ {metric.value}
68
+ </Text>
69
+ {metric.note ? (
70
+ <Text size="xs" align="right" weight={metric.tone && metric.tone !== "default" ? "medium" : "regular"} color={metric.tone && metric.tone !== "default" ? metric.tone : "muted"}>
71
+ {metric.note}
72
+ </Text>
73
+ ) : null}
74
+ </View>
75
+ ) : null}
76
+ </View>
77
+ );
78
+ }
79
+
80
+ const styles = StyleSheet.create({
81
+ identityRow: {
82
+ flexDirection: "row",
83
+ alignItems: "flex-start",
84
+ gap: 16,
85
+ },
86
+ identity: {
87
+ flex: 1,
88
+ gap: 2,
89
+ },
90
+ titleRow: {
91
+ flexDirection: "row",
92
+ alignItems: "center",
93
+ flexWrap: "wrap",
94
+ columnGap: 10,
95
+ rowGap: 4,
96
+ },
97
+ metric: {
98
+ alignItems: "flex-end",
99
+ gap: 1,
100
+ },
101
+ });
@@ -2,6 +2,7 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
2
2
  import { Text, type HeadingLevel } from "./text";
3
3
  import { Icon, type IconName } from "./icon";
4
4
  import { InfoPopover } from "./info_popover";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  // The card-less section header — the bare-canvas sibling of `CardHeader`, built
7
8
  // the same compound way. A title (+ optional leading icon / description) on one
@@ -36,7 +37,12 @@ export interface SectionProps {
36
37
  * </Section>
37
38
  */
38
39
  export function Section(props: SectionProps) {
39
- return <View style={[{ gap: 10 }, props.style]}>{props.children}</View>;
40
+ // gap 12 spaces the heading from its body. Space BETWEEN sections belongs to
41
+ // the page column: flat record/form pages use `gap: 32` on the content column
42
+ // with a bare `Divider` BETWEEN sections (never under the heading — the
43
+ // heading belongs to its content; the hairline separates it from the LAST
44
+ // section).
45
+ return <View style={[{ gap: 12 }, props.style]}>{props.children}</View>;
40
46
  }
41
47
 
42
48
  export interface SectionHeadingProps {
@@ -58,26 +64,28 @@ export interface SectionHeadingTitleProps {
58
64
  icon?: IconName;
59
65
  /** Heading rank. Defaults to 2 — typical for page-level section titles. */
60
66
  level?: HeadingLevel;
61
- /** Title weight. Defaults to `medium`; `semibold` (matching `CardHeaderTitle`)
62
- * for denser surfaces a side panel, a stacked group list where `medium`
63
- * blends into the body rows. */
67
+ /** Title weight. Defaults to `semibold` a section title must separate from
68
+ * the body rows at a glance (medium blends in; it remains as an opt-down for
69
+ * a surface where the heading competes with a stronger band above it). */
64
70
  weight?: "medium" | "semibold";
65
71
  /** An ⓘ popover after the title — a short "what this is / where it came from"
66
72
  * gloss, mirroring `CardHeaderTitle`'s `info`. */
67
73
  info?: string;
68
74
  }
69
75
 
70
- /** The title block — grows to push any sibling actions to the right edge. */
76
+ /** The title block — lg semibold (a section must announce itself; md blends
77
+ * into control labels) + an optional muted `description` line. Grows to push
78
+ * any sibling actions to the right edge. */
71
79
  export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
72
- const { children, description, icon, level = 2, weight = "medium", info } = props;
80
+ const { children, description, icon, level = 2, weight = "semibold", info } = props;
73
81
  return (
74
82
  <View style={{ flex: 1, gap: 2 }}>
75
83
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
76
84
  {icon ? <Icon name={icon} size={18} /> : null}
77
- <Text level={level} weight={weight} size="md">
85
+ <Text level={level} weight={weight} size="lg">
78
86
  {children}
79
87
  </Text>
80
- {info ? <InfoPopover text={info} accessibilityLabel="Giải thích dữ liệu" /> : null}
88
+ {info ? <InfoPopover text={info} accessibilityLabel={useLoticsLocale().sectionHeading.info} /> : null}
81
89
  </View>
82
90
  {description ? (
83
91
  <Text color="zinc-500" size="sm">
package/src/sources.tsx CHANGED
@@ -16,8 +16,9 @@ export interface SourceRef {
16
16
 
17
17
  export interface SourcesProps {
18
18
  sources: SourceRef[];
19
- /** Eyebrow above the chips. Default "Sources". */
20
- label?: string;
19
+ /** Eyebrow above the chips. Default "Sources"; pass `null` for chips only —
20
+ * the form used INSIDE a `Change` (a review card's own header/body). */
21
+ label?: string | null;
21
22
  /** Pass to make each chip openable (jump to the record / document / page). The
22
23
  * chips then show a hover state + an open glyph; the host does the navigation. */
23
24
  onOpen?: (source: SourceRef) => void;
@@ -45,9 +46,11 @@ export function Sources(props: SourcesProps) {
45
46
  if (props.sources.length === 0) return null;
46
47
  return (
47
48
  <View style={{ gap: 8 }}>
48
- <Text size="xs" color="muted" weight="medium">
49
- {props.label ?? "Sources"}
50
- </Text>
49
+ {props.label === null ? null : (
50
+ <Text size="xs" color="muted" weight="medium">
51
+ {props.label ?? "Sources"}
52
+ </Text>
53
+ )}
51
54
  <View style={styles.row}>
52
55
  {props.sources.map((s) =>
53
56
  props.onOpen ? (
@@ -0,0 +1,47 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Chip } from "./chip";
3
+ import { colors } from "./colors";
4
+ import { Icon } from "./icon";
5
+ import { useLoticsLocale } from "./locale";
6
+ import { Text } from "./text";
7
+
8
+ export interface SuggestionChipProps {
9
+ /** The suggested item ("Verify the customer's tax ID"). */
10
+ label: string;
11
+ /** Materialize the suggestion. */
12
+ onAdd: () => void;
13
+ /** Refuse the suggestion (the ✕). Omit for take-it-or-leave-it pills. */
14
+ onDismiss?: () => void;
15
+ /** Accessible name of the press target; default "Add: <label>". */
16
+ accessibilityLabel?: string;
17
+ /** The ✕'s name; defaults to the locale's "Dismiss suggestion". */
18
+ dismissLabel?: string;
19
+ }
20
+
21
+ /**
22
+ * A dismissible SUGGESTION pill — an item the record could have but doesn't
23
+ * yet (a common task, an expected line): a `Chip` whose press MATERIALIZES it
24
+ * and whose ✕ refuses it. A pill can never be mistaken for the real row it
25
+ * would become; suggestions never count in any total. Filter out labels the
26
+ * list already holds before rendering.
27
+ */
28
+ export function SuggestionChip(props: SuggestionChipProps) {
29
+ const { label, onAdd, onDismiss, accessibilityLabel, dismissLabel } = props;
30
+ const labels = useLoticsLocale().suggestionChip;
31
+ return (
32
+ <Chip onPress={onAdd} accessibilityLabel={accessibilityLabel ?? labels.add(label)} onDismiss={onDismiss} dismissTooltip={dismissLabel ?? labels.dismiss}>
33
+ <View style={styles.content}>
34
+ <Icon name="plus" size={15} color={colors.zinc[500]} />
35
+ <Text size="sm">{label}</Text>
36
+ </View>
37
+ </Chip>
38
+ );
39
+ }
40
+
41
+ const styles = StyleSheet.create({
42
+ content: {
43
+ flexDirection: "row",
44
+ alignItems: "center",
45
+ gap: 6,
46
+ },
47
+ });