@lotics/ui 5.10.1 → 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
 
@@ -26,10 +34,12 @@ Pick by capability, not by name. (→ the source file for the API.)
26
34
  `IconButton` (icon-ONLY — the circular affordance; an icon-only Button is a type error, so reach
27
35
  here. `size` `lg` 40px / `md` 28px / `sm` 24px, the SAME `color` palette as Button, plus `loading`
28
36
  and `elevated` (white fill + border + shadow, for a button sitting ON imagery — a tile's remove ✕,
29
- an overlay's retry); needs `accessibilityLabel`/`tooltip`), `LinkButton` (quiet ghost
30
- action Clear / Select all), `Chip` (dismissible facet chip). A button is never a raw
31
- `Pressable`. For a link OUT (a URL / record / document) use `Link` underline+blue, the
32
- destination signal; the opposite of `LinkButton` (a quiet in-app action, never underlined).
37
+ an overlay's retry); needs `accessibilityLabel`/`tooltip`), `TextLink` (underlined text that's
38
+ OPTIONALLY an action (`onPress`) or a link (`href`) or, with neither, plain underlined text you
39
+ drop in your own pressable like a table cell; colour via `color`; the go-to for Clear / Select all /
40
+ inline links), `Chip` (dismissible facet chip). A button is never a raw `Pressable`. For a link OUT
41
+ (a URL / record / document) use `Link` — fixed underline+blue + `role="link"`, the destination
42
+ signal; `TextLink` is the neutral, colour-configurable underlined link/action.
33
43
  - **Pick from a list** — `Picker` (native `<select>`, plain label-only single, native typeahead)
34
44
  or `Select` (the rich one — custom-rendered options, single/multi, select-all). Search-as-you-type
35
45
  / async / create-new → `Combobox`. A selectable card row → `CardSelectItem`. For a SELECT-FIELD
@@ -681,8 +691,12 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
681
691
  filter chips, and a primary Draft-from-notes CTA `Composer → AgentRun → ChangeReview`) ·
682
692
  `tpl_task_board` (the COLUMNS shape: a search · group-by · filter toolbar over a grouped, sortable
683
693
  grid of inline-editable cells — assignee/due/status/tags set directly, a files cell (thumbnail glance →
684
- popover grid → gallery), per-group add rows that pre-set the group's field, and the same Draft-from-notes
685
- CTA) · `tpl_pipeline` (the SAME inline-managed grouped-table shape, a second domain deals grouped by
694
+ popover grid → gallery), a **dynamic action column** (a per-row underlined action LINK driven by each
695
+ task's `action` descriptor attach/approve/open, each wired in a real app to its OWN workflow: one
696
+ board, a different action per row; the link's pressable fills the cell to match the inline editors),
697
+ a trailing **`ActionMenu`** (the ⋯ overflow — a `danger` Remove + confirm
698
+ today, more row actions later), per-group add rows that pre-set the group's field, and the same
699
+ Draft-from-notes CTA) · `tpl_pipeline` (the SAME inline-managed grouped-table shape, a second domain — deals grouped by
686
700
  stage with a money-value cell + owner + close-date edited inline, its own Draft-from-notes CTA drafting
687
701
  deals; proof the shape generalizes beyond tasks). All three: filters/search left, primary CTA right, one
688
702
  row; group-by holds only real dimensions, ✕ clears to ungrouped (no "Nothing" option). **`tpl_task_board` + `tpl_pipeline` are the inline-managed
