@lotics/ui 12.1.1 → 13.7.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 +5 -0
- package/docs/catalog.md +78 -35
- package/docs/composition.md +61 -24
- package/docs/data_entry.md +19 -17
- package/docs/templates.md +71 -76
- package/examples/tpl_item_list.tsx +6 -6
- package/examples/tpl_record.tsx +131 -188
- package/examples/tpl_task_board.tsx +20 -22
- package/package.json +3 -2
- package/src/badge.tsx +26 -10
- package/src/checklist.tsx +1 -1
- package/src/choice_list.tsx +47 -36
- package/src/clarify.tsx +14 -24
- package/src/detail_row.tsx +24 -8
- package/src/form_date_picker.tsx +2 -1
- package/src/form_field.tsx +23 -3
- package/src/form_picker.tsx +2 -1
- package/src/form_switch.tsx +16 -2
- package/src/form_text_input.tsx +4 -4
- package/src/inline_date_picker.tsx +19 -9
- package/src/inline_edit.tsx +43 -24
- package/src/inline_member_select.tsx +43 -10
- package/src/inline_number_input.tsx +4 -4
- package/src/inline_select.tsx +193 -87
- package/src/inline_text_input.tsx +4 -4
- package/src/inline_time_picker.tsx +4 -4
- package/src/inset.tsx +38 -0
- package/src/linked_record_box.tsx +102 -0
- package/src/number_input.tsx +1 -1
- package/src/radio_picker.tsx +18 -28
- package/src/text_input_field.tsx +3 -3
- package/examples/tpl_tasks.tsx +0 -456
- package/src/inline_tag_select.tsx +0 -140
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
5
5
|
import { Button } from "@lotics/ui/button";
|
|
@@ -12,6 +12,7 @@ import type { PickerOption } from "@lotics/ui/picker";
|
|
|
12
12
|
import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
|
|
13
13
|
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
14
14
|
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
15
|
+
import { Inset } from "@lotics/ui/inset";
|
|
15
16
|
import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
16
17
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
17
18
|
import { MenuButton } from "@lotics/ui/menu_button";
|
|
@@ -58,10 +59,7 @@ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
|
58
59
|
import { Finding, FindingComparison } from "@lotics/ui/finding";
|
|
59
60
|
import { FormTextInput } from "@lotics/ui/form_text_input";
|
|
60
61
|
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
61
|
-
import {
|
|
62
|
-
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
63
|
-
import { useFocusRing } from "@lotics/ui/use_focus_ring";
|
|
64
|
-
import { FOCUS_RING } from "@lotics/ui/control_surface";
|
|
62
|
+
import { LinkedRecordBox } from "@lotics/ui/linked_record_box";
|
|
65
63
|
import { RadioPicker } from "@lotics/ui/radio_picker";
|
|
66
64
|
import { SearchInput } from "@lotics/ui/search_input";
|
|
67
65
|
import { useSelection } from "@lotics/ui/use_selection";
|
|
@@ -159,7 +157,27 @@ interface StageTask {
|
|
|
159
157
|
stage: Desk;
|
|
160
158
|
done: boolean;
|
|
161
159
|
assignee: string | null;
|
|
160
|
+
/** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
|
|
161
|
+
* no calendar glyph, coloured by urgency (`tone`), press to edit. */
|
|
162
|
+
due: string;
|
|
162
163
|
}
|
|
164
|
+
|
|
165
|
+
// A due date N days from today — keeps the seeded urgency states (overdue / soon /
|
|
166
|
+
// later) correct whenever the gallery is opened.
|
|
167
|
+
const dueIn = (n: number): string => {
|
|
168
|
+
const d = new Date();
|
|
169
|
+
d.setDate(d.getDate() + n);
|
|
170
|
+
return d.toISOString().slice(0, 10);
|
|
171
|
+
};
|
|
172
|
+
// The urgency tone for a due date, fed to the cell's `tone`: red past due, amber within
|
|
173
|
+
// 3 days, else muted — the date text itself is the signal (no separate dot). Done → muted.
|
|
174
|
+
const dueTone = (due: string, done: boolean): "danger" | "warning" | "muted" => {
|
|
175
|
+
if (!due || done) return "muted";
|
|
176
|
+
const today = new Date();
|
|
177
|
+
today.setHours(0, 0, 0, 0);
|
|
178
|
+
const days = Math.round((new Date(due).getTime() - today.getTime()) / 86_400_000);
|
|
179
|
+
return days < 0 ? "danger" : days <= 3 ? "warning" : "muted";
|
|
180
|
+
};
|
|
163
181
|
// Common-but-OPTIONAL tasks per desk — in a real app, the record type's
|
|
164
182
|
// configured checklist. A NEW record starts with an EMPTY list (tasks truly
|
|
165
183
|
// vary); these commons wait as dismissible suggestion CHIPS under each desk
|
|
@@ -180,13 +198,13 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
|
|
|
180
198
|
|
|
181
199
|
// The in-flight demo record's existing checklist (a NEW record gets []).
|
|
182
200
|
const TASK_SEEDS: StageTask[] = [
|
|
183
|
-
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01" },
|
|
184
|
-
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01" },
|
|
185
|
-
{ id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null },
|
|
186
|
-
{ id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02" },
|
|
187
|
-
{ id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null },
|
|
188
|
-
{ id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03" },
|
|
189
|
-
{ id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null },
|
|
201
|
+
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10) },
|
|
202
|
+
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "" },
|
|
203
|
+
{ id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2) },
|
|
204
|
+
{ id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1) },
|
|
205
|
+
{ id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6) },
|
|
206
|
+
{ id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "" },
|
|
207
|
+
{ id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14) },
|
|
190
208
|
];
|
|
191
209
|
|
|
192
210
|
// The assignable roster — an app feeds `useMembers()` here.
|
|
@@ -569,28 +587,6 @@ const FEE_COLUMNS: TableColumn[] = [
|
|
|
569
587
|
const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
|
|
570
588
|
|
|
571
589
|
|
|
572
|
-
/** The keyboard DOOR of a pressable linked-record box: an EMPTY absolutely-
|
|
573
|
-
* positioned sibling BENEATH the box's content — a button must not contain
|
|
574
|
-
* the box's interactive descendants (the `PressableRow`/`TableRow` law), so
|
|
575
|
-
* the door carries the tab stop, the accessible name and the focus ring;
|
|
576
|
-
* mouse presses ride the `PressableRow` surface, and the interior verbs
|
|
577
|
-
* lift above the door via `zIndex: 1`. */
|
|
578
|
-
function BoxDoor({ label, onPress }: { label: string; onPress: () => void }) {
|
|
579
|
-
const { focusVisible, focusProps } = useFocusRing();
|
|
580
|
-
return (
|
|
581
|
-
<Pressable
|
|
582
|
-
accessibilityRole="button"
|
|
583
|
-
accessibilityLabel={label}
|
|
584
|
-
onPress={onPress}
|
|
585
|
-
{...focusProps}
|
|
586
|
-
style={[
|
|
587
|
-
{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0, borderRadius: 10 },
|
|
588
|
-
focusVisible ? { boxShadow: FOCUS_RING } : null,
|
|
589
|
-
]}
|
|
590
|
-
/>
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
590
|
/** The file-capable comment editor injected via `renderEditForm` — text + the
|
|
595
591
|
* comment's attachments (each removable), committed together (the DEFAULT
|
|
596
592
|
* edit form is text-only; the product injects its own equivalent). */
|
|
@@ -627,12 +623,13 @@ const DOC_COLUMNS: TableColumn[] = [
|
|
|
627
623
|
// folds behind "Show all forms". (The Documents section above is the INTAKE
|
|
628
624
|
// register — received files; this registry is what the record can PRODUCE.)
|
|
629
625
|
// READINESS: a form declares which RECORD FIELDS it reads (`needs`). The
|
|
630
|
-
// fields' HOME stays their DATA section (the colocation law)
|
|
631
|
-
// not-ready row
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
626
|
+
// fields' HOME stays their DATA section (the colocation law). Warning and fix
|
|
627
|
+
// are CO-LOCATED on the row: a not-ready row's mark NAMES what's missing (muted
|
|
628
|
+
// at rest so gaps show without checking, warning once checked), and the fix sits
|
|
629
|
+
// right under it in the `expansion` — an `Inset` (a FORM surface, never a Callout,
|
|
630
|
+
// which is an ARIA alert) with the editors, saved onto the record in place. Drafts
|
|
631
|
+
// are keyed BY FIELD, so a field two checked forms share is entered once and one
|
|
632
|
+
// Save resolves both. A ready form stays silent.
|
|
636
633
|
type NeedKey = "delivery_address" | "commodity_code" | "hazard_class";
|
|
637
634
|
const NEEDS: Record<NeedKey, { label: string; hint: string }> = {
|
|
638
635
|
delivery_address: { label: "Delivery address", hint: "The consignee's dock — printed on the delivery note" },
|
|
@@ -1039,21 +1036,11 @@ export function TplRecord() {
|
|
|
1039
1036
|
const isEligible = (f: SetForm) => f.id !== "c3" || hazardousGoods;
|
|
1040
1037
|
const checkedForms = ALL_FORMS.filter((f) => isEligible(f) && chosenForms.has(f.id));
|
|
1041
1038
|
const blockingNeeds = [...new Set(checkedForms.flatMap(missingOf))];
|
|
1042
|
-
// the
|
|
1043
|
-
// the same field
|
|
1044
|
-
//
|
|
1039
|
+
// the per-row fills' DRAFTS — keyed by field, SHARED between the rows of forms that
|
|
1040
|
+
// need the same field, so filling it under one form resolves it for all; Save commits
|
|
1041
|
+
// onto the record and the field's home row shows the value (the need resolves, the
|
|
1042
|
+
// row's mark clears, the fill drops away).
|
|
1045
1043
|
const [fillDrafts, setFillDrafts] = useState<Record<string, string>>({});
|
|
1046
|
-
// which rows have their fill editor OPEN — toggled ONLY by the row's
|
|
1047
|
-
// explicit "Add missing fields" trigger, never as a side effect of
|
|
1048
|
-
// checking (an undiscoverable expansion is no affordance)
|
|
1049
|
-
const [openFill, setOpenFill] = useState<ReadonlySet<string>>(new Set());
|
|
1050
|
-
const toggleFillOpen = (fid: string) =>
|
|
1051
|
-
setOpenFill((prev) => {
|
|
1052
|
-
const next = new Set(prev);
|
|
1053
|
-
if (next.has(fid)) next.delete(fid);
|
|
1054
|
-
else next.add(fid);
|
|
1055
|
-
return next;
|
|
1056
|
-
});
|
|
1057
1044
|
const needSetters: Record<NeedKey, (v: string) => void> = { delivery_address: setDeliveryAddress, commodity_code: setCommodityCode, hazard_class: setHazardClass };
|
|
1058
1045
|
const saveNeeds = (keys: NeedKey[]) => {
|
|
1059
1046
|
for (const k of keys) needSetters[k]((fillDrafts[k] ?? "").trim());
|
|
@@ -1125,11 +1112,11 @@ export function TplRecord() {
|
|
|
1125
1112
|
const addTask = () => {
|
|
1126
1113
|
const label = newTask.trim();
|
|
1127
1114
|
if (!label) return;
|
|
1128
|
-
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null }]);
|
|
1115
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "" }]);
|
|
1129
1116
|
setNewTask("");
|
|
1130
1117
|
};
|
|
1131
1118
|
const addSuggested = (desk: Desk, label: string) =>
|
|
1132
|
-
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null }]);
|
|
1119
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "" }]);
|
|
1133
1120
|
const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
|
|
1134
1121
|
const moveTask = (tid: string, desk: Desk) =>
|
|
1135
1122
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
|
|
@@ -1138,6 +1125,8 @@ export function TplRecord() {
|
|
|
1138
1125
|
const [dismissed, setDismissed] = useState<string[]>([]);
|
|
1139
1126
|
const assignTask = (tid: string, member: string | null) =>
|
|
1140
1127
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
|
|
1128
|
+
const dueTask = (tid: string, due: string) =>
|
|
1129
|
+
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
|
|
1141
1130
|
// The Task-list grammar: a clearable Group-by + filter chips derive the
|
|
1142
1131
|
// sections; empty groups drop (except desks — the journey stays visible).
|
|
1143
1132
|
const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
|
|
@@ -1539,7 +1528,7 @@ export function TplRecord() {
|
|
|
1539
1528
|
{/* CLASSIFICATION — the right-input-per-field law, worked. A field
|
|
1540
1529
|
gets the control its SHAPE wants, not a default text box:
|
|
1541
1530
|
· ≤5 exclusive options the user should SEE → RadioPicker
|
|
1542
|
-
(
|
|
1531
|
+
(a stacked column of full-width choices)
|
|
1543
1532
|
· a longer closed list → InlineSelect (rich options carry a
|
|
1544
1533
|
description line — it shows in the resting row too)
|
|
1545
1534
|
· a pick from a big REGISTRY → InlineSelect searchable (the
|
|
@@ -1585,7 +1574,6 @@ export function TplRecord() {
|
|
|
1585
1574
|
<DetailRow label="Insurance">
|
|
1586
1575
|
<RadioPicker
|
|
1587
1576
|
accessibilityLabel="Insurance"
|
|
1588
|
-
direction="row"
|
|
1589
1577
|
value={insurance}
|
|
1590
1578
|
onValueChange={setInsurance}
|
|
1591
1579
|
options={[
|
|
@@ -1808,7 +1796,7 @@ export function TplRecord() {
|
|
|
1808
1796
|
desk's rows fully editable (the stage gates the handoff
|
|
1809
1797
|
and Billing, never task editing — planning ahead on a
|
|
1810
1798
|
later desk is normal work) */}
|
|
1811
|
-
<Checklist trailingWidth={
|
|
1799
|
+
<Checklist trailingWidth={152}>
|
|
1812
1800
|
{g.items.map((t) => {
|
|
1813
1801
|
const next = nextDeskOf(t.stage);
|
|
1814
1802
|
const menuItems: ActionMenuItem[] = [
|
|
@@ -1828,19 +1816,21 @@ export function TplRecord() {
|
|
|
1828
1816
|
/>
|
|
1829
1817
|
}
|
|
1830
1818
|
trailing={
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1819
|
+
// Due + assignee as COMPACT grid cells (`variant="cell"`): the due a
|
|
1820
|
+
// urgency-coloured date (no calendar glyph), the assignee a bare AVATAR
|
|
1821
|
+
// (`avatarOnly` — no name/chevron, a dashed add-ghost when unset). Both
|
|
1822
|
+
// hug within the list's `trailingWidth` so the title never overlaps.
|
|
1823
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
|
|
1824
|
+
<View style={{ width: 104 }}>
|
|
1825
|
+
<InlineDatePicker variant="cell" tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Due…" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
|
|
1826
|
+
</View>
|
|
1827
|
+
<InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
|
|
1828
|
+
</View>
|
|
1839
1829
|
}
|
|
1840
1830
|
menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
|
|
1841
1831
|
>
|
|
1842
1832
|
<InlineTextInput
|
|
1843
|
-
|
|
1833
|
+
variant="cell"
|
|
1844
1834
|
value={t.label}
|
|
1845
1835
|
onSave={(v) => renameTask(t.id, v)}
|
|
1846
1836
|
struck={t.done}
|
|
@@ -1965,50 +1955,28 @@ export function TplRecord() {
|
|
|
1965
1955
|
</SectionHeading>
|
|
1966
1956
|
{customer ? (
|
|
1967
1957
|
<View style={{ gap: 10 }}>
|
|
1968
|
-
{/*
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
<
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
</View>
|
|
1991
|
-
<View style={{ gap: 6 }}>
|
|
1992
|
-
{[
|
|
1993
|
-
{ label: "Tax ID", value: customer.taxId || "—" },
|
|
1994
|
-
{ label: "Contact", value: customer.contact || "—" },
|
|
1995
|
-
{ label: "City", value: customer.city || "—" },
|
|
1996
|
-
].map((f) => (
|
|
1997
|
-
<View key={f.label} style={{ flexDirection: "row", gap: 10 }}>
|
|
1998
|
-
<Text size="sm" color="muted" style={{ width: 80 }}>{f.label}</Text>
|
|
1999
|
-
<Text size="sm" style={{ flex: 1 }}>{f.value}</Text>
|
|
2000
|
-
</View>
|
|
2001
|
-
))}
|
|
2002
|
-
</View>
|
|
2003
|
-
{/* the action row, INSIDE the box: destructive on the LEFT,
|
|
2004
|
-
the go-to verb on the RIGHT (the box itself = details);
|
|
2005
|
-
zIndex lifts the verbs above the door's hit area */}
|
|
2006
|
-
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 }}>
|
|
2007
|
-
<Button title="Remove" color="danger" onPress={() => setCustomerId(null)} />
|
|
2008
|
-
<View style={{ flex: 1 }} />
|
|
2009
|
-
<Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the customer's page */ }} />
|
|
2010
|
-
</View>
|
|
2011
|
-
</PressableRow>
|
|
1958
|
+
{/* The linked-record REFERENCE: the whole box is the keyboard door to the
|
|
1959
|
+
customer's detail (the a11y contract lives in `LinkedRecordBox`); the verbs
|
|
1960
|
+
ride inside — destructive Remove LEFT, go-to Open record RIGHT. */}
|
|
1961
|
+
<LinkedRecordBox
|
|
1962
|
+
icon="building-2"
|
|
1963
|
+
name={customer.name}
|
|
1964
|
+
subtitle={customer.code}
|
|
1965
|
+
facts={[
|
|
1966
|
+
{ label: "Tax ID", value: customer.taxId || "—" },
|
|
1967
|
+
{ label: "Contact", value: customer.contact || "—" },
|
|
1968
|
+
{ label: "City", value: customer.city || "—" },
|
|
1969
|
+
]}
|
|
1970
|
+
doorLabel={`${customer.name} — details`}
|
|
1971
|
+
onOpen={() => setCustomerOpen(true)}
|
|
1972
|
+
actions={
|
|
1973
|
+
<>
|
|
1974
|
+
<Button title="Remove" color="danger" onPress={() => setCustomerId(null)} />
|
|
1975
|
+
<View style={{ flex: 1 }} />
|
|
1976
|
+
<Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the customer's page */ }} />
|
|
1977
|
+
</>
|
|
1978
|
+
}
|
|
1979
|
+
/>
|
|
2012
1980
|
{/* invalid STATE is a Callout CO-LOCATED with what it describes —
|
|
2013
1981
|
it lives on the customer card, not floating at a section top */}
|
|
2014
1982
|
{!taxIdValid ? (
|
|
@@ -2022,7 +1990,17 @@ export function TplRecord() {
|
|
|
2022
1990
|
<Combobox
|
|
2023
1991
|
options={customerOptions}
|
|
2024
1992
|
onValueChange={onPickCustomer}
|
|
2025
|
-
|
|
1993
|
+
renderOptionContent={(o) => (
|
|
1994
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
1995
|
+
<View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
|
|
1996
|
+
<Icon name="building-2" size={14} color={colors.zinc[600]} />
|
|
1997
|
+
</View>
|
|
1998
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
1999
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{o.label}</Text>
|
|
2000
|
+
{o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(" · ")}</Text> : null}
|
|
2001
|
+
</View>
|
|
2002
|
+
</View>
|
|
2003
|
+
)}
|
|
2026
2004
|
reflectSelection={false}
|
|
2027
2005
|
allowCustom
|
|
2028
2006
|
customOptionPlacement="top"
|
|
@@ -2200,32 +2178,27 @@ export function TplRecord() {
|
|
|
2200
2178
|
alignment drifts. controlWidth 24 = CheckboxInput. */}
|
|
2201
2179
|
<Checklist controlWidth={24}>
|
|
2202
2180
|
{shown.map((f) => {
|
|
2203
|
-
// READINESS per row,
|
|
2204
|
-
//
|
|
2205
|
-
// checked,
|
|
2206
|
-
//
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
2209
|
-
//
|
|
2210
|
-
// (their home rows show the values) and the panel
|
|
2211
|
-
// collapses resolved; mark + trigger vanish = ready. A
|
|
2212
|
-
// ready form stays SILENT. Checking never expands
|
|
2213
|
-
// anything by itself.
|
|
2181
|
+
// READINESS per row, CO-LOCATED: a not-ready row's mark NAMES what's
|
|
2182
|
+
// missing (muted at rest — discoverable without checking; warning once
|
|
2183
|
+
// checked, it now blocks), and the FIX sits right under it in the
|
|
2184
|
+
// `expansion` — an `Inset` (a FORM surface, never a Callout, which is an
|
|
2185
|
+
// ARIA alert) with the editor for each gap, saved onto the record in
|
|
2186
|
+
// place. Drafts are keyed BY FIELD, so a field two checked forms share
|
|
2187
|
+
// is entered once and one Save resolves both. A ready form stays SILENT.
|
|
2214
2188
|
const missing = missingOf(f);
|
|
2215
2189
|
const checked = chosenForms.has(f.id);
|
|
2216
|
-
const filling = missing.length > 0 && openFill.has(f.id);
|
|
2217
2190
|
return (
|
|
2218
2191
|
<ChecklistRow
|
|
2219
2192
|
key={f.id}
|
|
2220
2193
|
control={<CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />}
|
|
2221
|
-
meta={missing.length > 0
|
|
2222
|
-
<View style={{ flexDirection: "row", alignItems: "
|
|
2223
|
-
<Icon name="circle-alert" size={
|
|
2224
|
-
<Text size="xs" color={checked ? "warning" : "muted"}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
|
|
2194
|
+
meta={missing.length > 0 ? (
|
|
2195
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
|
|
2196
|
+
<Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
|
|
2197
|
+
<Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
|
|
2225
2198
|
</View>
|
|
2226
2199
|
) : undefined}
|
|
2227
|
-
expansion={
|
|
2228
|
-
<
|
|
2200
|
+
expansion={checked && missing.length > 0 ? (
|
|
2201
|
+
<Inset>
|
|
2229
2202
|
<Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
|
|
2230
2203
|
{missing.map((k) => (
|
|
2231
2204
|
<FormTextInput
|
|
@@ -2237,31 +2210,18 @@ export function TplRecord() {
|
|
|
2237
2210
|
accessibilityLabel={NEEDS[k].label}
|
|
2238
2211
|
/>
|
|
2239
2212
|
))}
|
|
2240
|
-
<View style={{ flexDirection: "row", justifyContent: "flex-end"
|
|
2241
|
-
{/* secondary — the section keeps ONE primary
|
|
2242
|
-
(Create); disabled while a draft is empty */}
|
|
2243
|
-
<Button title="Cancel" color="muted" onPress={() => toggleFillOpen(f.id)} />
|
|
2213
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
|
|
2244
2214
|
<Button
|
|
2245
2215
|
title="Save fields"
|
|
2246
2216
|
color="secondary"
|
|
2247
|
-
disabled={missing.
|
|
2248
|
-
onPress={() =>
|
|
2217
|
+
disabled={missing.every((k) => !(fillDrafts[k] ?? "").trim())}
|
|
2218
|
+
onPress={() => saveNeeds(missing)}
|
|
2249
2219
|
/>
|
|
2250
2220
|
</View>
|
|
2251
|
-
</
|
|
2221
|
+
</Inset>
|
|
2252
2222
|
) : undefined}
|
|
2253
2223
|
>
|
|
2254
|
-
<
|
|
2255
|
-
<Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
|
|
2256
|
-
{/* the trigger hides while ITS panel is open —
|
|
2257
|
-
the panel's Cancel is the closer; a toggle
|
|
2258
|
-
verb above an open editor is noise */}
|
|
2259
|
-
{missing.length > 0 && !filling ? (
|
|
2260
|
-
<TextLink size="xs" onPress={() => toggleFillOpen(f.id)} style={{ color: colors.blue[600] }}>
|
|
2261
|
-
Add missing fields
|
|
2262
|
-
</TextLink>
|
|
2263
|
-
) : null}
|
|
2264
|
-
</View>
|
|
2224
|
+
<Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
|
|
2265
2225
|
</ChecklistRow>
|
|
2266
2226
|
);
|
|
2267
2227
|
})}
|
|
@@ -2291,11 +2251,10 @@ export function TplRecord() {
|
|
|
2291
2251
|
</Subsection>
|
|
2292
2252
|
<Subsection>
|
|
2293
2253
|
<View style={{ gap: 12 }}>
|
|
2294
|
-
{/* the CONSEQUENCE, co-located, once
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
self-evident. */}
|
|
2254
|
+
{/* the CONSEQUENCE, co-located, once — the gating law's degraded-but-valid
|
|
2255
|
+
case (Create stays ENABLED, missing fields print blank, the press
|
|
2256
|
+
confirms); the REPAIR lives co-located in each not-ready row's fill above.
|
|
2257
|
+
An empty pick stays silently disabled — self-evident. */}
|
|
2299
2258
|
{blockingNeeds.length > 0 ? (
|
|
2300
2259
|
<Callout tone="warning">
|
|
2301
2260
|
<CalloutText>
|
|
@@ -2443,41 +2402,25 @@ export function TplRecord() {
|
|
|
2443
2402
|
{siblings.length > 0 ? (
|
|
2444
2403
|
<View style={{ gap: 16 }}>
|
|
2445
2404
|
{siblings.map((sb) => (
|
|
2446
|
-
<
|
|
2405
|
+
<LinkedRecordBox
|
|
2447
2406
|
key={sb.id}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
{ label: "Note", value: sb.note || "—" },
|
|
2466
|
-
].map((f) => (
|
|
2467
|
-
<View key={f.label} style={{ flexDirection: "row", gap: 10 }}>
|
|
2468
|
-
<Text size="sm" color="muted" style={{ width: 80 }}>{f.label}</Text>
|
|
2469
|
-
<Text size="sm" style={{ flex: 1 }}>{f.value}</Text>
|
|
2470
|
-
</View>
|
|
2471
|
-
))}
|
|
2472
|
-
</View>
|
|
2473
|
-
{/* the action row, INSIDE the box, the go-to verb on the
|
|
2474
|
-
RIGHT (the box itself = details; nothing destructive
|
|
2475
|
-
here — the handoff mark stays); zIndex lifts it above
|
|
2476
|
-
the door's hit area */}
|
|
2477
|
-
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, justifyContent: "flex-end", zIndex: 1 }}>
|
|
2478
|
-
<Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the sibling's page */ }} />
|
|
2479
|
-
</View>
|
|
2480
|
-
</PressableRow>
|
|
2407
|
+
icon="file-text"
|
|
2408
|
+
name={sb.code}
|
|
2409
|
+
subtitle={`${sb.desk} record`}
|
|
2410
|
+
facts={[
|
|
2411
|
+
{ label: "Assignee", value: sb.assignee || "—" },
|
|
2412
|
+
{ label: "Created", value: sb.at },
|
|
2413
|
+
{ label: "Note", value: sb.note || "—" },
|
|
2414
|
+
]}
|
|
2415
|
+
doorLabel={`${sb.code} — details`}
|
|
2416
|
+
onOpen={() => setSiblingOpen(sb.id)}
|
|
2417
|
+
actions={
|
|
2418
|
+
<>
|
|
2419
|
+
<View style={{ flex: 1 }} />
|
|
2420
|
+
<Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the sibling's page */ }} />
|
|
2421
|
+
</>
|
|
2422
|
+
}
|
|
2423
|
+
/>
|
|
2481
2424
|
))}
|
|
2482
2425
|
</View>
|
|
2483
2426
|
) : null}
|
|
@@ -16,7 +16,6 @@ import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
|
16
16
|
import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
|
|
17
17
|
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
18
18
|
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
19
|
-
import { Select } from "@lotics/ui/select";
|
|
20
19
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
21
20
|
import { ActionMenu } from "@lotics/ui/action_menu";
|
|
22
21
|
import { Alert } from "@lotics/ui/alert";
|
|
@@ -25,7 +24,7 @@ import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
|
|
|
25
24
|
import { FilesEditor } from "@lotics/ui/files_editor";
|
|
26
25
|
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
27
26
|
import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
|
|
28
|
-
import { CONTROL_RADIUS
|
|
27
|
+
import { CONTROL_RADIUS } from "@lotics/ui/control_surface";
|
|
29
28
|
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
30
29
|
import { Composer } from "@lotics/ui/composer";
|
|
31
30
|
import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
|
|
@@ -265,10 +264,10 @@ export function TplTaskBoard() {
|
|
|
265
264
|
}
|
|
266
265
|
|
|
267
266
|
const propertyCols: DataGridColumn<Task>[] = [
|
|
268
|
-
{ key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect
|
|
269
|
-
{ key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker
|
|
270
|
-
{ key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect
|
|
271
|
-
{ key: "tags", label: "Tags", width: 168, cell: (t) => <
|
|
267
|
+
{ key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect variant="cell" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} onClear={() => patch(t.id, { ownerId: null })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
|
|
268
|
+
{ key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker variant="cell" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} onClear={() => patch(t.id, { due: null })} placeholder="No date" accessibilityLabel="Due date" /> },
|
|
269
|
+
{ key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect variant="cell" 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) => <InlineSelect multi searchable allowCustom variant="cell" value={t.tags.map((tag) => tag.value)} onSave={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" /> },
|
|
272
271
|
];
|
|
273
272
|
const filesCol: DataGridColumn<Task> = {
|
|
274
273
|
key: "files", label: "Files", width: 100, sortable: false,
|
|
@@ -295,7 +294,7 @@ export function TplTaskBoard() {
|
|
|
295
294
|
const visibleProps = propertyCols.filter((c) => c.key !== groupBy);
|
|
296
295
|
const addCols = [...visibleProps, filesCol, actionCol, menuCol];
|
|
297
296
|
const columns: DataGridColumn<Task>[] = [
|
|
298
|
-
{ key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput
|
|
297
|
+
{ key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput variant="cell" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
|
|
299
298
|
...addCols,
|
|
300
299
|
];
|
|
301
300
|
|
|
@@ -436,12 +435,12 @@ function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: s
|
|
|
436
435
|
<View style={{ gap: 10 }}>
|
|
437
436
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
|
|
438
437
|
<View style={{ flex: 1 }}>
|
|
439
|
-
<InlineTextInput
|
|
438
|
+
<InlineTextInput variant="cell" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
|
|
440
439
|
</View>
|
|
441
440
|
<View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
|
|
442
441
|
</View>
|
|
443
442
|
<View style={{ width: 220 }}>
|
|
444
|
-
<InlineDatePicker
|
|
443
|
+
<InlineDatePicker variant="cell" value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
|
|
445
444
|
</View>
|
|
446
445
|
<View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
|
|
447
446
|
</View>
|
|
@@ -528,10 +527,10 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
|
|
|
528
527
|
// Files / action / ⋯ act on a task that already exists, so the add row leaves
|
|
529
528
|
// those columns empty — the widths are still reserved so the columns stay aligned.
|
|
530
529
|
const editor = (key: string) =>
|
|
531
|
-
key === "assignee" ? <InlineMemberSelect
|
|
532
|
-
: key === "due" ? <InlineDatePicker
|
|
533
|
-
: key === "status" ? <InlineSelect
|
|
534
|
-
: key === "tags" ? <
|
|
530
|
+
key === "assignee" ? <InlineMemberSelect variant="cell" members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
|
|
531
|
+
: key === "due" ? <InlineDatePicker variant="cell" value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
|
|
532
|
+
: key === "status" ? <InlineSelect variant="cell" value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
|
|
533
|
+
: key === "tags" ? <InlineSelect multi searchable allowCustom variant="cell" value={tags.map((tag) => tag.value)} onSave={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" />
|
|
535
534
|
: null;
|
|
536
535
|
|
|
537
536
|
return (
|
|
@@ -556,18 +555,17 @@ const styles = StyleSheet.create({
|
|
|
556
555
|
ring: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
|
|
557
556
|
input: { borderWidth: 0, backgroundColor: "transparent" },
|
|
558
557
|
addField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
|
|
559
|
-
// Match the
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
clipHover: { borderColor: HOVER_BORDER },
|
|
558
|
+
// Match the CELL height (40) so the files field lines up with the others — and that
|
|
559
|
+
// height affords a larger thumbnail. Hover = the background-tint WASH (zinc-100), the
|
|
560
|
+
// standard CELL language (same as the variant="cell" editors beside these).
|
|
561
|
+
clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, cursor: "pointer" },
|
|
562
|
+
clipHover: { backgroundColor: colors.zinc[100] },
|
|
565
563
|
clipPreview: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
566
564
|
summary: { flexDirection: "row", alignItems: "center", gap: 4 },
|
|
567
565
|
summaryDot: { width: 8, height: 8, borderRadius: 999 },
|
|
568
566
|
result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
|
|
569
|
-
// Fills the cell (height + width) so the link's hit area matches the
|
|
570
|
-
action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS,
|
|
571
|
-
actionHover: {
|
|
567
|
+
// Fills the cell (height + width) so the link's hit area matches the cells; wash hover.
|
|
568
|
+
action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, cursor: "pointer" },
|
|
569
|
+
actionHover: { backgroundColor: colors.zinc[100] },
|
|
572
570
|
source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
|
|
573
571
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"./use_selection": "./src/use_selection.ts",
|
|
37
37
|
"./use_section_nav": "./src/use_section_nav.ts",
|
|
38
38
|
"./ledger": "./src/ledger.tsx",
|
|
39
|
-
"./inline_tag_select": "./src/inline_tag_select.tsx",
|
|
40
39
|
"./use_selection_mode": "./src/use_selection_mode.ts",
|
|
41
40
|
"./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
|
|
42
41
|
"./uploading_thumbnail": "./src/uploading_thumbnail.tsx",
|
|
@@ -120,6 +119,8 @@
|
|
|
120
119
|
"./card_select_item": "./src/card_select_item.tsx",
|
|
121
120
|
"./badge": "./src/badge.tsx",
|
|
122
121
|
"./callout": "./src/callout.tsx",
|
|
122
|
+
"./inset": "./src/inset.tsx",
|
|
123
|
+
"./linked_record_box": "./src/linked_record_box.tsx",
|
|
123
124
|
"./inline_edit": "./src/inline_edit.tsx",
|
|
124
125
|
"./inline_static": "./src/inline_static.tsx",
|
|
125
126
|
"./inline_text_input": "./src/inline_text_input.tsx",
|