@lotics/ui 8.0.0 → 10.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 (58) hide show
  1. package/AGENTS.md +177 -70
  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_item_list.tsx +1015 -124
  7. package/examples/tpl_pick.tsx +3 -3
  8. package/examples/tpl_pivot.tsx +1 -1
  9. package/examples/tpl_record.tsx +1354 -0
  10. package/examples/tpl_report.tsx +7 -7
  11. package/examples/tpl_rollup.tsx +6 -6
  12. package/examples/tpl_shifts.tsx +2 -2
  13. package/examples/tpl_statements.tsx +221 -0
  14. package/examples/tpl_stock.tsx +7 -7
  15. package/examples/tpl_task_board.tsx +16 -13
  16. package/examples/tpl_tasks.tsx +15 -28
  17. package/examples/tpl_tower.tsx +2 -2
  18. package/package.json +8 -7
  19. package/src/capture_row.tsx +59 -0
  20. package/src/checklist.tsx +104 -0
  21. package/src/chip.tsx +12 -3
  22. package/src/detail_row.tsx +137 -10
  23. package/src/inline_date_picker.tsx +8 -3
  24. package/src/inline_edit.tsx +40 -10
  25. package/src/inline_member_select.tsx +3 -0
  26. package/src/inline_number_input.tsx +5 -2
  27. package/src/inline_select.tsx +8 -3
  28. package/src/inline_tag_select.tsx +140 -0
  29. package/src/inline_text_input.tsx +5 -2
  30. package/src/inline_time_picker.tsx +5 -2
  31. package/src/ledger.tsx +220 -0
  32. package/src/locale.tsx +25 -0
  33. package/src/page_header.tsx +0 -2
  34. package/src/popover_nav.tsx +40 -0
  35. package/src/progress_bar.tsx +32 -1
  36. package/src/record_summary.tsx +101 -0
  37. package/src/section_heading.tsx +16 -8
  38. package/src/suggestion_chip.tsx +47 -0
  39. package/src/trend_footer.tsx +3 -1
  40. package/src/use_screen_size.ts +1 -1
  41. package/src/use_section_nav.test.ts +69 -0
  42. package/src/use_section_nav.ts +59 -0
  43. package/examples/tpl_billing.tsx +0 -344
  44. package/examples/tpl_detail.tsx +0 -232
  45. package/examples/tpl_directory.tsx +0 -260
  46. package/examples/tpl_intake.tsx +0 -206
  47. package/examples/tpl_order.tsx +0 -482
  48. package/examples/tpl_quick.tsx +0 -211
  49. package/examples/tpl_record_plain.tsx +0 -259
  50. package/examples/tpl_settings.tsx +0 -178
  51. package/examples/tpl_timeline.tsx +0 -244
  52. package/examples/tpl_wizard.tsx +0 -223
  53. package/src/animation_horizontal_slide.tsx +0 -75
  54. package/src/form_time_picker.tsx +0 -22
  55. package/src/highlighted_text.tsx +0 -92
  56. package/src/menu_title.tsx +0 -15
  57. package/src/pager_view.tsx +0 -167
  58. package/src/popover_header.tsx +0 -38
@@ -7,7 +7,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
7
  import { OptionList } from "./option_list";
8
8
  import type { PickerOption } from "./picker";
9
9
  import { ActivityIndicator } from "./activity_indicator";
10
- import { InlineEditView } from "./inline_edit";
10
+ import { type InlineEditBackground, InlineEditView } from "./inline_edit";
11
+ import { useLoticsLocale } from "./locale";
11
12
 
12
13
  export interface InlineSelectProps<T extends string> {
13
14
  value: T | null;
@@ -24,6 +25,8 @@ export interface InlineSelectProps<T extends string> {
24
25
  placeholder?: string;
25
26
  disabled?: boolean;
26
27
  accessibilityLabel?: string;
28
+ /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
29
+ background?: InlineEditBackground;
27
30
  /** Show an in-menu search box; default false. */
28
31
  searchable?: boolean;
29
32
  }
@@ -36,7 +39,8 @@ export interface InlineSelectProps<T extends string> {
36
39
  * Pass `renderOptionContent` for rich options, or omit it for a plain label list.
37
40
  */
38
41
  export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
39
- const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false } = props;
42
+ const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, background } = props;
43
+ const labels = useLoticsLocale().inline;
40
44
  const [open, setOpen] = useState(false);