@@ -731,7 +745,7 @@ section_heading (SectionHeading · SectionHeadingTitle — compound, owns no mar
731
745
  option_badge (OptionBadge — a select value as its configured colored badge) ·
732
746
  member_chip (MemberChip — avatar + name; the universal person render) ·
733
747
  member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
734
- status_badge · button · icon_button · link · link_button · chip · tabs · segmented_control ·
748
+ status_badge · button · icon_button · link · text_link (TextLink — underlined text that's optionally an `onPress` action or an `href` link, or plain underlined text to wrap in your own pressable; the neutral counterpart to the fixed-blue `Link`) · chip · tabs · segmented_control ·
735
749
  picker (native `<select>`, plain label-only single) · select (Select — rich/custom-rendered, single/multi, select-all, chips via `renderSelected` + `searchable` + `allowCustom` — the tag field is just a multi Select; opens `OptionList`) · option_list (OptionList — the ONE shared searchable listbox body every selector opens: single/multi, optional internal search, create row, keyboard + native-`<select>` typeahead; host it directly in a `Popover`/`Dialog` for a command palette) · combobox (single-select editable search input; browses on focus; no `multi` — multi-value chips → `Select multi`) ·
736
750
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
737
751
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { Pressable, ScrollView, StyleSheet, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
+ import { TextLink } from "@lotics/ui/text_link";
4
5
  import { colors, solid, asColorName } from "@lotics/ui/colors";
5
6
  import { Icon } from "@lotics/ui/icon";
6
7
  import { CheckCircle } from "@lotics/ui/check_circle";
@@ -17,6 +18,9 @@ import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
17
18
  import { InlineSelect } from "@lotics/ui/inline_select";
18
19
  import { Select } from "@lotics/ui/select";
19
20
  import { TextInputField } from "@lotics/ui/text_input_field";
21
+ import { ActionMenu } from "@lotics/ui/action_menu";
22
+ import { Alert } from "@lotics/ui/alert";
23
+ import { pickFiles } from "@lotics/ui/file_picker";
20
24
  import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
21
25
  import { FilesEditor } from "@lotics/ui/files_editor";
22
26
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
@@ -70,6 +74,20 @@ const tagOf = (k: string): TagOption => ({ value: k, label: TAGS[k]?.label ?? k
70
74
  const tagBadge = (t: TagOption): OptionValue => TAGS[t.value] ?? { key: t.value, label: t.label, color: null };
71
75
  const renderTagBadge = (o: { value: string; label?: string }) => <OptionBadge value={tagBadge({ value: o.value, label: o.label ?? o.value })} variant="dot" />;
72
76
 
77
+ // A per-row CONTEXTUAL action. Different tasks expose different actions, each
78
+ // wired (in a real app) to its OWN app-workflow — so ONE board drives many
79
+ // task-specific operations from a single column:
80
+ // • attach → pick a file; a workflow uploads it and attaches it to the row's
81
+ // linked record (e.g. the task's hồ sơ / case / order)
82
+ // • approve → a one-tap state-change workflow (sign-off, mark verified)
83
+ // • open → jump to the linked record / an external resource
84
+ // The cell renders an underlined action link for the row's `action`; `null` → none.
85
+ // Add a `kind` here + a branch in `RowActionCell` to introduce a new action.
86
+ type RowAction =
87
+ | { kind: "attach"; label: string }
88
+ | { kind: "approve"; label: string }
89
+ | { kind: "open"; label: string };
90
+
73
91
  interface Task {
74
92
  id: string;
75
93
  title: string;
@@ -78,6 +96,7 @@ interface Task {
78
96
  status: Status;
79
97
  tags: TagOption[];
80
98
  files: DisplayFile[];
99
+ action: RowAction | null;
81
100
  }
82
101
 
83
102
  const day = (n: number): string => {
@@ -101,13 +120,13 @@ const toDisplayFiles = (files: File[]): DisplayFile[] =>
101
120
  files.map((f) => ({ id: `f${++fileSeq}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) }));
102
121
 
103
122
  const SEED: Task[] = [
104
- { id: "t1", title: "Send VITTORIA their quote", ownerId: "m1", due: day(-2), status: "doing", tags: [tagOf("client"), tagOf("urgent")], files: [sampleImage("quote-draft", "#3b82f6")] },
105
- { id: "t2", title: "Finalize the gift-box packaging design", ownerId: "m3", due: day(0), status: "doing", tags: [tagOf("design")], files: [] },
106
- { id: "t3", title: "Sign off the die-cut sample", ownerId: "m1", due: day(0), status: "todo", tags: [tagOf("design"), tagOf("print")], files: [sampleImage("die-cut", "#10b981"), sampleImage("sample-2", "#f59e0b")] },
107
- { id: "t4", title: "Order 300g ivory board stock", ownerId: "m2", due: day(3), status: "todo", tags: [tagOf("print")], files: [] },
108
- { id: "t5", title: "Schedule the first delivery batch", ownerId: "m4", due: day(9), status: "todo", tags: [], files: [] },
109
- { id: "t6", title: "Draft the 2026 print contract", ownerId: null, due: null, status: "todo", tags: [tagOf("client")], files: [] },
110
- { id: "t7", title: "Update the Q3 price list", ownerId: "m3", due: day(-5), status: "done", tags: [], files: [] },
123
+ { id: "t1", title: "Send VITTORIA their quote", ownerId: "m1", due: day(-2), status: "doing", tags: [tagOf("client"), tagOf("urgent")], files: [sampleImage("quote-draft", "#3b82f6")], action: { kind: "attach", label: "Attach quote" } },
124
+ { id: "t2", title: "Finalize the gift-box packaging design", ownerId: "m3", due: day(0), status: "doing", tags: [tagOf("design")], files: [], action: { kind: "approve", label: "Sign off" } },
125
+ { id: "t3", title: "Sign off the die-cut sample", ownerId: "m1", due: day(0), status: "todo", tags: [tagOf("design"), tagOf("print")], files: [sampleImage("die-cut", "#10b981"), sampleImage("sample-2", "#f59e0b")], action: { kind: "attach", label: "Attach sample" } },
126
+ { id: "t4", title: "Order 300g ivory board stock", ownerId: "m2", due: day(3), status: "todo", tags: [tagOf("print")], files: [], action: { kind: "open", label: "Open spec" } },
127
+ { id: "t5", title: "Schedule the first delivery batch", ownerId: "m4", due: day(9), status: "todo", tags: [], files: [], action: null },
128
+ { id: "t6", title: "Draft the 2026 print contract", ownerId: null, due: null, status: "todo", tags: [tagOf("client")], files: [], action: { kind: "approve", label: "Approve draft" } },
129
+ { id: "t7", title: "Update the Q3 price list", ownerId: "m3", due: day(-5), status: "done", tags: [], files: [], action: null },
111
130
  ];
112
131
 
113
132
  type GroupBy = "status" | "assignee" | "due";
@@ -174,6 +193,9 @@ export function TplTaskBoard() {
174
193
  setCollapsed((c) => { const n = new Set(c); if (n.has(key)) n.delete(key); else n.add(key); return n; });
175
194
  const [sort, setSort] = useState<SortState | null>(null);
176
195
  const onSort = (key: string) => setSort((s) => cycleSort(s, key));
196
+ // Tangible feedback for a run action (the picked file's name / "Approved") — the
197
+ // place a real app would reflect the workflow's outcome.
198
+ const [actionResult, setActionResult] = useState<Record<string, string>>({});
177
199
 
178
200
  const patch = (id: string, next: Partial<Task>) =>
179
201
  setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, ...next } : t)));
@@ -183,6 +205,20 @@ export function TplTaskBoard() {
183
205
  setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, files: [...t.files, ...toDisplayFiles(picked)] } : t)));
184
206
  const removeFile = (id: string, fileId: string) =>
185
207
  setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, files: t.files.filter((f) => f.id !== fileId) } : t)));
208
+ const removeTask = (id: string) => setTasks((ts) => ts.filter((t) => t.id !== id));
209
+ // Destructive → always confirm before executing (the ActionMenu danger item).
210
+ const confirmRemove = (t: Task) =>
211
+ Alert.alert("Remove task?", `“${t.title}” will be removed from the board.`, [
212
+ { text: "Cancel", style: "cancel" },
213
+ { text: "Remove", style: "destructive", onPress: () => removeTask(t.id) },
214
+ ]);
215
+ // The dynamic actions. In a real app each dispatches its OWN app-workflow; here
216
+ // they record a result so the cell shows the action completed.
217
+ const runAttach = async (id: string) => {
218
+ const picked = await pickFiles();
219
+ if (picked.length > 0) setActionResult((r) => ({ ...r, [id]: picked[0].name }));
220
+ };
221
+ const runApprove = (id: string) => setActionResult((r) => ({ ...r, [id]: "Approved" }));
186
222
 
187
223
  // Draft-from-notes AI flow — agent proposes, manager keeps/edits/drops, commit adds tasks.
188
224
  const [draftOpen, setDraftOpen] = useState(false);
@@ -202,7 +238,7 @@ export function TplTaskBoard() {
202
238
  const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
203
239
  const addAccepted = useCallback(() => {
204
240
  const accepted = proposals.filter((p) => decisions[p.id] === "accepted");
205
- setTasks((ts) => [...ts, ...accepted.map((p, i) => ({ id: `d-${p.id}-${i}`, title: p.title, ownerId: null, due: p.due, status: "todo" as Status, tags: [], files: [] }))]);
241
+ setTasks((ts) => [...ts, ...accepted.map((p, i) => ({ id: `d-${p.id}-${i}`, title: p.title, ownerId: null, due: p.due, status: "todo" as Status, tags: [], files: [], action: null }))]);
206
242
  setAddedCount(accepted.length);
207
243
  setPhase("done");
208
244
  }, [proposals, decisions]);
@@ -228,17 +264,35 @@ export function TplTaskBoard() {
228
264
  }
229
265
 
230
266
  const propertyCols: DataGridColumn<Task>[] = [
231
- { key: "assignee", label: "Assignee", width: 168, sortable: true, cell: (t) => <InlineMemberSelect members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
232
- { key: "due", label: "Due", width: 150, sortable: true, cell: (t) => <InlineDatePicker value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" /> },
233
- { key: "status", label: "Status", width: 132, sortable: true, cell: (t) => <InlineSelect value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
234
- { key: "tags", label: "Tags", width: 210, cell: (t) => <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" /> },
267
+ { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
268
+ { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" /> },
269
+ { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
270
+ { key: "tags", label: "Tags", width: 168, cell: (t) => <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" /> },
235
271
  ];
236
272
  const filesCol: DataGridColumn<Task> = {
237
- key: "files", label: "Files", width: 116, sortable: false,
273
+ key: "files", label: "Files", width: 100, sortable: false,
238
274
  cell: (t) => <FilesCell files={t.files} onAdd={(fs) => addFiles(t.id, fs)} onRemove={(id) => removeFile(t.id, id)} />,
239
275
  };
276
+ // The DYNAMIC action column — each row shows the button its `action` describes.
277
+ const actionCol: DataGridColumn<Task> = {
278
+ key: "action", label: "Action", width: 156, sortable: false,
279
+ cell: (t) => <RowActionCell task={t} result={actionResult[t.id]} onAttach={runAttach} onApprove={runApprove} />,
280
+ };
281
+ // The ⋯ overflow menu — the row's secondary actions (Remove today; more later).
282
+ const menuCol: DataGridColumn<Task> = {
283
+ key: "__menu", label: "", width: 40, sortable: false,
284
+ cell: (t) => (
285
+ <ActionMenu
286
+ accessibilityLabel={`Actions for ${t.title}`}
287
+ items={[
288
+ // Add more row actions here (Duplicate, Assign to me, Snooze…).
289
+ { key: "remove", label: "Remove task", icon: "trash", danger: true, onPress: () => confirmRemove(t) },
290
+ ]}
291
+ />
292
+ ),
293
+ };
240
294
  const visibleProps = propertyCols.filter((c) => c.key !== groupBy);
241
- const addCols = [...visibleProps, filesCol];
295
+ const addCols = [...visibleProps, filesCol, actionCol, menuCol];
242
296
  const columns: DataGridColumn<Task>[] = [
243
297
  { key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
244
298
  ...addCols,
@@ -272,7 +326,7 @@ export function TplTaskBoard() {
272
326
  return (
273
327
  <>
274
328
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 24, paddingBottom: 96 }}>
275
- <View style={{ maxWidth: 1040, width: "100%", alignSelf: "center", gap: 16 }}>
329
+ <View style={{ maxWidth: 1240, width: "100%", alignSelf: "center", gap: 16 }}>
276
330
  <Text size="xl" weight="semibold">Task board</Text>
277
331
 
278
332
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -413,6 +467,32 @@ function FilesCell({ files, onAdd, onRemove }: { files: DisplayFile[]; onAdd: (p
413
467
  );
414
468
  }
415
469
 
470
+ // The dynamic action cell — an underlined LINK whose label is the row's `action`
471
+ // (a DIFFERENT action per task); pressing it dispatches that task's own workflow.
472
+ // The pressable fills the cell, so its hit area matches the inline-editor cells
473
+ // beside it. Once run, the cell shows the result (the attached file's name /
474
+ // "Approved") — where a real app reflects the dispatched workflow's outcome.
475
+ function RowActionCell({ task, result, onAttach, onApprove }: { task: Task; result: string | undefined; onAttach: (id: string) => void; onApprove: (id: string) => void }) {
476
+ const a = task.action;
477
+ if (!a) return null;
478
+ if (result) {
479
+ return (
480
+ <View style={styles.result}>
481
+ <Icon name="check" size={14} color={colors.emerald[600]} />
482
+ <Text size="sm" color="muted" numberOfLines={1}>{result}</Text>
483
+ </View>
484
+ );
485
+ }
486
+ // The action is its OWN pressable container (cell-sized hit area); the label is a
487
+ // plain `TextLink` (underlined text, no onPress of its own — the wrapper handles it).
488
+ const onPress = a.kind === "attach" ? () => onAttach(task.id) : a.kind === "approve" ? () => onApprove(task.id) : () => {};
489
+ return (
490
+ <Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={a.label} style={({ hovered }: { hovered?: boolean }) => [styles.action, hovered ? styles.actionHover : null]}>
491
+ <TextLink size="sm" numberOfLines={1}>{a.label}</TextLink>
492
+ </Pressable>
493
+ );
494
+ }
495
+
416
496
  function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns: DataGridColumn<Task>[]; onAdd: (t: Omit<Task, "id">) => void }) {
417
497
  const [title, setTitle] = useState("");
418
498
  const [ownerId, setOwnerId] = useState<string | null>(null);
@@ -423,7 +503,7 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
423
503
  const submit = () => {
424
504
  const t = title.trim();
425
505
  if (!t) return;
426
- onAdd({ title: t, ownerId, due, status, tags, files: [], ...preset });
506
+ onAdd({ title: t, ownerId, due, status, tags, files: [], action: null, ...preset });
427
507
  setTitle("");
428
508
  setOwnerId(null);
429
509
  setDue(null);
@@ -431,8 +511,8 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
431
511
  setTags([]);
432
512
  };
433
513
 
434
- // Files are added once a task exists (per-row paperclip), so the add row leaves
435
- // that column empty — the width is still reserved so the columns stay aligned.
514
+ // Files / action / ⋯ act on a task that already exists, so the add row leaves
515
+ // those columns empty — the widths are still reserved so the columns stay aligned.
436
516
  const editor = (key: string) =>
437
517
  key === "assignee" ? <InlineMemberSelect members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
438
518
  : key === "due" ? <InlineDatePicker value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
@@ -469,5 +549,9 @@ const styles = StyleSheet.create({
469
549
  clipPreview: { flexDirection: "row", alignItems: "center", gap: 6 },
470
550
  summary: { flexDirection: "row", alignItems: "center", gap: 4 },
471
551
  summaryDot: { width: 8, height: 8, borderRadius: 999 },
552
+ result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
553
+ // Fills the cell (height + width) so the link's hit area matches the inline cells.
554
+ action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
555
+ actionHover: { backgroundColor: colors.zinc[50] },
472
556
  source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
473
557
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.10.1",
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",
@@ -148,7 +149,7 @@
148
149
  "./range_slider": "./src/range_slider.tsx",
149
150
  "./counter": "./src/counter.tsx",
150
151
  "./link": "./src/link.tsx",
151
- "./link_button": "./src/link_button.tsx",
152
+ "./text_link": "./src/text_link.tsx",
152
153
  "./sort_header": "./src/sort_header.tsx",
153
154
  "./skeleton": "./src/skeleton.tsx",
154
155
  "./table": "./src/table.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}
@@ -3,10 +3,11 @@ import { StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { colors } from "./colors";
6
- import { LinkButton } from "./link_button";
6
+ 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.
@@ -108,7 +110,7 @@ export function FilterChip(props: FilterChipProps) {
108
110
  <PopoverFooter>{footer}</PopoverFooter>
109
111
  ) : showClear ? (
110
112
  <PopoverFooter align="start">
111
- <LinkButton title={clearLabel} onPress={onClear} />
113
+ <TextLink onPress={onClear}>{clearLabel}</TextLink>
112
114
  </PopoverFooter>
113
115
  ) : null}
114
116
  </PopoverContent>
package/src/link.tsx CHANGED
@@ -16,12 +16,12 @@ export interface LinkProps {
16
16
  }
17
17
 
18
18
  /**
19
- * An EXTERNAL hyperlink — underline + blue, the universal "this opens somewhere
20
- * else" signal (a document URL, a record, an invoice). The deliberate counterpart
21
- * to `LinkButton`: `LinkButton` is a QUIET IN-APP ACTION (a ghost button — "Clear",
22
- * "Show more") and intentionally NOT underlined; `Link` is a link OUT and IS
23
- * underlined-blue so it reads as a destination, not an action. The consumer wires
24
- * `onPress` to its opener.
19
+ * An EXTERNAL hyperlink — underline + blue + `role="link"`, the universal "this
20
+ * opens somewhere else" signal (a document URL, a record, an invoice), the colour
21
+ * fixed so it always reads as a destination. The deliberate counterpart to
22
+ * `TextLink` the neutral, colour-configurable underlined link that is OPTIONALLY
23
+ * an `onPress` action (or plain underlined text). Reach for `Link` when the thing
24
+ * is specifically a destination; the consumer wires `onPress` to its opener.
25
25
  */
26
26
  export function Link({ children, onPress, size = "sm", accessibilityLabel, testID }: LinkProps) {
27
27
  const { focusVisible, focusProps } = useFocusRing();
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
+ }
@@ -5,11 +5,12 @@ import { Text } from "./text";
5
5
  import { Icon } from "./icon";
6
6
  import { Checkbox } from "./checkbox";
7
7
  import { MenuButton } from "./menu_button";
8
- import { LinkButton } from "./link_button";
8
+ import { TextLink } from "./text_link";
9
9
  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
 
@@ -164,9 +159,9 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
164
159
 
165
160
  {list.showSelectAll || list.showDeselectAll ? (
166
161
  <View style={styles.selectAllContainer}>
167
- {list.showSelectAll ? <LinkButton title={selectAllLabel} onPress={list.selectAll} /> : null}
162
+ {list.showSelectAll ? <TextLink onPress={list.selectAll}>{selectAllLabel}</TextLink> : null}
168
163
  {list.showDeselectAll ? (
169
- <LinkButton title={deselectAllLabel} onPress={list.deselectAll} />
164
+ <TextLink onPress={list.deselectAll}>{deselectAllLabel}</TextLink>
170
165
  ) : null}
171
166
  </View>
172
167
  ) : null}
@@ -188,8 +183,8 @@ const styles = StyleSheet.create({
188
183
  alignItems: "center",
189
184
  gap: 16,
190
185
  paddingVertical: 4,
191
- // Matches a MenuButton option's 8px text inset so the bleeding-wash
192
- // LinkButtons line up under the option labels above.
186
+ // Matches a MenuButton option's 8px text inset so the select-all / deselect-all
187
+ // links line up under the option labels above.
193
188
  paddingHorizontal: 8,
194
189
  borderTopWidth: 1,
195
190
  borderTopColor: colors.border,
@@ -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
@@ -106,8 +104,8 @@ export function SortHeader(props: SortHeaderProps) {
106
104
  }
107
105
 
108
106
  const styles = StyleSheet.create({
109
- // Negative margin so the wash bleeds while the label stays flush with the
110
- // column content below (same idea as LinkButton).
107
+ // Negative margin so the hover wash bleeds while the label stays flush with the
108
+ // column content below.
111
109
  header: {
112
110
  flexDirection: "row",
113
111
  alignItems: "center",
package/src/text.tsx CHANGED
@@ -28,6 +28,9 @@ export interface TextProps {
28
28
  tabular?: boolean;
29
29
  style?: RNTextProps["style"];
30
30
  onPress?: () => void;
31
+ /** A URL — renders the text as a real anchor (`<a href>` on web; middle-click /
32
+ * open-in-new-tab work). For an in-app action use `onPress`. */
33
+ href?: string;
31
34
  /** ID used to target this element from `accessibilityLabelledBy` / `aria-describedby`. */
32
35
  nativeID?: string;
33
36
  accessibilityRole?: RNTextProps["accessibilityRole"];
@@ -71,6 +74,7 @@ export function Text(props: TextProps) {
71
74
  tabular,
72
75
  transform,
73
76
  onPress,
77
+ href,
74
78
  style,
75
79
  nativeID,
76
80
  accessibilityRole,
@@ -112,6 +116,7 @@ export function Text(props: TextProps) {
112
116
  ]}
113
117
  numberOfLines={numberOfLines}
114
118
  onPress={onPress}
119
+ {...(href != null ? { href } : null)}
115
120
  >
116
121
  {children}
117
122
  </RNText>
@@ -0,0 +1,45 @@
1
+ import { Text, type TextProps } from "./text";
2
+ import { Icon, type IconName } from "./icon";
3
+ import { getTextColor } from "./text_utils";
4
+
5
+ export interface TextLinkProps extends TextProps {
6
+ /** Optional leading icon. */
7
+ icon?: IconName;
8
+ /** A URL — renders a real anchor (`<a href>` on web; middle-click / open-in-new-tab
9
+ * work). For in-app routing, wrap `TextLink` or use `onPress`. */
10
+ href?: string;
11
+ /** An in-app action. With NEITHER `href` nor `onPress`, `TextLink` is just
12
+ * underlined text you wrap in your own pressable (e.g. a table cell). */
13
+ onPress?: () => void;
14
+ }
15
+
16
+ /**
17
+ * Underlined text that is OPTIONALLY a link (`href` → a web anchor) or an action
18
+ * (`onPress` → pressable); with neither it's plain underlined text you drop inside
19
+ * your own pressable (a table cell, a custom row). Colour comes from `color` (it
20
+ * inherits every `Text` prop) — the neutral, configurable counterpart to `Link`,
21
+ * which is fixed blue + `role="link"` for the "this opens somewhere else" signal.
22
+ */
23
+ export function TextLink(props: TextLinkProps) {
24
+ const { icon, href, onPress, children, color, style, ...textProps } = props;
25
+ const interactive = href != null || onPress != null;
26
+ return (
27
+ <Text
28
+ decoration="underline"
29
+ weight="medium"
30
+ color={color}
31
+ href={href}
32
+ onPress={onPress}
33
+ accessibilityRole={href != null ? "link" : onPress != null ? "button" : undefined}
34
+ style={[
35
+ icon ? { display: "flex", flexDirection: "row", alignItems: "center", gap: 4 } : null,
36
+ interactive ? { cursor: "pointer" } : null,
37
+ style,
38
+ ]}
39
+ {...textProps}
40
+ >
41
+ {icon ? <Icon name={icon} size={16} color={getTextColor(color)} /> : null}
42
+ {children}
43
+ </Text>
44
+ );
45
+ }
@@ -1,51 +0,0 @@
1
- import { StyleSheet } from "react-native";
2
- import { Text } from "./text";
3
- import { PressableHighlight } from "./pressable_highlight";
4
- import { CONTROL_RADIUS } from "./control_surface";
5
-
6
- export interface LinkButtonProps {
7
- title: string;
8
- onPress: () => void;
9
- /** Defaults to `title`. */
10
- accessibilityLabel?: string;
11
- testID?: string;
12
- }
13
-
14
- /**
15
- * A quiet inline action — "Clear", "Select all", "Show more" — styled as a GHOST
16
- * button: dark medium text with no chrome at rest and a soft rounded hover wash,
17
- * compact (not the 40px `Button`). The low-emphasis sibling of `Button` for
18
- * utility actions in a popover footer or a list's select-all row; it never
19
- * competes with a real/commit button, and reads more premium than an underlined
20
- * link or a filled/muted Button.
21
- */
22
- export function LinkButton(props: LinkButtonProps) {
23
- const { title, onPress, accessibilityLabel, testID } = props;
24
- return (
25
- <PressableHighlight
26
- focusRing testID={testID}
27
- onPress={onPress}
28
- accessibilityRole="button"
29
- accessibilityLabel={accessibilityLabel ?? title}
30
- userSelect="none"
31
- style={styles.btn}
32
- >
33
- <Text size="sm" weight="medium">
34
- {title}
35
- </Text>
36
- </PressableHighlight>
37
- );
38
- }
39
-
40
- const styles = StyleSheet.create({
41
- // The hover wash needs horizontal padding, but the negative margin cancels it
42
- // for POSITIONING — so the text sits flush with the surrounding content's left
43
- // edge (the option rows above, the editor body) while the wash bleeds outward.
44
- btn: {
45
- paddingVertical: 6,
46
- paddingHorizontal: 8,
47
- marginHorizontal: -8,
48
- borderRadius: CONTROL_RADIUS,
49
- alignSelf: "flex-start",
50
- },
51
- });