@lotics/ui 35.0.0 → 36.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.
package/MIGRATION.md CHANGED
@@ -4,6 +4,60 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 36.0.0 — seven components stopped announcing English; their strings moved to the pack
8
+
9
+ **Nothing to change if you pass a shipped pack** (`en` / `vi`) — both carry every
10
+ new entry, and no component API changed. A HAND-BUILT pack must add them:
11
+
12
+ ```ts
13
+ commentsButton: { comment: "bình luận", comments: "bình luận",
14
+ withSubject: (counted, subject) => `${counted} về ${subject}` },
15
+ infoPopover: { more: "Thông tin thêm", about: (label) => `Thông tin về ${label}` },
16
+ fileRow: { open: (name) => `Mở ${name}` },
17
+ sources: { open: (label) => `Mở ${label}`, heading: "Nguồn" },
18
+ counter: { decrease: (label) => `Giảm ${label}`, increase: (label) => `Tăng ${label}` },
19
+ allocationRow: { allocateTo: (label) => `Phân bổ vào ${label}`,
20
+ due: (amount) => `còn ${amount}`, clear: "Xoá", full: "Toàn bộ" },
21
+ columnFilter: { min: (label) => `${label} tối thiểu`, max: (label) => `${label} tối đa` },
22
+ ```
23
+
24
+ **Why.** These components built user-facing strings by concatenating an English
25
+ word onto caller data — `Open ${name}`, `About ${label}`, `${label} min`,
26
+ `${amount} due` — with no locale lookup at all. Most were accessible names, so a
27
+ Vietnamese app announced "Open hop_dong.pdf" to the one user who cannot see the
28
+ screen and has only the announcement; `Sources`' eyebrow and `AllocationRow`'s
29
+ caption and buttons were visibly English too.
30
+
31
+ `CommentsButton` is the same defect one step further along: it composed
32
+ `3 comments · Northwind`, and a middot is the one part of a string a screen reader
33
+ drops, so the mark carrying the relation never survived being read aloud.
34
+
35
+ **Every interpolated entry is a FUNCTION, not a word plus concatenation**, because
36
+ the added word sits in a different PLACE in different languages — `5,000,000 due`
37
+ against `còn 5.000.000`. A pack supplying only vocabulary would still be stuck with
38
+ English order. This is how every other interpolated string in a pack already works
39
+ (`sortHeader.sortBy`, `ledger.rowDetails`, `pagination.rangeWithTotal`).
40
+
41
+ Required rather than optional on purpose: both shipped packs are compile-forced
42
+ complete, and that is the mechanism that catches an untranslated string before it
43
+ ships.
44
+
45
+ `Checklist`'s phase toggle also lost a middot and needs nothing from you — its name
46
+ is now `Show Picking` rather than `Show · Picking`, verb then object.
47
+
48
+ ### `PressableHighlight` now sets `accessibilityRole="button"` itself
49
+
50
+ Nothing to pass; an explicit `accessibilityRole` still wins. Surfaces built on it
51
+ that never declared a role now announce as buttons and take a tab stop, which is
52
+ what the component always claimed to be — the docblock says "it IS a button", and
53
+ that is the whole reason its children must be non-interactive while `PressableRow`
54
+ stays role-less. In this package 18 of 35 call sites passed the role by hand and
55
+ every one passed `"button"`; the other 17 rendered a surface a screen reader could
56
+ name but not identify as pressable.
57
+
58
+ If a surface of yours is genuinely not a button, say so (`accessibilityRole="link"`,
59
+ …) — or reach for `PressableRow`, which carries no role by design.
60
+
7
61
  ## 35.0.0 — `FilterChip` has no Clear footer; the × is the clear
8
62
 
9
63
  `FilterChip` no longer renders a Clear button in its popover footer. **Nothing to
package/docs/catalog.md CHANGED
@@ -555,8 +555,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
555
555
  aims at, and splitting them leaves half the affordance dead under the pointer. FULL INK, unlike
556
556
  the muted values around it — everything else on a row is a fact ABOUT the record, this is people
557
557
  talking about it and the one thing there that can be UNREAD. `subject` reaches the accessible