41
45
  const [saving, setSaving] = useState(false);
42
46
  const [error, setError] = useState<string | null>(null);
@@ -52,7 +56,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
52
56
  try {
53
57
  await onSave(next);
54
58
  } catch (e) {
55
- setError(e instanceof Error && e.message ? e.message : "Couldn't save. Try again.");
59
+ setError(e instanceof Error && e.message ? e.message : labels.saveError);
56
60
  } finally {
57
61
  setSaving(false);
58
62
  }
@@ -65,6 +69,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
65
69
  <Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
66
70
  <PopoverTrigger>
67
71
  <InlineEditView
72
+ background={background}
68
73
  display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
69
74
  placeholder={placeholder}
70
75
  disabled={disabled}
@@ -0,0 +1,140 @@
1
+ import { useState } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Icon } from "./icon";
4
+ import { Text } from "./text";
5
+ import { colors } from "./colors";
6
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
+ import { OptionList } from "./option_list";
8
+ import type { PickerOption } from "./picker";
9
+ import { ActivityIndicator } from "./activity_indicator";
10
+ import { Badge } from "./badge";
11
+ import { type InlineEditBackground, InlineEditView } from "./inline_edit";
12
+ import { useLoticsLocale } from "./locale";
13
+
14
+ export interface InlineTagSelectProps<T extends string> {
15
+ /** The selected tag values. */
16
+ value: T[];
17
+ /** Commits the NEW SET when the picker closes (only if it changed). Throwing
18
+ * surfaces the inline error, like every inline editor. */
19
+ onSave: (next: T[]) => void | Promise<void>;
20
+ options: PickerOption<T>[];
21
+ /** The resting render of ONE selected tag (an `OptionBadge` dot, a `Chip`).
22
+ * Defaults to a plain zinc `Badge` of the label. */
23
+ renderTag?: (option: PickerOption<T>) => React.ReactNode;
24
+ /** Rich row content in the dropdown. Falls back to `renderTag`, then label. */
25
+ renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
26
+ placeholder?: string;
27
+ disabled?: boolean;
28
+ accessibilityLabel?: string;
29
+ /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
30
+ background?: InlineEditBackground;
31
+ /** Show an in-menu search box; default false. */
32
+ searchable?: boolean;
33
+ }
34
+
35
+ /**
36
+ * The inline-editable MULTI select — the tag-field member of the `Inline*`
37
+ * family (`InlineSelect` picks one; this holds a set). At rest the selected
38
+ * tags render as badges inside the standard inline chip; clicking floats a
39
+ * multi `OptionList` (checkbox rows) in a popover, toggles edit a local draft,
40
+ * and CLOSING the popover commits the new set in one `onSave` — the blur-commit
41
+ * contract of the inline family, applied to a set.
42
+ */
43
+ export function InlineTagSelect<T extends string>(props: InlineTagSelectProps<T>) {
44
+ const {
45
+ value, onSave, options, renderTag, renderOptionContent, placeholder, disabled, accessibilityLabel, background, searchable = false,
46
+ } = props;
47
+ const labels = useLoticsLocale().inline;
48
+ const [open, setOpenState] = useState(false);
49
+ const [draft, setDraft] = useState<T[]>(value);
50
+ const [saving, setSaving] = useState(false);
51
+ const [error, setError] = useState<string | null>(null);
52
+
53
+ const commit = async (next: T[]) => {
54
+ const changed = next.length !== value.length || next.some((v) => !value.includes(v));
55
+ if (!changed) return;
56
+ setSaving(true);
57
+ setError(null);
58
+ try {
59
+ await onSave(next);
60
+ } catch (e) {
61
+ setError(e instanceof Error && e.message ? e.message : labels.saveError);
62
+ } finally {
63
+ setSaving(false);
64
+ }
65
+ };
66
+
67
+ const setOpen = (next: boolean) => {
68
+ if (next) {
69
+ setDraft(value);
70
+ setOpenState(true);
71
+ return;
72
+ }
73
+ setOpenState(false);
74
+ void commit(draft);
75
+ };
76
+
77
+ const selected = options.filter((o) => value.includes(o.value));
78
+ const tag = (o: PickerOption<T>) => (renderTag ? renderTag(o) : <Badge label={o.label ?? String(o.value)} color="zinc" />);
79
+
80
+ return (
81
+ <View>
82
+ <Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
83
+ <PopoverTrigger>
84
+ <InlineEditView
85
+ background={background}
86
+ display={
87
+ selected.length > 0 ? (
88
+ <View style={styles.tags}>
89
+ {selected.map((o) => (
90
+ <View key={o.value}>{tag(o)}</View>
91
+ ))}
92
+ </View>
93
+ ) : (
94
+ ""
95
+ )
96
+ }
97
+ placeholder={placeholder}
98
+ disabled={disabled}
99
+ active={open && !disabled}
100
+ accessibilityLabel={accessibilityLabel}
101
+ trailing={
102
+ saving ? (
103
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
104
+ ) : (
105
+ <Icon name="chevron-down" size={18} color={colors.zinc[400]} />
106
+ )
107
+ }
108
+ />
109
+ </PopoverTrigger>
110
+ <PopoverContent disableBodyScroll>
111
+ <OptionList<T, true>
112
+ multi
113
+ search={{ mode: searchable ? "internal" : "none" }}
114
+ options={options}
115
+ value={draft}
116
+ onValueChange={setDraft}
117
+ onRequestClose={() => setOpen(false)}
118
+ renderOptionContent={renderOptionContent ?? renderTag}
119
+ />
120
+ </PopoverContent>
121
+ </Popover>
122
+ {error ? (
123
+ <Text size="xs" color="danger" style={styles.error}>
124
+ {error}
125
+ </Text>
126
+ ) : null}
127
+ </View>
128
+ );
129
+ }
130
+
131
+ const styles = StyleSheet.create({
132
+ tags: {
133
+ flexDirection: "row",
134
+ alignItems: "center",
135
+ flexWrap: "wrap",
136
+ columnGap: 4,
137
+ rowGap: 2,
138
+ },
139
+ error: { marginTop: 4 },
140
+ });
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
3
3
  import { TextInputField } from "./text_input_field";
