@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 +18 -8
- package/examples/tpl_task_board.tsx +102 -18
- package/examples/tpl_tasks.tsx +31 -15
- package/package.json +2 -2
- package/src/filter_chip.tsx +2 -2
- package/src/link.tsx +6 -6
- package/src/option_list.tsx +5 -5
- package/src/sort_header.tsx +2 -2
- package/src/text.tsx +5 -0
- package/src/text_link.tsx +45 -0
- package/src/link_button.tsx +0 -51
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`), `
|
|
30
|
-
action
|
|
31
|
-
|
|
32
|
-
|
|
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),
|
|
685
|
-
|
|
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 ·
|
|
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:
|
|
232
|
-
{ key: "due", label: "Due", width:
|
|
233
|
-
{ key: "status", label: "Status", width:
|
|
234
|
-
{ key: "tags", label: "Tags", width:
|
|
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:
|
|
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:
|
|
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
|
|
435
|
-
//
|
|
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/examples/tpl_tasks.tsx
CHANGED
|
@@ -285,25 +285,38 @@ export function TplTasks() {
|
|
|
285
285
|
const d = dueInfo(t.due);
|
|
286
286
|
return (
|
|
287
287
|
<View key={t.id}>
|
|
288
|
-
|
|
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
|
-
<
|
|
294
|
-
{t.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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": "
|
|
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
|
-
"./
|
|
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",
|
package/src/filter_chip.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
|
20
|
-
* else" signal (a document URL, a record, an invoice)
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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/option_list.tsx
CHANGED
|
@@ -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 {
|
|
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 ? <
|
|
167
|
+
{list.showSelectAll ? <TextLink onPress={list.selectAll}>{selectAllLabel}</TextLink> : null}
|
|
168
168
|
{list.showDeselectAll ? (
|
|
169
|
-
<
|
|
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
|
|
192
|
-
//
|
|
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,
|
package/src/sort_header.tsx
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/link_button.tsx
DELETED
|
@@ -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
|
-
});
|