@lotics/ui 6.0.0 → 6.1.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,17 @@ 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`. Adding a string to a wired component's
23
+ `*Labels` forces both packs in `locale.tsx` to fill it (compile error) — that's how the kit avoids a
24
+ silent English leak. Not yet on the provider (pass `labels` for now): `DateRangeFilterField`,
25
+ `RemainderMeter`, `ChangeReview`, calendar/gantt.
18
26
 
19
27
  ---
20
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "6.0.0",
3
+ "version": "6.1.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",
@@ -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) : "";
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,107 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { type PaginationLabels } from "./pagination";
3
+ import { type SortHeaderLabels } from "./sort_header";
4
+
5
+ /**
6
+ * The kit's localizable strings, one slice per string-bearing component. A
7
+ * `LoticsLocale` is plain DATA (like a date-fns locale) — it carries no i18n
8
+ * LOGIC, so the kit stays translation-agnostic while an app supplies the pack
9
+ * once at its root. Each slice REUSES the component's own `*Labels` type, so
10
+ * adding a localizable string to a component (a new key on its `Labels`) forces
11
+ * BOTH packs below to fill it — a missing translation is a compile error here,
12
+ * never a silent English leak at a call site.
13
+ *
14
+ * Resolution order in every wired component: an explicit per-instance prop wins,
15
+ * else this locale (from `LoticsLocaleProvider`, default `en`), else nothing —
16
+ * the locale is always complete, so there is no third fallback to forget.
17
+ */
18
+ export interface LoticsLocale {
19
+ /** `Pagination` range + page summaries and the prev/next tooltips. */
20
+ pagination: Required<PaginationLabels>;
21
+ /** `SortHeader` a11y prefix + asc/desc suffixes. */
22
+ sortHeader: Required<SortHeaderLabels>;
23
+ /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
24
+ * in-cell editors): the select-all/deselect-all links, the empty state, and
25
+ * the `Combobox` recents header. */
26
+ optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string };
27
+ /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
28
+ * a call site doesn't pass a dimension-specific `clearLabel`. */
29
+ filterChip: { clear: string };
30
+ /** `Drawer`: the record prev/next + close controls (screen-reader names). */
31
+ drawer: { previous: string; next: string; close: string };
32
+ }
33
+
34
+ /** The platform default — English. Every component's hardcoded default lives
35
+ * HERE now, so the strings are documented in one place and overridable. */
36
+ export const en: LoticsLocale = {
37
+ pagination: {
38
+ rangeWithTotal: (start, end, total) => `${start}–${end} of ${total.toLocaleString()}`,
39
+ range: (start, end) => `${start}–${end}`,
40
+ pageWithTotal: (page, pageCount) => `Page ${page} of ${pageCount}`,
41
+ page: (page) => `Page ${page}`,
42
+ previous: "Previous",
43
+ next: "Next",
44
+ },
45
+ sortHeader: {
46
+ sortBy: (label) => `Sort by ${label}`,
47
+ ascending: ", ascending",
48
+ descending: ", descending",
49
+ },
50
+ optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent" },
51
+ filterChip: { clear: "Clear" },
52
+ drawer: { previous: "Previous record", next: "Next record", close: "Close" },
53
+ };
54
+
55
+ /** Vietnamese. Maintained once here so every app (and the frontend) shares one
56
+ * canonical translation — no per-app drift ("Chọn tất cả" vs "Chọn hết"). */
57
+ export const vi: LoticsLocale = {
58
+ pagination: {
59
+ rangeWithTotal: (start, end, total) => `${start}–${end} trên ${total.toLocaleString("vi-VN")}`,
60
+ range: (start, end) => `${start}–${end}`,
61
+ pageWithTotal: (page, pageCount) => `Trang ${page} / ${pageCount}`,
62
+ page: (page) => `Trang ${page}`,
63
+ previous: "Trang trước",
64
+ next: "Trang sau",
65
+ },
66
+ sortHeader: {
67
+ sortBy: (label) => `Sắp xếp theo ${label}`,
68
+ ascending: " (tăng dần)",
69
+ descending: " (giảm dần)",
70
+ },
71
+ 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" },
72
+ filterChip: { clear: "Xóa" },
73
+ drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
74
+ };
75
+
76
+ const LoticsLocaleContext = createContext<LoticsLocale>(en);
77
+
78
+ interface LoticsLocaleProviderProps {
79
+ /** The locale pack supplying every kit string. Import a shipped pack
80
+ * (`import { vi } from "@lotics/ui/locale"`) or pass your own `LoticsLocale`. */
81
+ locale: LoticsLocale;
82
+ children: ReactNode;
83
+ }
84
+
85
+ /**
86
+ * App-root provider that supplies localized strings to @lotics/ui primitives —
87
+ * the sibling of `LoticsThemeProvider`. Wrap your top-level element once and
88
+ * every wired component picks up the pack; no per-instance label props:
89
+ *
90
+ * // src/main.tsx
91
+ * import { vi } from "@lotics/ui/locale";
92
+ * <LoticsLocaleProvider locale={vi}>
93
+ * <App />
94
+ * </LoticsLocaleProvider>
95
+ *
96
+ * Components read the locale via `useLoticsLocale()`. A per-instance label prop
97
+ * still overrides for one-offs; without a provider the English default applies,
98
+ * so an un-wrapped app behaves exactly as before.
99
+ */
100
+ export function LoticsLocaleProvider(props: LoticsLocaleProviderProps) {
101
+ return <LoticsLocaleContext.Provider value={props.locale}>{props.children}</LoticsLocaleContext.Provider>;
102
+ }
103
+
104
+ /** Read the current locale. Wired primitives call this; apps use the provider. */
105
+ export function useLoticsLocale(): LoticsLocale {
106
+ return useContext(LoticsLocaleContext);
107
+ }
@@ -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>
@@ -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