@lotics/ui 6.0.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,9 +12,18 @@ typeahead, async search, virtualization, and a11y the primitive already ships.
12
12
  `PortalHost` at the app root.
13
13
  - RN-Web only: `View`/`ScrollView` from `react-native`, the `Text` primitive (no raw
14
14
  `div`/`span`, no raw `fontSize`/`fontWeight`); styles are RN objects.
15
- - i18n: most components are string-free; the few that emit their own user-facing strings take
16
- a `labels` prop (`DateRangeFilterField`, `Pagination`, `SortHeader`/`Table.sortLabels`,
17
- `RemainderMeter`) localize there.
15
+ - i18n: most components are string-free. For the rest, set the language ONCE at the root with
16
+ **`LoticsLocaleProvider`** (`@lotics/ui/locale`, the sibling of `LoticsThemeProvider`) — wrap
17
+ `<LoticsLocaleProvider locale={vi}>` and every wired component picks up the pack, no per-instance
18
+ props. Shipped packs: `en` (default) and `vi` (one canonical Vietnamese translation, so apps don't
19
+ redefine — or drift on — strings). Resolution is **prop → provider locale → English default**, so a
20
+ per-instance `labels`/`clearLabel`/`selectAllLabel` still overrides for one-offs, and an un-wrapped
21
+ app stays English. Wired so far: `Pagination`, `SortHeader`/`Table.sortLabels`, `OptionList`
22
+ (+ `Select`/`Combobox` built on it), `FilterChip`, `Drawer`, `Confidence`, `RemainderMeter`,
23
+ `ChangeReview`/`ChangeReviewActions`, and `DateRangeFilterField`/`DateFilter` (presets + footer +
24
+ time-segment a11y, one `dateRange` slice). Adding a string to a wired component's `*Labels` forces
25
+ both packs in `locale.tsx` to fill it (compile error) — that's how the kit avoids a silent English
26
+ leak. Not yet on the provider (pass `labels` for now): calendar/gantt, the file/preview/comment labels.
18
27
 
19
28
  ---
20
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "6.0.0",
3
+ "version": "6.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -56,6 +56,7 @@
56
56
  "./status_grid": "./src/status_grid.tsx",
57
57
  "./heatmap": "./src/heatmap.tsx",
58
58
  "./trend_footer": "./src/trend_footer.tsx",
59
+ "./locale": "./src/locale.tsx",
59
60
  "./spacing": "./src/spacing.ts",
60
61
  "./theme": "./src/theme.tsx",
61
62
  "./progress_bar": "./src/progress_bar.tsx",
@@ -5,6 +5,7 @@ import { reviewCardStyle, reviewResolvedStyle } from "./control_surface";
5
5
  import { Text } from "./text";
6
6
  import { Icon } from "./icon";
7
7
  import { Button } from "./button";
8
+ import { useLoticsLocale } from "./locale";
8
9
 
9
10
  export type ChangeReviewItemStatus = "pending" | "accepted" | "rejected";
10
11
 
@@ -116,17 +117,18 @@ export function ChangeDiff({ before, after }: { before?: string; after: string }
116
117
  * `ReviewCard` reviews a SINGLE proposal, this is the BATCH engine.
117
118
  */
118
119
  export function ChangeReview<T>(props: ChangeReviewProps<T>) {
120
+ const loc = useLoticsLocale().changeReview;
119
121
  const status = props.status ?? "open";
120
122
  const perItem = props.onAcceptItem != null || props.onRejectItem != null;
121
123
  const total = props.items.length;
122
124
  const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
123
125
  const keptCount = props.items.filter((it, i) => statusFor(it, i) === "accepted").length;
124
- const title = props.title ?? "Suggested edits";
126
+ const title = props.title ?? loc.title;
125
127
  const L = {
126
- undo: "Undo",
127
- applied: "Applied",
128
- discarded: "Discarded",
129
- keptCount: (kept: number, total: number) => `${kept} of ${total} kept`,
128
+ undo: loc.undo,
129
+ applied: loc.applied,
130
+ discarded: loc.discarded,
131
+ keptCount: loc.keptCount,
130
132
  ...props.labels,
131
133
  };
132
134
 
@@ -173,8 +175,8 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
173
175
  <View key={props.getKey(item, i)} style={styles.itemCard}>
174
176
  {props.renderItem(item, i)}
175
177
  <View style={styles.itemActions}>
176
- <Button title={props.rejectLabel ?? "Drop"} color="muted" onPress={() => props.onRejectItem?.(i)} />
177
- <Button title={props.acceptLabel ?? "Keep"} color="secondary" onPress={() => props.onAcceptItem?.(i)} />
178
+ <Button title={props.rejectLabel ?? loc.reject} color="muted" onPress={() => props.onRejectItem?.(i)} />
179
+ <Button title={props.acceptLabel ?? loc.accept} color="secondary" onPress={() => props.onAcceptItem?.(i)} />
178
180
  </View>
179
181
  </View>
180
182
  );
@@ -233,6 +235,7 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
233
235
  * inline wrapper provides the chrome); self-hides once `status` isn't open.
