@lotics/ui 5.10.0 → 6.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/AGENTS.md CHANGED
@@ -26,10 +26,12 @@ Pick by capability, not by name. (→ the source file for the API.)
26
26
  `IconButton` (icon-ONLY — the circular affordance; an icon-only Button is a type error, so reach
27
27
  here. `size` `lg` 40px / `md` 28px / `sm` 24px, the SAME `color` palette as Button, plus `loading`
28
28
  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).
29
+ an overlay's retry); needs `accessibilityLabel`/`tooltip`), `TextLink` (underlined text that's
30
+ OPTIONALLY an action (`onPress`) or a link (`href`) or, with neither, plain underlined text you
31
+ drop in your own pressable like a table cell; colour via `color`; the go-to for Clear / Select all /
32
+ inline links), `Chip` (dismissible facet chip). A button is never a raw `Pressable`. For a link OUT
33
+ (a URL / record / document) use `Link` — fixed underline+blue + `role="link"`, the destination
34
+ signal; `TextLink` is the neutral, colour-configurable underlined link/action.
33
35
  - **Pick from a list** — `Picker` (native `<select>`, plain label-only single, native typeahead)
34
36
  or `Select` (the rich one — custom-rendered options, single/multi, select-all). Search-as-you-type
35
37
  / async / create-new → `Combobox`. A selectable card row → `CardSelectItem`. For a SELECT-FIELD
@@ -681,8 +683,12 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
681
683
  filter chips, and a primary Draft-from-notes CTA `Composer → AgentRun → ChangeReview`) ·
682
684
  `tpl_task_board` (the COLUMNS shape: a search · group-by · filter toolbar over a grouped, sortable
683
685
  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
686
+ popover grid → gallery), a **dynamic action column** (a per-row underlined action LINK driven by each
687
+ task's `action` descriptor attach/approve/open, each wired in a real app to its OWN workflow: one
688
+ board, a different action per row; the link's pressable fills the cell to match the inline editors),
689
+ a trailing **`ActionMenu`** (the ⋯ overflow — a `danger` Remove + confirm
690
+ today, more row actions later), per-group add rows that pre-set the group's field, and the same
691
+ Draft-from-notes CTA) · `tpl_pipeline` (the SAME inline-managed grouped-table shape, a second domain — deals grouped by
686
692
  stage with a money-value cell + owner + close-date edited inline, its own Draft-from-notes CTA drafting
687
693
  deals; proof the shape generalizes beyond tasks). All three: filters/search left, primary CTA right, one
688
694
  row; group-by holds only real dimensions, ✕ clears to ungrouped (no "Nothing" option). **`tpl_task_board` + `tpl_pipeline` are the inline-managed
@@ -690,7 +696,11 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
690
696
  rows). For thousands+ you BROWSE, that's `tpl_dossier`'s paginated register, not this.** Both build
691
697
  on the `DataGrid` primitive (the grid shape, extracted once these two uses confirmed it); each owns
692
698
  only its data, toolbar, columns and per-group add. There is NO Task component — a task ROW varies
693
- too much to bake into one, so rows compose `CheckCircle` + `InlineTextInput` + inline-editor cells.
699
+ too much to bake into one, so rows compose `CheckCircle` + `InlineTextInput` + inline-editor cells
700
+ as **sibling** controls in a plain `View`. NEVER wrap the whole row in a `Pressable accessibilityRole="button"`
701
+ (for tap-to-expand) with those controls nested inside: RNW renders the row AND each control as a `<button>`,
702
+ so you get `<button>`s inside a `<button>` — invalid DOM + the inner controls drop out of the keyboard tab
703
+ order. Give "expand" its own affordance (a trailing `Pressable` over the meta/chevron), as `tpl_tasks` does.
694
704
  - **Data capture** — `tpl_intake` (multi-fieldset form + attachments) · `tpl_order` (the
695
705
  transactional form: find-or-create + line items + attachment CRUD + 2-col grid) · `tpl_billing`