558
- NAME only ("3 comments · Northwind Packaging" is a destination; "3 comments" on the fortieth row
559
- is not) — never the visible label, which would spend row width restating whose record it is.
558
+ NAME only ("3 comments on Northwind Packaging" is a destination; "3 comments" on the fortieth row
559
+ is not) — never the visible label, which would spend row width restating whose record it is. The
560
+ pack's `commentsButton.withSubject` builds that phrase, so the word ORDER is the pack's to choose
561
+ and the join is a WORD: this string exists to be read aloud, and a screen reader drops
562
+ punctuation along with the relation it was carrying.
560
563
  **Render it only when `count > 0`** — a zero on every quiet row is a column of noise that trains
561
564
  the eye to skip exactly where the signal will appear. Pair it with a jump that LANDS on the
562
565
  thread (`tpl_item_list` opens the record's drawer on its comments section), because a count the
@@ -634,7 +637,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
634
637
  designing a pressable surface it doesn't cover.
635
638
  - **`pressable_highlight`** — `PressableHighlight`: the hover-wash + keyboard-focus-ring
636
639
  `Pressable` under `MenuButton`/`Switcher`/custom pressable surfaces; its style-fn/children
637
- receive `hovered` + `focusVisible`.
640
+ receive `hovered` + `focusVisible`. It announces as `role="button"` by DEFAULT (pass
641
+ `accessibilityRole` to override), and that role is the line between it and
642
+ **`PressableRow`**, which stays role-less and out of the tab order: a button may not
643
+ contain interactive descendants, so a row carrying its own CTA / ⋯ / checkbox is a
644
+ `PressableRow` with the body as its door, never this. Give it an `accessibilityLabel` —
645
+ a button whose whole content is an icon or a chip announces as nothing otherwise.
638
646
  - **`focus_ring_pressable`** — `FocusRingPressable`: a Pressable that rings on keyboard
639
647
  focus; the raw-control default.
640
648
  - **`card_select_item`** — `CardSelectItem`: a bordered, card-shaped button; `selected` =
@@ -2131,10 +2131,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2131
2131
  "the Harbor Freight order", never "RC-2026-0418" — the code is how
2132
2132
  the SYSTEM refers to the record, which makes it a supporting value.
2133
2133
  The register's identity cell makes the same trade, so the two
2134
- surfaces name a record the same way. */}
2134
+ surfaces name a record the same way.
2135
+
2136
+ A COMMA joins them, never a middot — two identifying facts read as
2137
+ a list, and the kit bans the glyph outright (composition.md
2138
+ §Microcopy): it claims a relation while refusing to name it, and a
2139
+ screen reader drops it, taking the only thing that joined them. */}
2135
2140
  <RecordSummary
2136
2141
  title={customer ? customer.name : "No customer"}
2137
- subtitle={[code, customer?.city].filter(Boolean).join(" · ")}
2142
+ subtitle={[code, customer?.city].filter(Boolean).join(", ")}
2138
2143
  />
2139
2144
  </View>
