@lotics/ui 11.8.1 → 11.8.2

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/docs/catalog.md CHANGED
@@ -370,7 +370,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
370
370
  - **`danger_zone`** — `DangerZone`: the destructive section — a soft danger-tinted frame
371
371
  (the kit's `tint`/`solid`, never raw hex) + a danger heading + a description + a
372
372
  destructive action slot (children, e.g. a `danger` Button); sits APART at the bottom of a
373
- record/settings surface.
373
+ record/settings surface. The heading defaults to the locale's `dangerZone.title`
374
+ ("Danger zone" / "Vùng nguy hiểm") — pass `title` only to override.
374
375
  - **`landmark`** — `Landmark`: the semantic region wrapper — `kind`
375
376
  banner|navigation|main|complementary|contentinfo|region maps to the matching HTML element
376
377
  on web for screen-reader landmark navigation, `accessibilityRole` on native;
@@ -390,8 +391,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
390
391
  - **`icon_button`** — `IconButton`: the icon-only circular action (see
391
392
  [Actions](#actions)).
392
393
  - **`back_button`** — `BackButton`: the chevron-left `IconButton` (lg, secondary) heading a
393
- screen/panel: `onPress` + translated `accessibilityLabel` (default "Back"); the one
394
- go-back glyph — don't hand-roll it.
394
+ screen/panel: `onPress` + `accessibilityLabel` (defaults to the locale's `nav.back` —
395
+ "Back" / "Quay lại"); the one go-back glyph — don't hand-roll it.
395
396
  - **`link`** — `Link`: the EXTERNAL hyperlink — fixed underline+blue + `role="link"`;
396
397
  `onPress` only (the consumer wires the opener).
397
398
  - **`text_link`** — `TextLink`: underlined text that's optionally an `onPress` action or an
@@ -804,7 +805,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
804
805
  browser file dialog imperatively — the trigger half behind every Add-file CTA; cancel
805
806
  resolves `[]` on modern engines.
806
807
  - **`file_dropzone`** — `FileDropzone`: the drag-drop capture well (`onFiles`, `accept`,
807
- `label`/`hint`/`dropLabel`, `height`); click falls back to a picker.
808
+ `label`/`hint`/`dropLabel`, `height`); click falls back to a picker. `label`/`dropLabel`
809
+ default from the locale's `fileDropzone` slice — pass them only to override.
808
810
  - **`files_editor`** — `FilesEditor` — THE all-in-one attachment field: `FileGrid` + a
809
811
  toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode
810
812
  (Select all · a Menu of Download/Share/Delete · Done; the per-tile ✕ is select-mode-only,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.8.1",
3
+ "version": "11.8.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/agent_run.tsx CHANGED
@@ -10,6 +10,7 @@ import { PressableHighlight } from "./pressable_highlight";
10
10
  import { Marker, type StepStatus } from "./stepper";
11
11
  import { AnimationFadeIn } from "./animation_fade_in";
12
12
  import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
13
+ import { useLoticsLocale, type LoticsLocale } from "./locale";
13
14
 
14
15
  export type AgentStepStatus = "running" | "done" | "error";
15
16
 
@@ -235,19 +236,20 @@ function StepBody({ label, detail, status }: { label: string; detail?: string; s
235
236
 
236
237
  // The press-to-open reveal for a tool step: the caller's `peek` wins; else Input /
237
238
  // Output / Error panels auto-built from the carried I/O; else nothing (no reveal).
238
- // Raw I/O stays OUT of the row — only shown here, on demand.
239
- function stepPeek(s: AgentRunStep): ReactNode {
239
+ // Raw I/O stays OUT of the row — only shown here, on demand. Panel titles come from
240
+ // the locale (the caller supplies a whole `peek` to override).
241
+ function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode {
240
242
  if (s.peek) return s.peek;
241
243
  const hasInput = s.input !== undefined;
242
244
  const hasOutput = s.output !== undefined;
243
245
  if (!hasInput && !hasOutput && !s.errorText) return null;
244
246
  return (
245
247
  <View style={{ gap: 8 }}>
246
- {hasInput ? <JsonPanel title="Input" value={stringifyData(s.input)} /> : null}
248
+ {hasInput ? <JsonPanel title={labels.input} value={stringifyData(s.input)} /> : null}
247
249
  {s.errorText ? (
248
- <JsonPanel title="Error" value={s.errorText} />
250
+ <JsonPanel title={labels.error} value={s.errorText} />
249
251
  ) : hasOutput ? (
250
- <JsonPanel title="Output" value={stringifyData(s.output)} />
252
+ <JsonPanel title={labels.output} value={stringifyData(s.output)} />
251
253
  ) : null}
252
254
  </View>
253
255
  );
@@ -256,7 +258,8 @@ function stepPeek(s: AgentRunStep): ReactNode {
256
258
  // A step's body, wrapped in a Peek when there's something to reveal (I/O or a
257
259
  // caller peek), plain otherwise.
258
260
  function StepContent({ s, label }: { s: AgentRunStep; label: string }) {
259
- const peek = stepPeek(s);
261
+ const locale = useLoticsLocale();
262
+ const peek = stepPeek(s, locale.agentRun);
260
263
  const body = <StepBody label={label} detail={s.detail} status={s.status} />;
261
264
  return peek ? (
262
265
  <Peek accessibilityLabel={label} content={peek}>
@@ -271,15 +274,16 @@ function StepContent({ s, label }: { s: AgentRunStep; label: string }) {
271
274
  // agent's reasoning never crowds the answer. Pulses while it's still streaming.
272
275
  function ReasoningDisclosure(props: { text: string; streaming?: boolean; expanded: boolean; onToggle: () => void }) {
273
276
  const { text, streaming, expanded, onToggle } = props;
277
+ const locale = useLoticsLocale();
274
278
  return (
275
279
  <View style={styles.group}>
276
- <PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel="Thinking" style={styles.row}>
280
+ <PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
277
281
  <View style={styles.dotCol}>
278
282
  <Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
279
283
  </View>
280
284
  <View style={styles.rowBody}>
281
285
  <Text size="sm" color="muted">
282
- {streaming ? "Thinking…" : "Thinking"}
286
+ {streaming ? locale.agentRun.thinkingStreaming : locale.agentRun.thinking}
283
287
  </Text>
284
288
  </View>
285
289
  {chevron(expanded ? "up" : "down")}
package/src/avatar.tsx CHANGED
@@ -3,6 +3,7 @@ import React from "react";
3
3
  import { View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
4
4
  import { Text } from "./text";
5
5
  import { colors } from "./colors";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  interface AvatarProps {
8
9
  size?: number;
@@ -20,7 +21,8 @@ interface AvatarProps {
20
21
  }
21
22
 
22
23
  export function Avatar(props: AvatarProps) {
23
- const { source, size = 32, name = "Unknown", style, contentFit, announce } = props;
24
+ const locale = useLoticsLocale();
25
+ const { source, size = 32, name = locale.avatar.unknown, style, contentFit, announce } = props;
24
26
  const decorative = !announce;
25
27
 
26
28
  if (!source || !source.uri) {
@@ -2,6 +2,7 @@ import type { ImageContentFit, ImageSource } from "expo-image";
2
2
  import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  interface AvatarProps {
7
8
  size?: number;
@@ -28,7 +29,8 @@ interface AvatarProps {
28
29
  * type-only imports so no expo-image module is ever loaded.
29
30
  */
30
31
  export function Avatar(props: AvatarProps) {
31
- const { source, size = 32, name = "Unknown", style, contentFit, announce } = props;
32
+ const locale = useLoticsLocale();
33
+ const { source, size = 32, name = locale.avatar.unknown, style, contentFit, announce } = props;
32
34
  const decorative = !announce;
33
35
 
34
36
  if (!source || !source.uri) {
@@ -1,14 +1,16 @@
1
1
  import { IconButton } from "./icon_button";
2
2
  import { View } from "react-native";
3
+ import { useLoticsLocale } from "./locale";
3
4
 
4
5
  interface BackButtonProps {
5
6
  onPress: () => void;
6
- /** Accessible name. Default: "Back". Pass a translated string from the consumer. */
7
+ /** Accessible name. Defaults to the locale's `nav.back` ("Back" / "Quay lại"). */
7
8
  accessibilityLabel?: string;
8
9
  }
9
10
 
10
11
  export function BackButton(props: BackButtonProps) {
11
- const { onPress, accessibilityLabel = "Back" } = props;
12
+ const locale = useLoticsLocale();
13
+ const { onPress, accessibilityLabel = locale.nav.back } = props;
12
14
  return (
13
15
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
14
16
  <IconButton
package/src/bar_chart.tsx CHANGED
@@ -2,6 +2,7 @@ import { View, StyleSheet } from "react-native";
2
2
  import { useMemo } from "react";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  const DEFAULT_BAR_COLOR = colors.blue[500];
7
8
 
@@ -66,11 +67,12 @@ function useAxisTicks(maxValue: number) {
66
67
  * one inline trend use `Sparkline`; for parts-of-a-whole, `PieChart` / `Breakdown`.
67
68
  */
68
69
  export function BarChart(props: BarChartProps) {
70
+ const locale = useLoticsLocale();
69
71
  const {
70
72
  data,
71
73
  orientation = "vertical",
72
74
  formatNumber = defaultFormatNumber,
73
- emptyLabel = "No data",
75
+ emptyLabel = locale.chart.noData,
74
76
  } = props;
75
77
  const maxValue = Math.max(...data.map((d) => d.value), 1);
76
78
  const axisTicks = useAxisTicks(maxValue);
package/src/composer.tsx CHANGED
@@ -5,6 +5,7 @@ import { colors } from "./colors";
5
5
  import { Text } from "./text";
6
6
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
7
7
  import { useAutoGrowHeight } from "./use_auto_grow_height";
8
+ import { useLoticsLocale } from "./locale";
8
9
 
9
10
  /**
10
11
  * The pure composer *chrome* — a bordered, auto-growing multiline text input
@@ -63,6 +64,7 @@ export interface ComposerProps {
63
64
  }
64
65
 
65
66
  export function Composer(props: ComposerProps) {
67
+ const locale = useLoticsLocale();
66
68
  const {
67
69
  onSend,
68
70
  onStop,
@@ -78,8 +80,8 @@ export function Composer(props: ComposerProps) {
78
80
  actionsButton,
79
81
  footerRight,
80
82
  maxLines = 10,
81
- sendLabel = "Send",
82
- stopLabel = "Stop",
83
+ sendLabel = locale.composer.send,
84
+ stopLabel = locale.composer.stop,
83
85
  } = props;
84
86
 
85
87
  const [internalText, setInternalText] = useState("");
@@ -3,9 +3,11 @@ import { View, StyleSheet } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { solid, tint } from "./colors";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  export interface DangerZoneProps {
8
- /** Section heading. Default "Danger zone". */
9
+ /** Section heading. Defaults to the locale's `dangerZone.title`
10
+ * ("Danger zone" / "Vùng nguy hiểm"). */
9
11
  title?: string;
10
12
  /** A short line spelling out the consequence of the action (it's usually
11
13
  * irreversible — say so here). */
@@ -23,7 +25,8 @@ export interface DangerZoneProps {
23
25
  * action(s) go in `children`.
24
26
  */
25
27
  export function DangerZone(props: DangerZoneProps) {
26
- const { title = "Danger zone", description, children } = props;
28
+ const locale = useLoticsLocale();
29
+ const { title = locale.dangerZone.title, description, children } = props;
27
30
  return (
28
31
  <View style={styles.zone}>
29
32
  <View style={styles.heading}>
package/src/dialog.tsx CHANGED
@@ -7,6 +7,7 @@ import { IconButton } from "@lotics/ui/icon_button";
7
7
  import { Text } from "@lotics/ui/text";
8
8
  import { BackButton } from "@lotics/ui/back_button";
9
9
  import { useOverlayScope } from "@lotics/ui/overlay_scope";
10
+ import { useLoticsLocale } from "@lotics/ui/locale";
10
11
  import {
11
12
  ScreenRouterContext,
12
13
  ScreenRouterInternalContext,
@@ -98,6 +99,7 @@ export function Dialog(props: DialogProps) {
98
99
 
99
100
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
100
101
  const screenSize = useScreenSize();
102
+ const locale = useLoticsLocale();
101
103
 
102
104
  const isControlled = controlledOpen !== undefined;
103
105
  const open = isControlled ? controlledOpen : uncontrolledOpen;
@@ -163,7 +165,7 @@ export function Dialog(props: DialogProps) {
163
165
  <PortalHost>
164
166
  <View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
165
167
  <View style={[styles.closeButtonContainer, { paddingHorizontal: screenSize.small ? 16 : 24 }]}>
166
- <IconButton icon="x" size="lg" accessibilityLabel="Close" onPress={handleClose} />
168
+ <IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
167
169
  </View>
168
170
  <View style={styles.container}>{children}</View>
169
171
  </View>
@@ -6,6 +6,7 @@ import { Text } from "./text";
6
6
  import { pickFiles } from "./file_picker";
7
7
  import { FOCUS_RING } from "./control_surface";
8
8
  import { useFocusRing } from "./use_focus_ring";
9
+ import { useLoticsLocale } from "./locale";
9
10
 
10
11
  export interface FileDropzoneProps {
11
12
  /** Receives the picked/dropped files. Bytes/upload belong to the host —
@@ -18,7 +19,9 @@ export interface FileDropzoneProps {
18
19
  multiple?: boolean;
19
20
  /** Main line ("Kéo thả tệp vào đây"). */
20
21
  label?: string;
21
- /** Secondary line ("hoặc bấm để chọn tệp · PDF, ảnh"). */
22
+ /** Secondary line. Defaults to the locale's `fileDropzone.hint` ("or click to
23
+ * browse" / "hoặc bấm để chọn"); override with an accepted-types line
24
+ * ("… · PDF, ảnh"). */
22
25
  hint?: string;
23
26
  /** Main line while a drag hovers the zone ("Thả để tải lên"). */
24
27
  dropLabel?: string;
@@ -52,13 +55,14 @@ function matchesAccept(file: File, accept: string | undefined): boolean {
52
55
  * `dropLabel`. On native (no drag events) it degrades to press-to-pick.
53
56
  */
54
57
  export function FileDropzone(props: FileDropzoneProps) {
58
+ const locale = useLoticsLocale();
55
59
  const {
56
60
  onFiles,
57
61
  accept,
58
62
  multiple = true,
59
- label = "Drag files here",
60
- hint = "or click to browse",
61
- dropLabel = "Drop to upload",
63
+ label = locale.fileDropzone.label,
64
+ hint = locale.fileDropzone.hint,
65
+ dropLabel = locale.fileDropzone.drop,
62
66
  height = 160,
63
67
  disabled = false,
64
68
  accessibilityLabel,
@@ -8,6 +8,7 @@ import { ActivityIndicator } from "./activity_indicator";
8
8
  import { colors } from "./colors";
9
9
  import { FOCUS_RING } from "./control_surface";
10
10
  import { useFocusRing } from "./use_focus_ring";
11
+ import { useLoticsLocale } from "./locale";
11
12
  import { useCallback, useState } from "react";
12
13
  import {
13
14
  GestureResponderEvent,
@@ -366,7 +367,8 @@ function TileScrim({ hovered, pressed }: { hovered?: boolean; pressed: boolean }
366
367
  return <View style={[styles.tileScrim, pressed && styles.tileScrimPressed]} pointerEvents="none" />;
367
368
  }
368
369
 
369
- export function RemoveButton({ onPress, label = "Remove" }: { onPress: () => void; label?: string }) {
370
+ export function RemoveButton({ onPress, label }: { onPress: () => void; label?: string }) {
371
+ const locale = useLoticsLocale();
370
372
  const handlePress = useCallback(
371
373
  (e: GestureResponderEvent) => {
372
374
  e.stopPropagation();
@@ -376,7 +378,7 @@ export function RemoveButton({ onPress, label = "Remove" }: { onPress: () => voi
376
378
  );
377
379
  return (
378
380
  <View style={styles.removeButtonAnchor}>
379
- <IconButton icon="x" size="sm" elevated accessibilityLabel={label} onPress={handlePress} />
381
+ <IconButton icon="x" size="sm" elevated accessibilityLabel={label ?? locale.fileThumbnail.remove} onPress={handlePress} />
380
382
  </View>
381
383
  );
382
384
  }
@@ -18,6 +18,7 @@ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
18
18
  import { FileGalleryModal } from "./file_gallery_modal";
19
19
  import { RotatableImage } from "./rotatable_image";
20
20
  import { useImageRotation } from "./use_image_rotation";
21
+ import { useLoticsLocale } from "./locale";
21
22
 
22
23
  const GAP = 12;
23
24
 
@@ -33,7 +34,8 @@ const rotateButtonStyle: ViewStyle = {
33
34
  export interface ImageGalleryProps {
34
35
  images: DisplayFile[];
35
36
  loading?: boolean;
36
- /** Shown when there are no images and not loading. Pass a translated string. */
37
+ /** Shown when there are no images and not loading. Defaults to the locale's
38
+ * `imageGallery.empty` ("No images." / "Chưa có ảnh."). */
37
39
  emptyText?: string;
38
40
  /** Optional max width. Omit to fill the container. */
39
41
  maxWidth?: number;
@@ -52,13 +54,14 @@ export interface ImageGalleryProps {
52
54
  export function ImageGallery({
53
55
  images,
54
56
  loading = false,
55
- emptyText = "No images.",
57
+ emptyText,
56
58
  maxWidth,
57
59
  thumbnailPosition = "bottom",
58
60
  mainAspectRatio = 4 / 3,
59
61
  mainFraction = 0.6,
60
62
  rotatable = true,
61
63
  }: ImageGalleryProps) {
64
+ const locale = useLoticsLocale();
62
65
  const [selected, setSelected] = useState(0);
63
66
  const [zoomIdx, setZoomIdx] = useState<number | null>(null);
64
67
  const [containerWidth, setContainerWidth] = useState(0);
@@ -66,7 +69,7 @@ export function ImageGallery({
66
69
  const rotation = useImageRotation();
67
70
 
68
71
  if (loading) return <ActivityIndicator />;
69
- if (images.length === 0) return <Text size="sm" color="muted">{emptyText}</Text>;
72
+ if (images.length === 0) return <Text size="sm" color="muted">{emptyText ?? locale.imageGallery.empty}</Text>;
70
73
 
71
74
  const idx = Math.min(selected, images.length - 1);
72
75
  const main = images[idx];
@@ -79,7 +82,7 @@ export function ImageGallery({
79
82
  <View style={{ width: "100%" }}>
80
83
  <FocusRingPressable accessibilityRole="button"
81
84
  onPress={() => setZoomIdx(idx)}
82
- accessibilityLabel="Zoom image"
85
+ accessibilityLabel={locale.imageGallery.zoom}
83
86
  style={{
84
87
  width: "100%",
85
88
  aspectRatio: mainAspectRatio,
@@ -97,14 +100,14 @@ export function ImageGallery({
97
100
  <View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
98
101
  <FocusRingPressable accessibilityRole="button"
99
102
  onPress={() => rotation.rotate(main.id, -1)}
100
- accessibilityLabel="Rotate left"
103
+ accessibilityLabel={locale.imageGallery.rotateLeft}
101
104
  style={rotateButtonStyle}
102
105
  >
103
106
  <Icon name="rotate-ccw" size={16} color={colors.white} />
104
107
  </FocusRingPressable>
105
108
  <FocusRingPressable accessibilityRole="button"
106
109
  onPress={() => rotation.rotate(main.id, 1)}
107
- accessibilityLabel="Rotate right"
110
+ accessibilityLabel={locale.imageGallery.rotateRight}
108
111
  style={rotateButtonStyle}
109
112
  >
110
113
  <Icon name="rotate-cw" size={16} color={colors.white} />
@@ -4,11 +4,13 @@ import type { PopoverSide, PopoverAlign } from "./popover";
4
4
  import { IconButton } from "./icon_button";
5
5
  import { Text } from "./text";
6
6
  import { colors } from "./colors";
7
+ import { useLoticsLocale } from "./locale";
7
8
 
8
9
  export interface InfoPopoverProps {
9
10
  /** Explanatory text shown when the popover opens. */
10
11
  text: string;
11
- /** Accessible name for the trigger button. */
12
+ /** Accessible name for the trigger button. Defaults to the locale's
13
+ * `infoPopover.more` ("More information" / "Thông tin thêm"). */
12
14
  accessibilityLabel?: string;
13
15
  /** Popover placement relative to the trigger. */
14
16
  side?: PopoverSide;
@@ -25,7 +27,8 @@ export interface InfoPopoverProps {
25
27
  * label) without inflating the line.
26
28
  */
27
29
  export function InfoPopover(props: InfoPopoverProps) {
28
- const { text, accessibilityLabel = "More information", side = "bottom", align = "end" } = props;
30
+ const locale = useLoticsLocale();
31
+ const { text, accessibilityLabel = locale.infoPopover.more, side = "bottom", align = "end" } = props;
29
32
  return (
30
33
  <Popover side={side} align={align}>
31
34
  <PopoverTrigger>
@@ -3,6 +3,7 @@ import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
  import { useMemo, useState, useCallback } from "react";
5
5
  import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  export interface LineChartPoint {
8
9
  x: string | number;
@@ -28,12 +29,13 @@ const defaultFormatXLabel = (x: string | number): string => String(x);
28
29
  * axes, `lineColor`, `height`. (No recharts.) For a tiny inline trend use `Sparkline`.
29
30
  */
30
31
  export function LineChart(props: LineChartProps) {
32
+ const locale = useLoticsLocale();
31
33
  const {
32
34
  points: data,
33
35
  height: chartHeight = 160,
34
36
  formatNumber = defaultFormatNumber,
35
37
  formatXLabel = defaultFormatXLabel,
36
- emptyLabel = "No data",
38
+ emptyLabel = locale.chart.noData,
37
39
  lineColor = colors.blue[500],
38
40
  } = props;
39
41
 
package/src/locale.tsx CHANGED
@@ -41,6 +41,8 @@ export interface LoticsLocale {
41
41
  floatingActionBar: { clear: string };
42
42
  /** `FormField`: the "Optional" marker shown next to the label when `optional`. */
43
43
  formField: { optional: string };
44
+ /** `DangerZone`: the section heading shown when no `title` prop is given. */
45
+ dangerZone: { title: string };
44
46
  /** `Drawer`: the record prev/next + close controls (screen-reader names). */
45
47
  drawer: { previous: string; next: string; close: string };
46
48
  /** The `Inline*` editor family: the shared save-error line and the
@@ -88,6 +90,38 @@ export interface LoticsLocale {
88
90
  * (open/download/remove + the confirm), the gallery chrome (close, prev/next,
89
91
  * rotate), and the preview captions (not-available / load-failed / password). */
90
92
  gallery: GalleryLabels;
93
+ /** `Avatar`: the fallback name (initials + a11y label) shown when no `name`. */
94
+ avatar: { unknown: string };
95
+ /** `BackButton` + `PopoverNavHeader`: the back-chevron's a11y name. */
96
+ nav: { back: string };
97
+ /** `BarChart` / `LineChart` / `PieChart`: the empty-state caption and the
98
+ * `PieChart` center total's caption. */
99
+ chart: { noData: string; total: string };
100
+ /** `Composer`: the send / stop button a11y + tooltip names. */
101
+ composer: { send: string; stop: string };
102
+ /** `Dialog` / `Modal` / `Popover`: the dismiss control's a11y / tooltip name. */
103
+ overlay: { close: string };
104
+ /** `FileDropzone`: the resting main line, the secondary hint, and the
105
+ * drag-hover line. `hint` is a generic default — apps override the prop with
106
+ * an accepted-types line ("… · PDF, ảnh"). */
107
+ fileDropzone: { label: string; hint: string; drop: string };
108
+ /** `FileThumbnail`'s `RemoveButton`: the ✕ a11y name. */
109
+ fileThumbnail: { remove: string };
110
+ /** `ImageGallery`: the empty state, the inline rotate controls, and the
111
+ * press-to-zoom a11y name. */
112
+ imageGallery: { empty: string; rotateLeft: string; rotateRight: string; zoom: string };
113
+ /** `InfoPopover`: the ⓘ trigger's a11y name. */
114
+ infoPopover: { more: string };
115
+ /** `Matrix`: the total column/row header and the legend's less→more ends. */
116
+ matrix: { total: string; less: string; more: string };
117
+ /** `ScrollToBottom`: the jump-to-latest tooltip. */
118
+ scrollToBottom: { tooltip: string };
119
+ /** `TextInputField`: the clear-button tooltip. */
120
+ textInputField: { clear: string };
121
+ /** `AgentRun`: the reasoning disclosure's label (settled / streaming) and the
122
+ * auto-built tool peek's Input / Error / Output panel titles. (Tool-step labels
123
+ * and the "{n} steps" suffix stay prop-localized — `labelForTool` / `stepsLabel`.) */
124
+ agentRun: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string };
91
125
  }
92
126
 
93
127
  /** The platform default — English. Every component's hardcoded default lives
@@ -112,6 +146,7 @@ export const en: LoticsLocale = {
112
146
  filterChip: { clear: "Clear" },
113
147
  floatingActionBar: { clear: "Clear" },
114
148
  formField: { optional: "Optional" },
149
+ dangerZone: { title: "Danger zone" },
115
150
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
116
151
  inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
117
152
  ledger: { rowDetails: (label) => `${label} details` },
@@ -168,6 +203,19 @@ export const en: LoticsLocale = {
168
203
  download: "Download file",
169
204
  passwordProtected: "This file is password-protected and cannot be previewed",
170
205
  },
206
+ avatar: { unknown: "Unknown" },
207
+ nav: { back: "Back" },
208
+ chart: { noData: "No data", total: "Total" },
209
+ composer: { send: "Send", stop: "Stop" },
210
+ overlay: { close: "Close" },
211
+ fileDropzone: { label: "Drag files here", hint: "or click to browse", drop: "Drop to upload" },
212
+ fileThumbnail: { remove: "Remove" },
213
+ imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
214
+ infoPopover: { more: "More information" },
215
+ matrix: { total: "Total", less: "Less", more: "More" },
216
+ scrollToBottom: { tooltip: "Scroll to bottom" },
217
+ textInputField: { clear: "Clear" },
218
+ agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output" },
171
219
  };
172
220
 
173
221
  /** Vietnamese. Maintained once here so every app (and the frontend) shares one
@@ -192,6 +240,7 @@ export const vi: LoticsLocale = {
192
240
  filterChip: { clear: "Xóa" },
193
241
  floatingActionBar: { clear: "Bỏ chọn" },
194
242
  formField: { optional: "Tùy chọn" },
243
+ dangerZone: { title: "Vùng nguy hiểm" },
195
244
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
196
245
  inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
197
246
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
@@ -248,6 +297,19 @@ export const vi: LoticsLocale = {
248
297
  download: "Tải xuống",
249
298
  passwordProtected: "Tệp có mật khẩu — không xem trước được",
250
299
  },
300
+ avatar: { unknown: "Không rõ" },
301
+ nav: { back: "Quay lại" },
302
+ chart: { noData: "Không có dữ liệu", total: "Tổng" },
303
+ composer: { send: "Gửi", stop: "Dừng" },
304
+ overlay: { close: "Đóng" },
305
+ fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm để chọn", drop: "Thả để tải lên" },
306
+ fileThumbnail: { remove: "Xóa" },
307
+ imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
308
+ infoPopover: { more: "Thông tin thêm" },
309
+ matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
310
+ scrollToBottom: { tooltip: "Cuộn xuống cuối" },
311
+ textInputField: { clear: "Xóa" },
312
+ agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả" },
251
313
  };
252
314
 
253
315
  const LoticsLocaleContext = createContext<LoticsLocale>(en);
package/src/matrix.tsx CHANGED
@@ -4,6 +4,7 @@ import { colors, withAlpha, type ColorName } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { FocusRingPressable } from "./focus_ring_pressable";
6
6
  import { matrixTotals, type MatrixAxisItem, type MatrixCellRef } from "./matrix_totals";
7
+ import { useLoticsLocale } from "./locale";
7
8
 
8
9
  type Display = "number" | "heat" | "both";
9
10
 
@@ -121,8 +122,10 @@ export interface MatrixHeaderProps {
121
122
  totalLabel?: string;
122
123
  }
123
124
 
124
- function MatrixHeader({ corner, totalLabel = "Total" }: MatrixHeaderProps) {
125
+ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
125
126
  const { cols, rowLabelWidth, totalColWidth, hasTotals } = useMatrix();
127
+ const locale = useLoticsLocale();
128
+ const resolvedTotalLabel = totalLabel ?? locale.matrix.total;
126
129
  return (
127
130
  <View style={styles.headRow}>
128
131
  <View style={[styles.rowLabel, { width: rowLabelWidth }]}>
@@ -141,7 +144,7 @@ function MatrixHeader({ corner, totalLabel = "Total" }: MatrixHeaderProps) {
141
144
  ))}
142
145
  {hasTotals ? (
143
146
  <Text size="xs" color="muted" weight="medium" align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
144
- {totalLabel}
147
+ {resolvedTotalLabel}
145
148
  </Text>
146
149
  ) : null}
147
150
  </View>
@@ -240,12 +243,13 @@ export interface MatrixTotalsProps {
240
243
  label?: string;
241
244
  }
242
245
 
243
- function MatrixTotals({ label = "Total" }: MatrixTotalsProps) {
246
+ function MatrixTotals({ label }: MatrixTotalsProps) {
244
247
  const { cols, colTotals, grandTotal, formatValue, rowLabelWidth, totalColWidth } = useMatrix();
248
+ const locale = useLoticsLocale();
245
249
  return (
246
250
  <View style={styles.totalsRow}>
247
251
  <Text size="sm" weight="semibold" color="muted" numberOfLines={1} style={[styles.rowLabel, { width: rowLabelWidth }]}>
248
- {label}
252
+ {label ?? locale.matrix.total}
249
253
  </Text>
250
254
  {cols.map((c) => (
251
255
  <Text key={c.key} size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={styles.colCell}>
@@ -264,18 +268,19 @@ export interface MatrixLegendProps {
264
268
  moreLabel?: string;
265
269
  }
266
270
 
267
- function MatrixLegend({ lessLabel = "Less", moreLabel = "More" }: MatrixLegendProps) {
271
+ function MatrixLegend({ lessLabel, moreLabel }: MatrixLegendProps) {
268
272
  const { heatBase } = useMatrix();
273
+ const locale = useLoticsLocale();
269
274
  return (
270
275
  <View style={styles.scale}>
271
276
  <Text size="xs" color="muted">
272
- {lessLabel}
277
+ {lessLabel ?? locale.matrix.less}
273
278
  </Text>
274
279
  {[0.15, 0.36, 0.57, 0.78, 1].map((alpha) => (
275
280
  <View key={alpha} style={[styles.swatch, { backgroundColor: withAlpha(heatBase, alpha) }]} />
276
281
  ))}
277
282
  <Text size="xs" color="muted">
278
- {moreLabel}
283
+ {moreLabel ?? locale.matrix.more}
279
284
  </Text>
280
285
  </View>
281
286
  );
package/src/modal.tsx CHANGED
@@ -5,6 +5,7 @@ import { IconButton } from "@lotics/ui/icon_button";
5
5
  import { Text } from "@lotics/ui/text";
6
6
  import { PortalHost } from "@lotics/ui/portal";
7
7
  import { useOverlayScope } from "@lotics/ui/overlay_scope";
8
+ import { useLoticsLocale } from "@lotics/ui/locale";
8
9
 
9
10
  export interface ModalProps {
10
11
  open: boolean;
@@ -59,6 +60,7 @@ export interface ModalHeaderProps {
59
60
  */
60
61
  export function ModalHeader(props: ModalHeaderProps) {
61
62
  const { title, eyebrow, actions, onClose, style } = props;
63
+ const locale = useLoticsLocale();
62
64
 
63
65
  return (
64
66
  <View style={[styles.header, style]}>
@@ -83,7 +85,7 @@ export function ModalHeader(props: ModalHeaderProps) {
83
85
  ) : null}
84
86
  </View>
85
87
  {actions !== undefined ? <View style={styles.headerActions}>{actions}</View> : null}
86
- <IconButton icon="x" size="lg" accessibilityLabel="Close" onPress={onClose} />
88
+ <IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={onClose} />
87
89
  </View>
88
90
  );
89
91
  }
package/src/pie_chart.tsx CHANGED
@@ -3,6 +3,7 @@ import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
  import { useMemo, useState, useCallback } from "react";
5
5
  import Svg, { Path, G } from "react-native-svg";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  const FALLBACK_PIE_COLORS = [
8
9
  colors.blue[300],
@@ -40,13 +41,14 @@ export interface PieChartProps {
40
41
  }
41
42
 
42
43
  export function PieChart(props: PieChartProps) {
44
+ const locale = useLoticsLocale();
43
45
  const {
44
46
  slices: chartData,
45
47
  size = PIE_SIZE,
46
48
  showLegend = true,
47
49
  formatNumber = defaultFormatNumber,
48
- emptyLabel = "No data",
49
- centerLabel = "Total",
50
+ emptyLabel = locale.chart.noData,
51
+ centerLabel = locale.chart.total,
50
52
  } = props;
51
53
 
52
54
  const [isVerticalLayout, setIsVerticalLayout] = useState(false);
package/src/popover.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  snapshotOpenModalLayers,
20
20
  } from "./popover_layers";
21
21
  import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
22
+ import { useLoticsLocale } from "./locale";
22
23
 
23
24
  export type PopoverSide = "top" | "right" | "bottom" | "left";
24
25
  export type PopoverAlign = "start" | "center" | "end";
@@ -187,7 +188,7 @@ export interface PopoverContentProps {
187
188
  disableBodyScroll?: boolean;
188
189
  /** When true, renders as bottom sheet on small screens (close button, slide-up animation) */
189
190
  small?: boolean;
190
- /** Accessible name for the bottom-sheet close button. Default: "Close". Pass a translated string from the consumer. */
191
+ /** Accessible name for the bottom-sheet close button. Defaults to the locale's `overlay.close` ("Close" / "Đóng"). */
191
192
  closeLabel?: string;
192
193
  /** When true (default), the popover moves focus into its content on open and
193
194
  * restores it on close. Set false for an anchored panel whose trigger must
@@ -198,6 +199,7 @@ export interface PopoverContentProps {
198
199
  }
199
200
 
200
201
  export function PopoverContent(props: PopoverContentProps) {
202
+ const locale = useLoticsLocale();
201
203
  const {
202
204
  testID,
203
205
  children,
@@ -205,7 +207,7 @@ export function PopoverContent(props: PopoverContentProps) {
205
207
  contentContainerStyle,
206
208
  disableBodyScroll,
207
209
  small = false,
208
- closeLabel = "Close",
210
+ closeLabel = locale.overlay.close,
209
211
  manageFocus = true,
210
212
  } = props;
211
213
  const { open, onOpenChange, triggerRef, side, align, offset, inheritTriggerWidth } =
@@ -2,6 +2,7 @@ import { createContext, ReactNode, useContext } from "react";
2
2
  import { IconButton } from "./icon_button";
3
3
  import { View, StyleSheet } from "react-native";
4
4
  import { Text } from "./text";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  export interface PopoverNavContextValue {
7
8
  currentRoute: string;
@@ -37,7 +38,7 @@ export function PopoverScreen(props: PopoverScreenProps) {
37
38
  export interface PopoverNavHeaderProps {
38
39
  title: string;
39
40
  right?: ReactNode;
40
- /** Accessible name for the back button. Default: "Back". Pass a translated string from the consumer. */
41
+ /** Accessible name for the back button. Defaults to the locale's `nav.back` ("Back" / "Quay lại"). */
41
42
  backLabel?: string;
42
43
  }
43
44
 
@@ -46,7 +47,8 @@ export interface PopoverNavHeaderProps {
46
47
  * Pairs with `PopoverScreen`. Distinct from `Popover`'s own plain
47
48
  * `PopoverHeader` children container. */
48
49
  export function PopoverNavHeader(props: PopoverNavHeaderProps) {
49
- const { title, right, backLabel = "Back" } = props;
50
+ const locale = useLoticsLocale();
51
+ const { title, right, backLabel = locale.nav.back } = props;
50
52
  const { goBack, canGoBack } = usePopoverNav();
51
53
 
52
54
  return (
@@ -2,15 +2,18 @@ import { colors } from "./colors";
2
2
  import { StyleSheet } from "react-native";
3
3
  import { Icon } from "./icon";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  interface ScrollToBottomProps {
7
8
  onPress: () => void;
8
- /** Tooltip text. Default: "Scroll to bottom". Pass a translated string from the consumer. */
9
+ /** Tooltip text. Defaults to the locale's `scrollToBottom.tooltip`
10
+ * ("Scroll to bottom" / "Cuộn xuống cuối"). */
9
11
  tooltip?: string;
10
12
  }
11
13
 
12
14
  export function ScrollToBottom(props: ScrollToBottomProps) {
13
- const { onPress, tooltip = "Scroll to bottom" } = props;
15
+ const locale = useLoticsLocale();
16
+ const { onPress, tooltip = locale.scrollToBottom.tooltip } = props;
14
17
 
15
18
  return (
16
19
  <PressableHighlight
@@ -16,6 +16,7 @@ import { fontFamilyRegular, getInputLineHeight, getInputTextStyle } from "./text
16
16
  import { useScreenSize } from "./use_screen_size";
17
17
  import { useAutoGrowHeight } from "./use_auto_grow_height";
18
18
  import { useFormField } from "./form_field";
19
+ import { useLoticsLocale } from "./locale";
19
20
  import type { ShortcutDescriptor } from "./keyboard";
20
21
 
21
22
  interface TextInputFieldProps extends RNTextInputProps {
@@ -29,7 +30,8 @@ interface TextInputFieldProps extends RNTextInputProps {
29
30
  autoGrow?: boolean;
30
31
  /** Keyboard shortcut badge shown in the right slot when the input is empty. */
31
32
  shortcut?: string | ShortcutDescriptor;
32
- /** Accessible name for the clear button. Default: "Clear". Pass a translated string from the consumer. */
33
+ /** Accessible name for the clear button. Defaults to the locale's
34
+ * `textInputField.clear` ("Clear" / "Xóa"). */
33
35
  clearLabel?: string;
34
36
  // DOM-only ARIA attrs not declared on React Native's TextInputProps. They
35
37
  // are forwarded verbatim to the underlying web input.
@@ -39,6 +41,7 @@ interface TextInputFieldProps extends RNTextInputProps {
39
41
  }
40
42
 
41
43
  export function TextInputField(props: TextInputFieldProps) {
44
+ const locale = useLoticsLocale();
42
45
  const {
43
46
  style,
44
47
  icon,
@@ -51,7 +54,7 @@ export function TextInputField(props: TextInputFieldProps) {
51
54
  disabled,
52
55
  autoGrow,
53
56
  shortcut,
54
- clearLabel = "Clear",
57
+ clearLabel = locale.textInputField.clear,
55
58
  ref,
56
59
  "aria-controls": ariaControls,
57
60
  "aria-activedescendant": ariaActivedescendant,