234
236
  */
235
237
  export function ChangeReviewActions<T>(props: ChangeReviewActionsProps<T>) {
238
+ const loc = useLoticsLocale().changeReview;
236
239
  if ((props.status ?? "open") !== "open") return null;
237
240
  const perItem = props.onAcceptItem != null;
238
241
  const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
@@ -243,12 +246,12 @@ export function ChangeReviewActions<T>(props: ChangeReviewActionsProps<T>) {
243
246
  });
244
247
  return (
245
248
  <View style={styles.actionsRow}>
246
- {perItem ? <Button title={props.acceptAllLabel ?? "Accept all"} color="muted" onPress={acceptAll} /> : null}
249
+ {perItem ? <Button title={props.acceptAllLabel ?? loc.acceptAll} color="muted" onPress={acceptAll} /> : null}
247
250
  <View style={{ flex: 1 }} />
248
- {props.onDiscard ? <Button title={props.discardLabel ?? "Discard"} color="muted" onPress={props.onDiscard} /> : null}
251
+ {props.onDiscard ? <Button title={props.discardLabel ?? loc.discard} color="muted" onPress={props.onDiscard} /> : null}
249
252
  {props.onApply ? (
250
253
  <Button
251
- title={props.applyLabel ?? (perItem ? "Apply kept" : "Apply")}
254
+ title={props.applyLabel ?? (perItem ? loc.applyKept : loc.apply)}
252
255
  color="primary"
253
256
  disabled={perItem && keptCount === 0}
254
257
  onPress={props.onApply}
@@ -92,7 +92,8 @@ export interface ColumnFilterProps {
92
92
  column: FilterableColumn;
93
93
  value: ColumnFilterValue | undefined;
94
94
  onChange: (value: ColumnFilterValue | undefined) => void;
95
- /** Accessible name for the clear (X) control. Pass a translated string. Default "Clear". */
95
+ /** Accessible name for the clear (X) control. Omit to use the locale's
96
+ * `filterChip.clear` (English "Clear" by default). */
96
97
  clearLabel?: string;
97
98
  }
98
99
 
@@ -105,7 +106,7 @@ export interface ColumnFilterProps {
105
106
  * `columnFilterToConditions`. Pure UI; no data layer.
106
107
  */
107
108
  export function ColumnFilter(props: ColumnFilterProps) {
108
- const { column, value, onChange, clearLabel = "Clear" } = props;
109
+ const { column, value, onChange, clearLabel } = props;
109
110
  const active = isColumnFilterActive(value);
110
111
 
111
112
  return (
package/src/combobox.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { useCallback, useRef, useState, type ReactNode } from "react";
10
10
  import { colors } from "./colors";
11
11
  import { FOCUS_RING } from "./control_surface";
12
+ import { useLoticsLocale } from "./locale";
12
13
  import { Text } from "./text";
13
14
  import { Icon, type IconName } from "./icon";
14
15
  import { TextInputField } from "./text_input_field";
@@ -110,8 +111,6 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
110
111
  searchDebounceMs = 200,
111
112
  icon,
112
113
  placeholder,
113
- recentsLabel = "Recent",
114
- emptyText = "No results",
115
114
  accessibilityLabel = "Results",
116
115
  disabled = false,
117
116
  clearable = false,
@@ -121,6 +120,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
121
120
  testID,
122
121
  style,
123
122
  } = props;
123
+ const loc = useLoticsLocale().optionList;
124
+ const emptyText = props.emptyText ?? loc.noResults;
125
+ const recentsLabel = props.recentsLabel ?? loc.recent;
124
126
 
125
127
  const single = value ?? null;
126
128
  const reflectedText = single ? (single.label ?? single.value) : "";
@@ -1,6 +1,7 @@
1
1
  import { StyleSheet, View } from "react-native";
2
2
  import { colors, solid, type ColorName } from "./colors";
3
3
  import { Text } from "./text";
4
+ import { useLoticsLocale } from "./locale";
4
5
 
5
6
  export type ConfidenceLevel = "high" | "medium" | "low";
6
7
 
@@ -20,7 +21,6 @@ export interface ConfidenceProps {
20
21
  labels?: Partial<ConfidenceLabels>;
21
22
  }
22
23
 
23
- const DEFAULT_LABELS: ConfidenceLabels = { high: "High confidence", medium: "Medium confidence", low: "Low confidence" };
24
24
  const FILLED: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
25
25
  // high emerald, medium amber, low zinc (unsure — not an error). One family per
26
26
  // level; the fill count AND the colour both carry it.
@@ -40,7 +40,7 @@ export function levelFromScore(score: number): ConfidenceLevel {
40
40
  */
41
41
  export function Confidence(props: ConfidenceProps) {
42
42
  const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
43
- const l = { ...DEFAULT_LABELS, ...props.labels };
43
+ const l = { ...useLoticsLocale().confidence, ...props.labels };
44
44
  const filled = FILLED[level];
45
45
  const fill = solid(COLOR[level]);
46
46
  return (
@@ -11,6 +11,7 @@ import { useScreenSize } from "./use_screen_size";
11
11
  import { SegmentLabels } from "./date_segments";
12
12
  import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
13
13
  import { formatDate } from "./format_date";
14
+ import { useLoticsLocale } from "./locale";
14
15
 
15
16
  type SelectionMode = "single" | "range";
16
17
 
@@ -42,25 +43,6 @@ export interface DateFilterLabels extends SegmentLabels {
42
43
  selectDate: string;
43
44
  }
44
45
 
45
- const DEFAULT_LABELS: DateFilterLabels = {
46
- year: "Year",
47
- month: "Month",
48
- day: "Day",
49
- hour: "Hour",
50
- minute: "Minute",
51
- dayPeriod: "AM/PM",
52
- today: "Today",
53
- yesterday: "Yesterday",
54
- tomorrow: "Tomorrow",
55
- thisWeek: "This week",
56
- thisMonth: "This month",
57
- lastMonth: "Last month",
58
- custom: "Custom",
59
- from: "From",
60
- to: "To",
61
- selectDateRange: "Select date range",
62
- selectDate: "Select date",
63
- };
64
46
 
65
47
  export interface DateFilterProps {
66
48
  value: DateFilterValue;
@@ -106,9 +88,10 @@ function formatDateDisplay(date: Date | null, locale: string | undefined): strin
106
88
 
107
89
  export function DateFilter(props: DateFilterProps) {
108
90
  const { value, onValueChange, includeTime = false, locale } = props;
91
+ const loc = useLoticsLocale().dateRange;
109
92
  const labels = useMemo<DateFilterLabels>(
110
- () => ({ ...DEFAULT_LABELS, ...props.labels }),
111
- [props.labels],
93
+ () => ({ ...loc, ...props.labels }),
94
+ [loc, props.labels],
112
95
  );
113
96
  const screenSize = useScreenSize();
114
97
  const calendarRef = useRef<CalendarRef>(null);
@@ -9,6 +9,7 @@ import { PressableHighlight } from "./pressable_highlight";
9
9
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
10
10
  import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
11
11
  import { formatDate } from "./format_date";
12
+ import { useLoticsLocale } from "./locale";
12
13
 
13
14
  // =============================================================================
14
15
  // DateRangeFilterField — the common filter composition over DateFilter:
@@ -27,7 +28,6 @@ export interface DateRangeFilterFieldLabels extends DateFilterLabels {
27
28
  placeholder: string;
28
29
  }
29
30
 
30
- const DEFAULT_FIELD_LABELS = { clear: "Clear", done: "Done", placeholder: "All time" };
31
31
 
32
32
  export interface DateRangeFilterFieldProps {
33
33
  value: DateFilterValue;
@@ -110,7 +110,8 @@ function formatTrigger(value: DateFilterValue, includeTime: boolean, locale: str
110
110
 
111
111
  export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
112
112
  const { value, onValueChange, includeTime, locale, testID } = props;
113
- const labels = useMemo(() => ({ ...DEFAULT_FIELD_LABELS, ...props.labels }), [props.labels]);
113
+ const loc = useLoticsLocale().dateRange;
114
+ const labels = useMemo(() => ({ ...loc, ...props.labels }), [loc, props.labels]);
114
115
  const [open, setOpen] = useState(false);
115
116
 
116
117
  const hasValue = Boolean(value.start.date || value.end.date);
package/src/drawer.tsx CHANGED
@@ -6,6 +6,7 @@ import { colors } from "@lotics/ui/colors";
6
6
  import { IconButton } from "@lotics/ui/icon_button";
7
7
  import { Text } from "@lotics/ui/text";
8
8
  import { useOverlayScope } from "@lotics/ui/overlay_scope";
9
+ import { useLoticsLocale } from "@lotics/ui/locale";
9
10
 
10
11
  export interface DrawerProps {
11
12
  open: boolean;
@@ -44,6 +45,7 @@ export interface DrawerProps {
44
45
  */
45
46
  export function Drawer(props: DrawerProps) {
46
47
  const { open, onOpenChange, title, width = 420, onPrev, onNext, position, children, testID } = props;
48
+ const loc = useLoticsLocale().drawer;
47
49
  const screenSize = useScreenSize();
48
50
  useOverlayScope(open);
49
51
  const handleClose = () => onOpenChange(false);
@@ -79,7 +81,7 @@ export function Drawer(props: DrawerProps) {
79
81
  <Modal visible={open} onRequestClose={handleClose} transparent>
80
82
  <View style={styles.base}>
81
83
  {/* Scrim is a sibling of the panel, so tapping the panel never closes. */}
82
- <Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel="Close" tabIndex={-1} />
84
+ <Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel={loc.close} tabIndex={-1} />
83
85
  <View style={[styles.panel, { width: screenSize.small ? "100%" : width }]}>
84
86
  <PortalHost>
85
87
  <View style={styles.header}>
@@ -94,7 +96,7 @@ export function Drawer(props: DrawerProps) {
94
96
  <View style={styles.nav}>
95
97
  <IconButton
96
98
  icon="chevron-left"
97
- accessibilityLabel="Previous record"
99
+ accessibilityLabel={loc.previous}
98
100
  onPress={onPrev ?? (() => {})}
99
101
  disabled={!onPrev}
100
102
  />
@@ -105,13 +107,13 @@ export function Drawer(props: DrawerProps) {
105
107
  ) : null}
106
108
  <IconButton
107
109
  icon="chevron-right"
108
- accessibilityLabel="Next record"
110
+ accessibilityLabel={loc.next}
109
111
  onPress={onNext ?? (() => {})}
110
112
  disabled={!onNext}
111
113
  />
112
114
  </View>
113
115
  ) : null}
114
- <IconButton icon="x" size="lg" accessibilityLabel="Close" onPress={handleClose} />
116
+ <IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
115
117
  </View>
116
118
  <View testID={testID} style={styles.body}>
117
119
  {children}
@@ -7,6 +7,7 @@ import { TextLink } from "./text_link";
7
7
  import { Chip } from "./chip";
8
8
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
9
9
  import type { PopoverSide, PopoverAlign } from "./popover";
10
+ import { useLoticsLocale } from "./locale";
10
11
 
11
12
  export interface FilterChipProps {
12
13
  /** The dimension name — shown alone when inactive ("Owner"), prefixed when
@@ -69,7 +70,8 @@ export function selectSummary(
69
70
  * toolbar's `ColumnFilter` is this pill plus its query-condition mapping.
70
71
  */
71
72
  export function FilterChip(props: FilterChipProps) {
72
- const { label, summary, onClear, clearLabel = "Clear", children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
73
+ const { label, summary, onClear, children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
74
+ const clearLabel = props.clearLabel ?? useLoticsLocale().filterChip.clear;
73
75
  const active = summary != null && (typeof summary !== "string" || summary.length > 0);
74
76
  // The clear × / Clear footer only when there's a clearable selection AND no
75
77
  // custom footer — a valued, non-clearable pill ("Target: 20") keeps its chevron.
package/src/locale.tsx ADDED
@@ -0,0 +1,190 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { type PaginationLabels } from "./pagination";
3
+ import { type SortHeaderLabels } from "./sort_header";
4
+ import { type ConfidenceLabels } from "./confidence";
5
+ import { type RemainderMeterLabels } from "./remainder_meter";
6
+ import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
7
+
8
+ /**
9
+ * The kit's localizable strings, one slice per string-bearing component. A
10
+ * `LoticsLocale` is plain DATA (like a date-fns locale) — it carries no i18n
11
+ * LOGIC, so the kit stays translation-agnostic while an app supplies the pack
12
+ * once at its root. Each slice REUSES the component's own `*Labels` type, so
13
+ * adding a localizable string to a component (a new key on its `Labels`) forces
14
+ * BOTH packs below to fill it — a missing translation is a compile error here,
15
+ * never a silent English leak at a call site.
16
+ *
17
+ * Resolution order in every wired component: an explicit per-instance prop wins,
18
+ * else this locale (from `LoticsLocaleProvider`, default `en`), else nothing —
19
+ * the locale is always complete, so there is no third fallback to forget.
20
+ */
21
+ export interface LoticsLocale {
22
+ /** `Pagination` range + page summaries and the prev/next tooltips. */
23
+ pagination: Required<PaginationLabels>;
24
+ /** `SortHeader` a11y prefix + asc/desc suffixes. */
25
+ sortHeader: Required<SortHeaderLabels>;
26
+ /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
27
+ * in-cell editors): the select-all/deselect-all links, the empty state, and
28
+ * the `Combobox` recents header. */
29
+ optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string };
30
+ /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
31
+ * a call site doesn't pass a dimension-specific `clearLabel`. */
32
+ filterChip: { clear: string };
33
+ /** `Drawer`: the record prev/next + close controls (screen-reader names). */
34
+ drawer: { previous: string; next: string; close: string };
35
+ /** `Confidence`: the full level phrase ("High confidence" …). */
36
+ confidence: ConfidenceLabels;
37
+ /** `RemainderMeter`: the applied / remaining / over / exact captions. */
38
+ remainderMeter: Required<RemainderMeterLabels>;
39
+ /** `ChangeReview` (+ `ChangeReviewActions`): the review-card chrome — the
40
+ * Keep/Drop verdicts, Undo, the Applied/Discarded tags, the kept-counter,
41
+ * the title, and the commit-bar Accept-all / Discard. */
42
+ changeReview: {
43
+ title: string;
44
+ accept: string;
45
+ reject: string;
46
+ undo: string;
47
+ applied: string;
48
+ discarded: string;
49
+ keptCount: (kept: number, total: number) => string;
50
+ acceptAll: string;
51
+ discard: string;
52
+ apply: string;
53
+ applyKept: string;
54
+ };
55
+ /** `DateRangeFilterField` (and the `DateFilter` panel it wraps): presets,
56
+ * from/to, the footer Clear/Done, the trigger placeholder, and the time-field
57
+ * segment a11y names. */
58
+ dateRange: DateRangeFilterFieldLabels;
59
+ }
60
+
61
+ /** The platform default — English. Every component's hardcoded default lives
62
+ * HERE now, so the strings are documented in one place and overridable. */
63
+ export const en: LoticsLocale = {
64
+ pagination: {
65
+ rangeWithTotal: (start, end, total) => `${start}–${end} of ${total.toLocaleString()}`,
66
+ range: (start, end) => `${start}–${end}`,
67
+ pageWithTotal: (page, pageCount) => `Page ${page} of ${pageCount}`,
68
+ page: (page) => `Page ${page}`,
69
+ previous: "Previous",
70
+ next: "Next",
71
+ },
72
+ sortHeader: {
73
+ sortBy: (label) => `Sort by ${label}`,
74
+ ascending: ", ascending",
75
+ descending: ", descending",
76
+ },
77
+ optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent" },
78
+ filterChip: { clear: "Clear" },
79
+ drawer: { previous: "Previous record", next: "Next record", close: "Close" },
80
+ confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
81
+ remainderMeter: {
82
+ applied: (allocated, total) => `${allocated} of ${total} applied`,
83
+ remaining: (remainder) => `${remainder} unapplied`,
84
+ over: (amount) => `Over by ${amount}`,
85
+ exact: "Fully applied",
86
+ },
87
+ changeReview: {
88
+ title: "Suggested edits",
89
+ accept: "Keep",
90
+ reject: "Drop",
91
+ undo: "Undo",
92
+ applied: "Applied",
93
+ discarded: "Discarded",
94
+ keptCount: (kept, total) => `${kept} of ${total} kept`,
95
+ acceptAll: "Accept all",
96
+ discard: "Discard",
97
+ apply: "Apply",
98
+ applyKept: "Apply kept",
99
+ },
100
+ dateRange: {
101
+ year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
102
+ today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow",
103
+ thisWeek: "This week", thisMonth: "This month", lastMonth: "Last month",
104
+ custom: "Custom", from: "From", to: "To",
105
+ selectDateRange: "Select date range", selectDate: "Select date",
106
+ clear: "Clear", done: "Done", placeholder: "All time",
107
+ },
108
+ };
109
+
110
+ /** Vietnamese. Maintained once here so every app (and the frontend) shares one
111
+ * canonical translation — no per-app drift ("Chọn tất cả" vs "Chọn hết"). */
112
+ export const vi: LoticsLocale = {
113
+ pagination: {
114
+ rangeWithTotal: (start, end, total) => `${start}–${end} trên ${total.toLocaleString("vi-VN")}`,
115
+ range: (start, end) => `${start}–${end}`,
116
+ pageWithTotal: (page, pageCount) => `Trang ${page} / ${pageCount}`,
117
+ page: (page) => `Trang ${page}`,
118
+ previous: "Trang trước",
119
+ next: "Trang sau",
120
+ },
121
+ sortHeader: {
122
+ sortBy: (label) => `Sắp xếp theo ${label}`,
123
+ ascending: " (tăng dần)",
124
+ descending: " (giảm dần)",
125
+ },
126
+ optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", noResults: "Không có kết quả", recent: "Gần đây" },
127
+ filterChip: { clear: "Xóa" },
128
+ drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
129
+ confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
130
+ remainderMeter: {
131
+ applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
132
+ remaining: (remainder) => `Còn ${remainder}`,
133
+ over: (amount) => `Vượt ${amount}`,
134
+ exact: "Đã phân bổ đủ",
135
+ },
136
+ changeReview: {
137
+ title: "Đề xuất chỉnh sửa",
138
+ accept: "Giữ",
139
+ reject: "Bỏ",
140
+ undo: "Hoàn tác",
141
+ applied: "Đã áp dụng",
142
+ discarded: "Đã bỏ",
143
+ keptCount: (kept, total) => `Giữ ${kept}/${total}`,
144
+ acceptAll: "Giữ tất cả",
145
+ discard: "Hủy",
146
+ apply: "Áp dụng",
147
+ applyKept: "Áp dụng mục đã giữ",
148
+ },
149
+ dateRange: {
150
+ year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
151
+ today: "Hôm nay", yesterday: "Hôm qua", tomorrow: "Ngày mai",
152
+ thisWeek: "Tuần này", thisMonth: "Tháng này", lastMonth: "Tháng trước",
153
+ custom: "Tùy chọn", from: "Từ", to: "Đến",
154
+ selectDateRange: "Chọn khoảng ngày", selectDate: "Chọn ngày",
155
+ clear: "Xóa", done: "Xong", placeholder: "Tất cả thời gian",
156
+ },
157
+ };
158
+
159
+ const LoticsLocaleContext = createContext<LoticsLocale>(en);
160
+
161
+ interface LoticsLocaleProviderProps {
162
+ /** The locale pack supplying every kit string. Import a shipped pack
163
+ * (`import { vi } from "@lotics/ui/locale"`) or pass your own `LoticsLocale`. */
164
+ locale: LoticsLocale;
165
+ children: ReactNode;
166
+ }
167
+
168
+ /**
169
+ * App-root provider that supplies localized strings to @lotics/ui primitives —
170
+ * the sibling of `LoticsThemeProvider`. Wrap your top-level element once and
171
+ * every wired component picks up the pack; no per-instance label props:
172
+ *
173
+ * // src/main.tsx
174
+ * import { vi } from "@lotics/ui/locale";
175
+ * <LoticsLocaleProvider locale={vi}>
176
+ * <App />
177
+ * </LoticsLocaleProvider>
178
+ *
179
+ * Components read the locale via `useLoticsLocale()`. A per-instance label prop
180
+ * still overrides for one-offs; without a provider the English default applies,
181
+ * so an un-wrapped app behaves exactly as before.
182
+ */
183
+ export function LoticsLocaleProvider(props: LoticsLocaleProviderProps) {
184
+ return <LoticsLocaleContext.Provider value={props.locale}>{props.children}</LoticsLocaleContext.Provider>;
185
+ }
186
+
187
+ /** Read the current locale. Wired primitives call this; apps use the provider. */
188
+ export function useLoticsLocale(): LoticsLocale {
189
+ return useContext(LoticsLocaleContext);
190
+ }
@@ -10,6 +10,7 @@ import { ActivityIndicator } from "./activity_indicator";
10
10
  import { TextInputField } from "./text_input_field";
11
11
  import { useScreenSize } from "./use_screen_size";
12
12
  import { useOptionList, type UseOptionListParams } from "./use_option_list";
13
+ import { useLoticsLocale } from "./locale";
13
14
  import type { PickerOption } from "./picker";
14
15
 
15
16
  export interface OptionListProps<T extends string = string, MULTI extends boolean = false, D = unknown>
@@ -38,17 +39,11 @@ export interface OptionListProps<T extends string = string, MULTI extends boolea
38
39
  export function OptionList<T extends string, MULTI extends boolean = false, D = unknown>(
39
40
  props: OptionListProps<T, MULTI, D>,
40
41
  ) {
41
- const {
42
- testID,
43
- renderOptionContent,
44
- getOptionDescription,
45
- loading = false,
46
- emptyText = "No results",
47
- accessibilityLabel,
48
- selectAllLabel = "Select all",
49
- deselectAllLabel = "Deselect all",
50
- search,
51
- } = props;
42
+ const { testID, renderOptionContent, getOptionDescription, loading = false, accessibilityLabel, search } = props;
43
+ const loc = useLoticsLocale().optionList;
44
+ const emptyText = props.emptyText ?? loc.noResults;
45
+ const selectAllLabel = props.selectAllLabel ?? loc.selectAll;
46
+ const deselectAllLabel = props.deselectAllLabel ?? loc.deselectAll;
52
47
  const list = useOptionList(props);
53
48
  const { small } = useScreenSize();
54
49
 
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import { View, StyleSheet } from "react-native";
3
3
  import { IconButton } from "./icon_button";
4
4
  import { Text } from "./text";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  /**
7
8
  * Localizable strings for `Pagination`. Every field is optional; an omitted
@@ -25,12 +26,6 @@ export interface PaginationLabels {
25
26
  next?: string;
26
27
  }
27
28
 
28
- const defaultRangeWithTotal = (start: number, end: number, total: number): string =>
29
- `${start}–${end} of ${total.toLocaleString()}`;
30
- const defaultRange = (start: number, end: number): string => `${start}–${end}`;
31
- const defaultPageWithTotal = (page: number, pageCount: number): string => `Page ${page} of ${pageCount}`;
32
- const defaultPage = (page: number): string => `Page ${page}`;
33
-
34
29
  export interface PaginationProps {
35
30
  /** 0-indexed. */
36
31
  page: number;
@@ -59,20 +54,21 @@ export interface PaginationProps {
59
54
  */
60
55
  export function Pagination(props: PaginationProps): React.ReactNode {
61
56
  const { page, pageSize, rowCount, hasMore, total, loading, onPageChange, labels } = props;
57
+ const loc = useLoticsLocale().pagination;
62
58
  const start = page * pageSize + 1;
63
59
  const end = page * pageSize + rowCount;
64
60
  const showRange = !loading && rowCount > 0;
65
61
 
66
62
  const summary = showRange
67
63
  ? total !== undefined
68
- ? (labels?.rangeWithTotal ?? defaultRangeWithTotal)(start, end, total)
69
- : (labels?.range ?? defaultRange)(start, end)
64
+ ? (labels?.rangeWithTotal ?? loc.rangeWithTotal)(start, end, total)
65
+ : (labels?.range ?? loc.range)(start, end)
70
66
  : "";
71
67
 
72
68
  const pageLabel =
73
69
  total !== undefined
74
- ? (labels?.pageWithTotal ?? defaultPageWithTotal)(page + 1, Math.max(1, Math.ceil(total / pageSize)))
75
- : (labels?.page ?? defaultPage)(page + 1);
70
+ ? (labels?.pageWithTotal ?? loc.pageWithTotal)(page + 1, Math.max(1, Math.ceil(total / pageSize)))
71
+ : (labels?.page ?? loc.page)(page + 1);
76
72
 
77
73
  return (
78
74
  <View style={styles.container}>
@@ -90,14 +86,14 @@ export function Pagination(props: PaginationProps): React.ReactNode {
90
86
  color="secondary"
91
87
  onPress={() => onPageChange(Math.max(0, page - 1))}
92
88
  disabled={page === 0 || !!loading}
93
- tooltip={labels?.previous ?? "Previous"}
89
+ tooltip={labels?.previous ?? loc.previous}
94
90
  />
95
91
  <IconButton
96
92
  icon="chevron-right"
97
93
  color="secondary"
98
94
  onPress={() => onPageChange(page + 1)}
99
95
  disabled={!hasMore || !!loading}
100
- tooltip={labels?.next ?? "Next"}
96
+ tooltip={labels?.next ?? loc.next}
101
97
  />
102
98
  </View>
103
99
  </View>
@@ -1,6 +1,7 @@
1
1
  import { StyleSheet, View, type DimensionValue } from "react-native";
2
2
  import { colors, solid } from "./colors";
3
3
  import { Text } from "./text";
4
+ import { useLoticsLocale } from "./locale";
4
5
 
5
6
  /**
6
7
  * Override the meter's captions for localization. Each key is optional and
@@ -17,11 +18,6 @@ export interface RemainderMeterLabels {
17
18
  exact?: string;
18
19
  }
19
20
 
20
- const defaultApplied = (allocated: string, total: string) => `${allocated} of ${total} applied`;
21
- const defaultRemaining = (remainder: string) => `${remainder} unapplied`;
22
- const defaultOver = (amount: string) => `Over by ${amount}`;
23
- const DEFAULT_EXACT = "Fully applied";
24
-
25
21
  export interface RemainderMeterProps {
26
22
  /** The source amount being distributed — the payment, the available stock. */
27
23
  total: number;
@@ -43,20 +39,21 @@ export interface RemainderMeterProps {
43
39
  */
44
40
  export function RemainderMeter(props: RemainderMeterProps) {
45
41
  const { total, allocated, format = (n) => n.toLocaleString(), labels } = props;
42
+ const loc = useLoticsLocale().remainderMeter;
46
43
  const remainder = total - allocated;
47
44
  const state = remainder > 0 ? "under" : remainder < 0 ? "over" : "exact";
48
45
  const pct: DimensionValue = `${total <= 0 ? 0 : Math.min(100, (allocated / total) * 100)}%`;
49
46
  const barColor = state === "over" ? solid("red") : state === "exact" ? solid("emerald") : solid("blue");
50
47
  const right =
51
48
  state === "exact"
52
- ? (labels?.exact ?? DEFAULT_EXACT)
49
+ ? (labels?.exact ?? loc.exact)
53
50
  : state === "over"
54
- ? (labels?.over ?? defaultOver)(format(-remainder))
55
- : (labels?.remaining ?? defaultRemaining)(format(remainder));
51
+ ? (labels?.over ?? loc.over)(format(-remainder))
52
+ : (labels?.remaining ?? loc.remaining)(format(remainder));
56
53
  return (
57
54
  <View style={{ gap: 8 }}>
58
55
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
59
- <Text size="sm" color="muted" style={{ flex: 1 }}>{(labels?.applied ?? defaultApplied)(format(allocated), format(total))}</Text>
56
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{(labels?.applied ?? loc.applied)(format(allocated), format(total))}</Text>
60
57
  <Text size="sm" weight="medium" tabular color={state === "over" ? "danger" : state === "exact" ? "success" : "default"}>
61
58
  {right}
62
59
  </Text>
@@ -3,6 +3,7 @@ import { Text } from "./text";
3
3
  import { Icon } from "./icon";
4
4
  import { colors } from "./colors";
5
5
  import { PressableHighlight } from "./pressable_highlight";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  export type SortDir = "asc" | "desc";
8
9
  export interface SortState {
@@ -74,14 +75,11 @@ export interface SortHeaderProps {
74
75
  */
75
76
  export function SortHeader(props: SortHeaderProps) {
76
77
  const { label, sortKey, sort, onSort, align = "left", style, labels } = props;
78
+ const loc = useLoticsLocale().sortHeader;
77
79
  const active = sort?.key === sortKey;
78
80
  const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
79
- const dirText = active
80
- ? sort.dir === "asc"
81
- ? (labels?.ascending ?? ", ascending")
82
- : (labels?.descending ?? ", descending")
83
- : "";
84
- const sortByLabel = (labels?.sortBy ?? ((l: string) => `Sort by ${l}`))(label);
81
+ const dirText = active ? (sort.dir === "asc" ? (labels?.ascending ?? loc.ascending) : (labels?.descending ?? loc.descending)) : "";
82
+ const sortByLabel = (labels?.sortBy ?? loc.sortBy)(label);
85
83
 
86
84
  return (
87
85
  <PressableHighlight