2140
2145
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "35.0.0",
3
+ "version": "36.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/accordion.tsx CHANGED
@@ -101,7 +101,8 @@ export function AccordionHeader(props: AccordionHeaderProps) {
101
101
 
102
102
  return (
103
103
  <PressableHighlight
104
- focusRing onPress={toggle}
104
+ focusRing
105
+ onPress={toggle}
105
106
  accessibilityRole="button"
106
107
  // The W3C prop, NOT `accessibilityState` — this react-native-web build drops the
107
108
  // latter silently, so a disclosure shipped a `role="button"` that never announced
@@ -68,7 +68,8 @@ export function AgentProgress(props: AgentProgressProps) {
68
68
  ) : null}
69
69
 
70
70
  <PressableHighlight
71
- focusRing onPress={() => setExpanded((e) => !e)}
71
+ focusRing
72
+ onPress={() => setExpanded((e) => !e)}
72
73
  style={styles.pill}
73
74
  accessibilityLabel={expanded ? "Hide the agent's steps" : "Show the agent's steps"}
74
75
  >
@@ -3,6 +3,7 @@ import { View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Button } from "./button";
5
5
  import { NumberInput } from "./number_input";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  export interface AllocationRowProps {
8
9
  /** The target's primary line — an invoice no., an order, a cost centre. */
@@ -28,6 +29,7 @@ export interface AllocationRowProps {
28
29
  */
29
30
  export function AllocationRow(props: AllocationRowProps) {
30
31
  const { label, sublabel, cap, value, onValueChange, format = (n) => n.toLocaleString(), trailing } = props;
32
+ const locale = useLoticsLocale();
31
33
  const full = value >= cap;
32
34
  return (
33
35
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, minHeight: 60 }}>
@@ -38,17 +40,17 @@ export function AllocationRow(props: AllocationRowProps) {
38
40
  </View>
39
41
  {sublabel ? <Text size="xs" color="muted">{sublabel}</Text> : null}
40
42
  </View>
41
- <Text size="sm" color="muted" tabular style={{ width: 128, textAlign: "right" }}>{`${format(cap)} due`}</Text>
43
+ <Text size="sm" color="muted" tabular style={{ width: 128, textAlign: "right" }}>{locale.allocationRow.due(format(cap))}</Text>
42
44
  <View style={{ width: 150 }}>
43
45
  <NumberInput
44
46
  value={value || null}
45
47
  onValueChange={(n) => onValueChange(Math.max(0, Math.min(cap, n ?? 0)))}
46
48
  min={0}
47
49
  max={cap}
48
- accessibilityLabel={`Allocate to ${label}`}
50
+ accessibilityLabel={locale.allocationRow.allocateTo(label)}
49
51
  />
50
52
  </View>
51
- <Button title={full ? "Clear" : "Full"} color="muted" onPress={() => onValueChange(full ? 0 : cap)} />
53
+ <Button title={full ? locale.allocationRow.clear : locale.allocationRow.full} color="muted" onPress={() => onValueChange(full ? 0 : cap)} />
52
54
  </View>
53
55
  );
54
56
  }
package/src/breakdown.tsx CHANGED
@@ -95,7 +95,8 @@ export function Breakdown(props: BreakdownProps) {
95
95
  );
96
96
  return onSelect ? (
97
97
  <PressableHighlight
98
- focusRing key={item.key}
98
+ focusRing
99
+ key={item.key}
99
100
  accessibilityRole="button"
100
101
  // The W3C prop — `accessibilityState` is dropped by this react-native-web
101
102
  // build, and `selected` has no meaning on a `button` role regardless.
@@ -114,7 +115,8 @@ export function Breakdown(props: BreakdownProps) {
114
115
  })}
115
116
  {collapsible ? (
116
117
  <PressableHighlight
117
- focusRing accessibilityRole="button"
118
+ focusRing
119
+ accessibilityRole="button"
118
120
  // The W3C prop — `accessibilityState` is dropped by this react-native-web build.
119
121
  aria-expanded={expanded}
120
122
  accessibilityLabel={expanded ? labels.less : labels.more(hidden)}
package/src/checklist.tsx CHANGED
@@ -165,9 +165,13 @@ export function ChecklistGroup({ title, open, onToggleOpen, labels, ...positiona
165
165
  {/* A TEXT link, not a chevron: the phase is a heading rather than a row, so
166
166
  a disclosure triangle would give it the affordance of something that
167
167
  opens INTO a place. This only shows and hides rows already in the run,
168
- and it says which in words. */}
168
+ and it says which in words.
169
+
170
+ The NAME is verb then object with nothing between them — "Show Picking"
171
+ is already a phrase, and the middot that stood here bought no clarity
172
+ while being dropped by every screen reader that read it aloud. */}
169
173
  {onToggleOpen != null ? (
170
- <Pressable onPress={onToggleOpen} accessibilityRole="button" accessibilityLabel={`${word} · ${title}`}>
174
+ <Pressable onPress={onToggleOpen} accessibilityRole="button" accessibilityLabel={`${word} ${title}`}>
171
175
  <TextLink size="xs">{word}</TextLink>
172
176
  </Pressable>
173
177
  ) : null}
@@ -46,7 +46,8 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
46
46
  const active = option.value === value;
47
47
  return (
48
48
  <PressableHighlight
49
- focusRing key={option.value}
49
+ focusRing
50
+ key={option.value}
50
51
  testID={option.testID}
51
52
  onPress={() => onValueChange(option.value)}
52
53
  accessibilityRole="button"
@@ -5,6 +5,7 @@ import { NumberInput } from "./number_input";
5
5
  import { OptionList } from "./option_list";
6
6
  import { FilterChip } from "./filter_chip";
7
7
  import type { PickerOption } from "./picker";
8
+ import { useLoticsLocale } from "./locale";
8
9
 
9
10
  /** A column the picker can filter on. `type` selects the control + operators. */
10
11
  export interface FilterableColumn {
@@ -107,6 +108,7 @@ export interface ColumnFilterProps {
107
108
  */
108
109
  export function ColumnFilter(props: ColumnFilterProps) {
109
110
  const { column, value, onChange, clearLabel } = props;
111
+ const locale = useLoticsLocale();
110
112
  const active = isColumnFilterActive(value);
111
113
 
112
114
  return (
@@ -129,7 +131,7 @@ export function ColumnFilter(props: ColumnFilterProps) {
129
131
  ) : column.type === "number" ? (
130
132
  <View style={styles.range}>
131
133
  <NumberInput
132
- accessibilityLabel={`${column.label} min`}
134
+ accessibilityLabel={locale.columnFilter.min(column.label)}
133
135
  value={value?.kind === "number" ? value.min : null}
134
136
  onValueChange={(min) =>
135
137
  onChange({ kind: "number", min, max: value?.kind === "number" ? value.max : null })
@@ -139,7 +141,7 @@ export function ColumnFilter(props: ColumnFilterProps) {
139
141
 
140
142
  </Text>
141
143
  <NumberInput
142
- accessibilityLabel={`${column.label} max`}
144
+ accessibilityLabel={locale.columnFilter.max(column.label)}
143
145
  value={value?.kind === "number" ? value.max : null}
144
146
  onValueChange={(max) =>
145
147
  onChange({ kind: "number", min: value?.kind === "number" ? value.min : null, max })
@@ -20,8 +20,14 @@ export interface CommentsButtonProps {
20
20
  /** `sm` (the register row's) or `md`. Matches `CopyButton`'s scale so the two
21
21
  * affordances a row carries sit at one size. */
22
22
  size?: "sm" | "md";
23
- /** Override the wording; otherwise the locale's. */
24
- labels?: { comments?: string; comment?: string };
23
+ /** Override the wording; otherwise the locale's. `withSubject` builds the whole
24
+ * phrase from the counted noun and the subject, so an override changes the word
25
+ * ORDER too, not just the words. */
26
+ labels?: {
27
+ comments?: string;
28
+ comment?: string;
29
+ withSubject?: (counted: string, subject: string) => string;
30
+ };
25
31
  disabled?: boolean;
26
32
  testID?: string;
27
33
  }
@@ -57,7 +63,15 @@ export function CommentsButton(props: CommentsButtonProps) {
57
63
  // The subject rides the NAME, never the visible label: a row already says whose
58
64
  // record it is, and repeating it beside the count spends the row's width on
59
65
  // something the eye has just read.
60
- const name = subject ? `${count} ${noun} · ${subject}` : `${count} ${noun}`;
66
+ //
67
+ // The PACK composes it, exactly as `sortBy` and `rowDetails` do. Two reasons,
68
+ // and the second is the one that outlives this component: a middot is dropped by
69
+ // every screen reader, so the one mark carrying "these comments are about that
70
+ // record" is the one part that never survives being read aloud — and the word
71
+ // that replaces it sits in a different PLACE in different languages, which a
72
+ // component concatenating in English order cannot express at all.
73
+ const counted = `${count} ${noun}`;
74
+ const name = subject ? (labels?.withSubject ?? locale.commentsButton.withSubject)(counted, subject) : counted;
61
75
 
62
76
  return (
63
77
  <PressableHighlight
package/src/counter.tsx CHANGED
@@ -2,6 +2,7 @@ import { StyleSheet, View } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
  import { IconButton } from "./icon_button";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  export interface CounterProps {
7
8
  value: number;
@@ -9,7 +10,8 @@ export interface CounterProps {
9
10
  min?: number;
10
11
  max?: number;
11
12
  step?: number;
12
- /** Announced name — the buttons become "Decrease/Increase {label}". */
13
+ /** Announced name — the pack wraps it into each button's own phrase
14
+ * ("Decrease {label}" in English, "Giảm {label}" in Vietnamese). */
13
15
  accessibilityLabel: string;
14
16
  /** Render the value with units ("3 nights", "2 guests"). Default: the number. */
15
17
  format?: (value: number) => string;
@@ -24,6 +26,7 @@ export interface CounterProps {
24
26
  */
25
27
  export function Counter(props: CounterProps) {
26
28
  const { value, onValueChange, min = 0, max = Infinity, step = 1, accessibilityLabel, format } = props;
29
+ const locale = useLoticsLocale();
27
30
  const clamp = (n: number) => Math.min(max, Math.max(min, n));
28
31
 
29
32
  return (
@@ -32,7 +35,7 @@ export function Counter(props: CounterProps) {
32
35
  icon="minus"
33
36
  size="md"
34
37
  style={styles.btn}
35
- accessibilityLabel={`Decrease ${accessibilityLabel}`}
38
+ accessibilityLabel={locale.counter.decrease(accessibilityLabel)}
36
39
  disabled={value <= min}
37
40
  onPress={() => onValueChange(clamp(value - step))}
38
41
  />
@@ -43,7 +46,7 @@ export function Counter(props: CounterProps) {
43
46
  icon="plus"
44
47
  size="md"
45
48
  style={styles.btn}
46
- accessibilityLabel={`Increase ${accessibilityLabel}`}
49
+ accessibilityLabel={locale.counter.increase(accessibilityLabel)}
47
50
  disabled={value >= max}
48
51
  onPress={() => onValueChange(clamp(value + step))}
49
52
  />
package/src/file_row.tsx CHANGED
@@ -5,6 +5,7 @@ import { colors } from "./colors";
5
5
  import { FileBadge } from "./file_badge";
6
6
  import { FOCUS_RING } from "./control_surface";
7
7
  import { useFocusRing } from "./use_focus_ring";
8
+ import { useLoticsLocale } from "./locale";
8
9
 
9
10
  export interface FileRowProps {
10
11
  /** The file / document name — the primary line. */
@@ -76,6 +77,7 @@ export function FileRow({
76
77
  leading,
77
78
  size = "sm",
78
79
  }: FileRowProps) {
80
+ const locale = useLoticsLocale();
79
81
  const md = size === "md";
80
82
  const [hovered, setHovered] = useState(false);
81
83
  const [pressed, setPressed] = useState(false);
@@ -134,7 +136,7 @@ export function FileRow({
134
136
  onPressIn={() => setPressed(true)}
135
137
  onPressOut={() => setPressed(false)}
136
138
  accessibilityRole="button"
137
- accessibilityLabel={`Open ${name}`}
139
+ accessibilityLabel={locale.fileRow.open(name)}
138
140
  style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
139
141
  >
140
142
  {content}
package/src/kpi_card.tsx CHANGED
@@ -3,6 +3,7 @@ import { Text } from "./text";
3
3
  import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
4
4
  import { TrendChip } from "./trend_chip";
5
5
  import { InfoPopover } from "./info_popover";
6
+ import { useLoticsLocale } from "./locale";
6
7
  import { SPACE } from "./spacing";
7
8
 
8
9
  interface KPICardProps {
@@ -50,13 +51,16 @@ interface KPICardProps {
50
51
  */
51
52
  export function KPICard(props: KPICardProps) {
52
53
  const { label, value, format, currency, locale, emptyLabel, compact, size = "lg", tone, trend, caption, info, style } = props;
54
+ // `pack`, not `locale` — that name is taken by the BCP-47 tag this card passes
55
+ // to `Metric` for number formatting, which is a different axis entirely.
56
+ const pack = useLoticsLocale();
53
57
  return (
54
58
  <View style={[styles.container, style]}>
55
59
  <View style={styles.labelRow}>
56
60
  <Text size="xs" color="muted" transform="uppercase">
57
61
  {label}
58
62
  </Text>
59
- {info ? <InfoPopover text={info} accessibilityLabel={`About ${label}`} /> : null}
63
+ {info ? <InfoPopover text={info} accessibilityLabel={pack.infoPopover.about(label)} /> : null}
60
64
  </View>
61
65
  <View style={styles.valueRow}>
62
66
  <Metric
package/src/locale.tsx CHANGED
@@ -164,8 +164,27 @@ export interface LoticsLocale {
164
164
  /** `ImageGallery`: the empty state, the inline rotate controls, and the
165
165
  * press-to-zoom a11y name. */
166
166
  imageGallery: { empty: string; rotateLeft: string; rotateRight: string; zoom: string };
167
- /** `InfoPopover`: the ⓘ trigger's a11y name. */
168
- infoPopover: { more: string };
167
+ /** `InfoPopover`: the ⓘ trigger's a11y name. `more` is the bare default;
168
+ * `about` names the SUBJECT, which is what a screen a11y reader needs when a
169
+ * page carries several ⓘ buttons and "More information" is said four times. */
170
+ infoPopover: { more: string; about: (label: string) => string };
171
+ /** `FileRow` / `Sources`: the door onto one file or one cited source. Two
172
+ * slices rather than one shared verb, because a pack may open a FILE and open
173
+ * a SOURCE with different words. */
174
+ fileRow: { open: (name: string) => string };
175
+ sources: { open: (label: string) => string; heading: string };
176
+ /** `Counter`: the two steppers, named for the value they move. */
177
+ counter: { decrease: (label: string) => string; increase: (label: string) => string };
178
+ /** `AllocationRow`: the a11y name of the amount input (naming its destination),
179
+ * the outstanding-amount caption, and the two fill/empty verbs. */
180
+ allocationRow: {
181
+ allocateTo: (label: string) => string;
182
+ due: (amount: string) => string;
183
+ clear: string;
184
+ full: string;
185
+ };
186
+ /** `ColumnFilter`: the two bounds of a numeric range, named for their column. */
187
+ columnFilter: { min: (label: string) => string; max: (label: string) => string };
169
188
  /** `Matrix`: the total column/row header and the legend's less→more ends. */
170
189
  matrix: { total: string; less: string; more: string };
171
190
  /** `ScrollToBottom`: the jump-to-latest tooltip. */
@@ -209,8 +228,17 @@ export interface LoticsLocale {
209
228
  * value only the call site knows, the same split `referenceField.open` draws. */
210
229
  copyButton: { copy: string; copied: string };
211
230
  /** `CommentsButton`'s accessible name — the kit owns the noun so one surface
212
- * does not say "comments" while the next says "notes" for the same thread. */
213
- commentsButton: { comment: string; comments: string };
231
+ * does not say "comments" while the next says "notes" for the same thread.
232
+ * `withSubject` builds the WHOLE phrase, so a pack chooses the word ORDER and
233
+ * not merely the vocabulary: the subject leads in some languages, and a
234
+ * component that concatenates in English order cannot be translated into one.
235
+ * It joins with a WORD because this string exists to be read aloud, and a
236
+ * screen reader drops punctuation, taking the relation with it. */
237
+ commentsButton: {
238
+ comment: string;
239
+ comments: string;
240
+ withSubject: (counted: string, subject: string) => string;
241
+ };
214
242
  }
215
243
 
216
244
  /** The platform default — English. Every component's hardcoded default lives
@@ -329,7 +357,17 @@ export const en: LoticsLocale = {
329
357
  retryAll: "Retry all",
330
358
  },
331
359
  imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
332
- infoPopover: { more: "More information" },
360
+ infoPopover: { more: "More information", about: (label) => `About ${label}` },
361
+ fileRow: { open: (name) => `Open ${name}` },
362
+ sources: { open: (label) => `Open ${label}`, heading: "Sources" },
363
+ counter: { decrease: (label) => `Decrease ${label}`, increase: (label) => `Increase ${label}` },
364
+ allocationRow: {
365
+ allocateTo: (label) => `Allocate to ${label}`,
366
+ due: (amount) => `${amount} due`,
367
+ clear: "Clear",
368
+ full: "Full",
369
+ },
370
+ columnFilter: { min: (label) => `${label} min`, max: (label) => `${label} max` },
333
371
  matrix: { total: "Total", less: "Less", more: "More" },
334
372
  scrollToBottom: { tooltip: "Scroll to bottom" },
335
373
  textInputField: { clear: "Clear" },
@@ -366,7 +404,11 @@ export const en: LoticsLocale = {
366
404
  approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
367
405
  messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
368
406
  copyButton: { copy: "Copy", copied: "Copied" },
369
- commentsButton: { comment: "comment", comments: "comments" },
407
+ commentsButton: {
408
+ comment: "comment",
409
+ comments: "comments",
410
+ withSubject: (counted, subject) => `${counted} on ${subject}`,
411
+ },
370
412
  };
371
413
 
372
414
  /** Vietnamese. Maintained once here so every app (and the frontend) shares one
@@ -482,7 +524,22 @@ export const vi: LoticsLocale = {
482
524
  retryAll: "Thử lại tất cả",
483
525
  },
484
526
  imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
485
- infoPopover: { more: "Thông tin thêm" },
527
+ infoPopover: { more: "Thông tin thêm", about: (label) => `Thông tin về ${label}` },
528
+ fileRow: { open: (name) => `Mở ${name}` },
529
+ sources: { open: (label) => `Mở ${label}`, heading: "Nguồn" },
530
+ counter: { decrease: (label) => `Giảm ${label}`, increase: (label) => `Tăng ${label}` },
531
+ allocationRow: {
532
+ allocateTo: (label) => `Phân bổ vào ${label}`,
533
+ // The word LEADS in Vietnamese and trails in English — "còn 5.000.000 ₫"
534
+ // against "5,000,000 ₫ due". A pack-owned function is the only shape that
535
+ // expresses both.
536
+ due: (amount) => `còn ${amount}`,
537
+ clear: "Xoá",
538
+ full: "Toàn bộ",
539
+ },
540
+ // The qualifier FOLLOWS the noun in Vietnamese — "Doanh thu tối thiểu", never
541
+ // "tối thiểu Doanh thu". Exactly what a pack-owned function is for.
542
+ columnFilter: { min: (label) => `${label} tối thiểu`, max: (label) => `${label} tối đa` },
486
543
  matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
487
544
  scrollToBottom: { tooltip: "Cuộn xuống cuối" },
488
545
  textInputField: { clear: "Xóa" },
@@ -519,7 +576,11 @@ export const vi: LoticsLocale = {
519
576
  approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
520
577
  messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
521
578
  copyButton: { copy: "Sao chép", copied: "Đã sao chép" },
522
- commentsButton: { comment: "bình luận", comments: "bình luận" },
579
+ commentsButton: {
580
+ comment: "bình luận",
581
+ comments: "bình luận",
582
+ withSubject: (counted, subject) => `${counted} về ${subject}`,
583
+ },
523
584
  };
524
585
 
525
586
  const LoticsLocaleContext = createContext<LoticsLocale>(en);
package/src/peek.tsx CHANGED
@@ -33,7 +33,8 @@ export function Peek(props: PeekProps) {
33
33
  <Popover side={side} align={align}>
34
34
  <PopoverTrigger>
35
35
  <PressableHighlight
36
- focusRing accessibilityRole="button"
36
+ focusRing
37
+ accessibilityRole="button"
37
38
  accessibilityLabel={accessibilityLabel}
38
39
  style={styles.trigger}
39
40
  // 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
@@ -71,12 +71,14 @@ export interface PressableHighlightProps extends PressableProps {
71
71
  * A pressable component that highlights when hovered and pressed.
72
72
  * It will darken the `backgroundColor` prop when hovered and pressed.
73
73
  *
74
- * It IS a button and it WRAPS its children — so its content must be non-interactive by
75
- * construction. A row whose content carries its own controls (a CTA, a ⋯ menu, a
76
- * checkbox, a `Link`) must not be built on it: a button cannot contain interactive
77
- * descendants. Compose the role-less `PressableRow` + a `PressDoor` sibling instead
78
- * the surface takes the mouse, the door takes the keyboard, the controls stay their own
79
- * tab stops (`TableRow` is the reference).
74
+ * It IS a button it says so itself, with `accessibilityRole="button"` by default
75
+ * and it WRAPS its children, so its content must be non-interactive by construction.
76
+ * A row whose content carries its own controls (a CTA, a menu, a checkbox, a
77
+ * `Link`) must not be built on it: a button cannot contain interactive descendants.
78
+ * Compose the role-less `PressableRow` + a `PressDoor` sibling instead the surface
79
+ * takes the mouse, the door takes the keyboard, the controls stay their own tab stops
80
+ * (`TableRow` is the reference). That role split IS the difference between the two
81
+ * components, which is why neither leaves it to the call site.
80
82
  */
81
83
  export function PressableHighlight(props: PressableHighlightProps) {
82
84
  const {
@@ -122,6 +124,15 @@ export function PressableHighlight(props: PressableHighlightProps) {
122
124
  ] as StyleProp<ViewStyle>;
123
125
  }}
124
126
  onPress={handlePress}
127
+ // THE ROLE IS THE CONTRACT, so it is set here rather than asked for at every
128
+ // call site. This component IS a button — that is what separates it from the
129
+ // role-less `PressableRow`, and it is why its children must be
130
+ // non-interactive. Left to the call sites the split existed only in prose:
131
+ // 18 of 35 passed the role, all 18 passed "button", and the other 17
132
+ // rendered a surface a screen reader could name but not identify as
133
+ // pressable. Before the spread, so a caller that genuinely means something
134
+ // else still wins.
135
+ accessibilityRole="button"
125
136
  {...restPressableProps}
126
137
  // `onFocus` / `onBlur` are part of `PressableProps`, so a trailing
127
138
  // `{...tooltipProps}` spread would silently overwrite caller-provided
@@ -17,7 +17,8 @@ export function ScrollToBottom(props: ScrollToBottomProps) {
17
17
 
18
18
  return (
19
19
  <PressableHighlight
20
- focusRing testID="scroll-to-bottom-button"
20
+ focusRing
21
+ testID="scroll-to-bottom-button"
21
22
  tooltip={tooltip}
22
23
  onPress={onPress}
23
24
  style={styles.button}
package/src/sources.tsx CHANGED
@@ -3,6 +3,7 @@ import { colors, solid, tint, type ColorName } from "./colors";
3
3
  import { Text } from "./text";
4
4
  import { Icon, type IconName } from "./icon";
5
5
  import { PressableHighlight } from "./pressable_highlight";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  export type SourceKind = "record" | "document" | "table" | "web" | "knowledge";
8
9
 
@@ -43,18 +44,19 @@ const KIND: Record<SourceKind, { icon: IconName; color: ColorName }> = {
43
44
  * Renders nothing for an empty list.
44
45
  */
45
46
  export function Sources(props: SourcesProps) {
47
+ const locale = useLoticsLocale();
46
48
  if (props.sources.length === 0) return null;
47
49
  return (
48
50
  <View style={{ gap: 8 }}>
49
51
  {props.label === null ? null : (
50
52
  <Text size="xs" color="muted" weight="medium">
51
- {props.label ?? "Sources"}
53
+ {props.label ?? locale.sources.heading}
52
54
  </Text>
53
55
  )}
54
56
  <View style={styles.row}>
55
57
  {props.sources.map((s) =>
56
58
  props.onOpen ? (
57
- <PressableHighlight focusRing key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={`Open ${s.label}`} style={styles.chip}>
59
+ <PressableHighlight focusRing key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={locale.sources.open(s.label)} style={styles.chip}>
58
60
  <SourceChip source={s} openable />
59
61
  </PressableHighlight>
60
62
  ) : (
@@ -131,7 +131,8 @@ export function StatusLegend(props: StatusLegendProps) {
131
131
  );
132
132
  return onSelect ? (
133
133
  <PressableHighlight
134
- focusRing key={state.key}
134
+ focusRing
135
+ key={state.key}
135
136
  accessibilityRole="button"
136
137
  // The W3C prop — `accessibilityState` is dropped by this react-native-web
137
138
  // build, and `selected` has no meaning on a `button` role regardless.
@@ -3,6 +3,7 @@ import { Text } from "./text";
3
3
  import { Metric, type MetricFormat, type MetricTone } from "./metric";
4
4
  import { TrendChip } from "./trend_chip";
5
5
  import { InfoPopover } from "./info_popover";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  export interface SummaryLineItem {
8
9
  /** Label that reads AFTER the value ("below minimum", "stock value"). */
@@ -49,6 +50,7 @@ export interface SummaryLineProps {
49
50
  */
50
51
  export function SummaryLine(props: SummaryLineProps) {
51
52
  const { items } = props;
53
+ const locale = useLoticsLocale();
52
54
  return (
53
55
  <View style={styles.row}>
54
56
  {items.map((item) => (
@@ -67,7 +69,7 @@ export function SummaryLine(props: SummaryLineProps) {
67
69
  {item.label}
68
70
  </Text>
69
71
  {item.trend != null ? <TrendChip value={item.trend} /> : null}
70
- {item.info ? <InfoPopover text={item.info} accessibilityLabel={`About ${item.label}`} /> : null}
72
+ {item.info ? <InfoPopover text={item.info} accessibilityLabel={locale.infoPopover.about(item.label)} /> : null}
71
73
  </View>
72
74
  ))}
73
75
  </View>
@@ -16,7 +16,8 @@ export function SwitchButton(props: SwitchButtonProps) {
16
16
 
17
17
  return (
18
18
  <PressableHighlight
19
- focusRing style={{
19
+ focusRing
20
+ style={{
20
21
  flexDirection: "row",
21
22
  gap: 16,
22
23
  alignItems: "center",