696
706
  (charges→invoice→collect: the invoice DOCUMENT is the unit — editable charge lines + method,
@@ -727,7 +737,7 @@ section_heading (SectionHeading · SectionHeadingTitle — compound, owns no mar
727
737
  option_badge (OptionBadge — a select value as its configured colored badge) ·
728
738
  member_chip (MemberChip — avatar + name; the universal person render) ·
729
739
  member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
730
- status_badge · button · icon_button · link · link_button · chip · tabs · segmented_control ·
740
+ 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 ·
731
741
  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`) ·
732
742
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
733
743
  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
  });
@@ -285,25 +285,38 @@ export function TplTasks() {
285
285
  const d = dueInfo(t.due);
286
286
  return (
287
287
  <View key={t.id}>
288
- <Pressable onPress={() => toggleExpand(t.id, !open)} accessibilityRole="button" accessibilityLabel={t.title} style={({ hovered }: { hovered?: boolean }) => [styles.row, hovered ? styles.rowHover : null]}>
288
+ {/* A row is NOT a single button: the ring, the title, and the expand
289
+ affordance are SIBLING controls in a plain View. Wrapping the row in a
290
+ Pressable accessibilityRole="button" while nesting CheckCircle +
291
+ InlineTextInput (each renders a <button> on web) would put <button>s
292
+ inside a <button> — invalid DOM + the inner controls drop out of the
293
+ keyboard tab order. So the expand affordance is its own Pressable. */}
294
+ <View style={styles.row}>
289
295
  <CheckCircle checked={done} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} />
290
296
  <View style={{ flex: 1 }}>
291
297
  <InlineTextInput value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={done} accessibilityLabel="Title" />
292
298
  </View>
293
- <View style={styles.meta}>
294
- {t.files.length > 0 ? (
295
- <View style={styles.clip}>
296
- <Icon name="paperclip" size={13} color={colors.zinc[400]} />
297
- <Text size="xs" color="muted" tabular>{t.files.length}</Text>
298
- </View>
299
- ) : null}
300
- {t.tags.length > 0 ? (
301
- <View style={styles.dots}>{t.tags.map((tag) => <View key={tag.value} style={[styles.dot, { backgroundColor: tagColor(tag) }]} />)}</View>
302
- ) : null}
303
- {d ? <Text size="xs" weight="medium" color={d.overdue && !done ? "danger" : "muted"}>{d.label}</Text> : null}
304
- </View>
305
- <Icon name={open ? "chevron-up" : "chevron-down"} size={16} color={colors.zinc[300]} />
306
- </Pressable>
299
+ <Pressable
300
+ onPress={() => toggleExpand(t.id, !open)}
301
+ accessibilityRole="button"
302
+ accessibilityLabel={open ? `Collapse ${t.title}` : `Expand ${t.title}`}
303
+ style={({ hovered }: { hovered?: boolean }) => [styles.expand, hovered ? styles.rowHover : null]}
304
+ >
305
+ <View style={styles.meta}>
306
+ {t.files.length > 0 ? (
307
+ <View style={styles.clip}>
308
+ <Icon name="paperclip" size={13} color={colors.zinc[400]} />
309
+ <Text size="xs" color="muted" tabular>{t.files.length}</Text>
310
+ </View>
311
+ ) : null}
312
+ {t.tags.length > 0 ? (
313
+ <View style={styles.dots}>{t.tags.map((tag) => <View key={tag.value} style={[styles.dot, { backgroundColor: tagColor(tag) }]} />)}</View>
314
+ ) : null}
315
+ {d ? <Text size="xs" weight="medium" color={d.overdue && !done ? "danger" : "muted"}>{d.label}</Text> : null}
316
+ </View>
317
+ <Icon name={open ? "chevron-up" : "chevron-down"} size={16} color={colors.zinc[300]} />
318
+ </Pressable>
319
+ </View>
307
320
  {open ? (
308
321
  <View style={styles.detail}>
309
322
  <Field label="Due">
@@ -414,6 +427,9 @@ const styles = StyleSheet.create({
414
427
  borderRadius: 10,
415
428
  },
416
429
  rowHover: { backgroundColor: colors.zinc[50] },
430
+ // The expand affordance — its own button (meta + chevron), sibling to the ring
431
+ // and title so no interactive control nests inside another.
432
+ expand: { flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36, paddingHorizontal: 8, borderRadius: 8 },
417
433
  capture: {
418
434
  borderBottomWidth: 1,
419
435
  borderBottomColor: colors.zinc[100],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.10.0",
3
+ "version": "6.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -148,7 +148,7 @@
148
148
  "./range_slider": "./src/range_slider.tsx",
149
149
  "./counter": "./src/counter.tsx",
150
150
  "./link": "./src/link.tsx",
151
- "./link_button": "./src/link_button.tsx",
151
+ "./text_link": "./src/text_link.tsx",
152
152
  "./sort_header": "./src/sort_header.tsx",
153
153
  "./skeleton": "./src/skeleton.tsx",
154
154
  "./table": "./src/table.tsx",
@@ -3,7 +3,7 @@ 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";
@@ -108,7 +108,7 @@ export function FilterChip(props: FilterChipProps) {
108
108
  <PopoverFooter>{footer}</PopoverFooter>
109
109
  ) : showClear ? (
110
110
  <PopoverFooter align="start">
111
- <LinkButton title={clearLabel} onPress={onClear} />
111
+ <TextLink onPress={onClear}>{clearLabel}</TextLink>
112
112
  </PopoverFooter>
113
113
  ) : null}
114
114
  </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();
@@ -5,7 +5,7 @@ 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";
@@ -164,9 +164,9 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
164
164
 
165
165
  {list.showSelectAll || list.showDeselectAll ? (
166
166
  <View style={styles.selectAllContainer}>
167
- {list.showSelectAll ? <LinkButton title={selectAllLabel} onPress={list.selectAll} /> : null}
167
+ {list.showSelectAll ? <TextLink onPress={list.selectAll}>{selectAllLabel}</TextLink> : null}
168
168
  {list.showDeselectAll ? (
169
- <LinkButton title={deselectAllLabel} onPress={list.deselectAll} />
169
+ <TextLink onPress={list.deselectAll}>{deselectAllLabel}</TextLink>
170
170
  ) : null}
171
171
  </View>
172
172
  ) : null}
@@ -188,8 +188,8 @@ const styles = StyleSheet.create({
188
188
  alignItems: "center",
189
189
  gap: 16,
190
190
  paddingVertical: 4,
191
- // Matches a MenuButton option's 8px text inset so the bleeding-wash
192
- // LinkButtons line up under the option labels above.
191
+ // Matches a MenuButton option's 8px text inset so the select-all / deselect-all
192
+ // links line up under the option labels above.
193
193
  paddingHorizontal: 8,
194
194
  borderTopWidth: 1,
195
195
  borderTopColor: colors.border,
@@ -106,8 +106,8 @@ export function SortHeader(props: SortHeaderProps) {
106
106
  }
107
107
 
108
108
  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).
109
+ // Negative margin so the hover wash bleeds while the label stays flush with the
110
+ // column content below.
111
111
  header: {
112
112
  flexDirection: "row",
113
113
  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
- });