4
- import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
4
+ import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
5
5
 
6
6
  export interface InlineTextInputProps {
7
7
  value: string;
@@ -15,6 +15,8 @@ export interface InlineTextInputProps {
15
15
  disabled?: boolean;
16
16
  /** Strike + mute the resting value (a completed item that stays editable). */
17
17
  struck?: boolean;
18
+ /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
19
+ background?: InlineEditBackground;
18
20
  accessibilityLabel?: string;
19
21
  }
20
22
 
@@ -25,7 +27,7 @@ export interface InlineTextInputProps {
25
27
  * value in a dense record / detail surface.
26
28
  */
27
29
  export function InlineTextInput(props: InlineTextInputProps) {
28
- const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel } = props;
30
+ const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , background } = props;
29
31
  const edit = useInlineEdit<string>({ value, onSave });
30
32
 
31
33
  const onKeyPress = useCallback(
@@ -47,6 +49,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
47
49
 
48
50
  return (
49
51
  <InlineEditFrame
52
+ background={background}
50
53
  editing={edit.editing}
51
54
  display={value}
52
55
  placeholder={placeholder}
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { KeyboardEvent } from "react";
3
3
  import { TimePicker } from "./time_picker";
4
- import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
4
+ import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
5
5
 
6
6
  export interface InlineTimePickerProps {
7
7
  /** Canonical 24-hour "HH:mm", "" when empty. */
@@ -13,6 +13,8 @@ export interface InlineTimePickerProps {
13
13
  controls?: InlineEditControls;
14
14
  disabled?: boolean;
15
15
  accessibilityLabel?: string;
16
+ /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
17
+ background?: InlineEditBackground;
16
18
  }
17
19
 
18
20
  /**
@@ -21,7 +23,7 @@ export interface InlineTimePickerProps {
21
23
  * field at the same height, so the form never reflows.
22
24
  */
23
25
  export function InlineTimePicker(props: InlineTimePickerProps) {
24
- const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel } = props;
26
+ const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , background } = props;
25
27
  const edit = useInlineEdit<string>({ value, onSave });
26
28
 
27
29
  const onKeyDown = useCallback(
@@ -39,6 +41,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
39
41
 
40
42
  return (
41
43
  <InlineEditFrame
44
+ background={background}
42
45
  editing={edit.editing}
43
46
  display={value}
44
47
  placeholder={placeholder}
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
@@ -36,6 +36,19 @@ 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
+ /** `TrendFooter`: the direction words of the trend sentence. */
49
+ trendFooter: { up: string; down: string };
50
+ /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
51
+ suggestionChip: { add: (label: string) => string; dismiss: string };
39
52
  /** `Confidence`: the full level phrase ("High confidence" …). */
40
53
  confidence: ConfidenceLabels;
41
54
  /** `Finding`: the severity badge word ("Critical" …). */
@@ -90,6 +103,12 @@ export const en: LoticsLocale = {
90
103
  filterChip: { clear: "Clear" },
91
104
  formField: { optional: "Optional" },
92
105
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
106
+ inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
107
+ ledger: { rowDetails: (label) => `${label} details` },
108
+ sectionHeading: { info: "About this data" },
109
+ chip: { remove: "Remove" },
110
+ trendFooter: { up: "Up", down: "Down" },
111
+ suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
93
112
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
94
113
  remainderMeter: {
95
114
  applied: (allocated, total) => `${allocated} of ${total} applied`,
@@ -161,6 +180,12 @@ export const vi: LoticsLocale = {
161
180
  filterChip: { clear: "Xóa" },
162
181
  formField: { optional: "Tùy chọn" },
163
182
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
183
+ inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
184
+ ledger: { rowDetails: (label) => `Chi tiết ${label}` },
185
+ sectionHeading: { info: "Giải thích dữ liệu" },
186
+ chip: { remove: "Xóa" },
187
+ trendFooter: { up: "Tăng", down: "Giảm" },
188
+ suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
164
189
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
165
190
  remainderMeter: {
166
191
  applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
@@ -1,7 +1,6 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "@lotics/ui/text";
3
3
  import { ReactNode } from "react";
4
- import { useScreenSize } from "@lotics/ui/use_screen_size";
5
4
 
6
5
  interface PageHeaderProps {
7
6
  title: string;
@@ -12,7 +11,6 @@ interface PageHeaderProps {
12
11
 
13
12
  export function PageHeader(props: PageHeaderProps) {
14
13
  const { title, description, left, right } = props;
15
- const screenSize = useScreenSize();
16
14
 
17
15
  const hasNav = !!left || !!right;
18
16
 
@@ -1,4 +1,7 @@
1
1
  import { createContext, ReactNode, useContext } from "react";
2
+ import { IconButton } from "./icon_button";
3
+ import { View, StyleSheet } from "react-native";
4
+ import { Text } from "./text";
2
5
 
3
6
  export interface PopoverNavContextValue {
4
7
  currentRoute: string;
@@ -30,3 +33,40 @@ export function PopoverScreen(props: PopoverScreenProps) {
30
33
 
31
34
  return <>{children}</>;
32
35
  }
36
+
37
+ export interface PopoverNavHeaderProps {
38
+ title: string;
39
+ right?: ReactNode;
40
+ /** Accessible name for the back button. Default: "Back". Pass a translated string from the consumer. */
41
+ backLabel?: string;
42
+ }
43
+
44
+ /** The routed-popover title row: a back chevron auto-appears while `canGoBack`
45
+ * (reads `usePopoverNav`), sm-medium title fills, `right` a trailing action.
46
+ * Pairs with `PopoverScreen`. Distinct from `Popover`'s own plain
47
+ * `PopoverHeader` children container. */
48
+ export function PopoverNavHeader(props: PopoverNavHeaderProps) {
49
+ const { title, right, backLabel = "Back" } = props;
50
+ const { goBack, canGoBack } = usePopoverNav();
51
+
52
+ return (
53
+ <View style={navHeaderStyles.container}>
54
+ {canGoBack && (
55
+ <IconButton icon="chevron-left" size="lg" color="secondary" accessibilityLabel={backLabel} onPress={goBack} />
56
+ )}
57
+ <Text size="sm" weight="medium" style={{ flex: 1 }}>
58
+ {title}
59
+ </Text>
60
+ {right}
61
+ </View>
62
+ );
63
+ }
64
+
65
+ const navHeaderStyles = StyleSheet.create({
66
+ container: {
67
+ paddingLeft: 8,
68
+ flexDirection: "row",
69
+ alignItems: "center",
70
+ gap: 8,
71
+ },
72
+ });
@@ -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",