@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 +24 -10
- package/examples/tpl_task_board.tsx +102 -18
- package/package.json +3 -2
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +4 -2
- package/src/drawer.tsx +6 -4
- package/src/filter_chip.tsx +5 -3
- package/src/link.tsx +6 -6
- package/src/locale.tsx +107 -0
- package/src/option_list.tsx +11 -16
- package/src/pagination.tsx +8 -12
- package/src/sort_header.tsx +6 -8
- package/src/text.tsx +5 -0
- package/src/text_link.tsx +45 -0
- package/src/link_button.tsx +0 -51
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
|
|
16
|
-
|
|
17
|
-
|
|
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`), `
|
|
30
|
-
action
|
|
31
|
-
|
|
32
|
-
|
|
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),
|
|
685
|
-
|
|
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 ·
|
|
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:
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
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
|
-
"./
|
|
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",
|
package/src/column_filter.tsx
CHANGED
|
@@ -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.
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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}
|
package/src/filter_chip.tsx
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
<
|
|
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
|
|
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/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
|
+
}
|
package/src/option_list.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 ? <
|
|
162
|
+
{list.showSelectAll ? <TextLink onPress={list.selectAll}>{selectAllLabel}</TextLink> : null}
|
|
168
163
|
{list.showDeselectAll ? (
|
|
169
|
-
<
|
|
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
|
|
192
|
-
//
|
|
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,
|
package/src/pagination.tsx
CHANGED
|
@@ -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 ??
|
|
69
|
-
: (labels?.range ??
|
|
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 ??
|
|
75
|
-
: (labels?.page ??
|
|
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 ??
|
|
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 ??
|
|
96
|
+
tooltip={labels?.next ?? loc.next}
|
|
101
97
|
/>
|
|
102
98
|
</View>
|
|
103
99
|
</View>
|
package/src/sort_header.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
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
|
-
});
|