@lotics/ui 7.9.0 → 7.10.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 +21 -11
- package/examples/tpl_record_plain.tsx +120 -15
- package/package.json +3 -1
- package/src/danger_zone.tsx +58 -0
- package/src/detail_row.tsx +8 -1
- package/src/inline_edit.tsx +14 -4
- package/src/inline_select.tsx +9 -8
- package/src/inline_static.tsx +63 -0
- package/examples/tpl_record.tsx +0 -302
package/AGENTS.md
CHANGED
|
@@ -69,7 +69,8 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
69
69
|
via `TimePicker`.
|
|
70
70
|
- **Edit a record's fields in place** — the `Inline*` family: `InlineTextInput` ·
|
|
71
71
|
`InlineNumberInput` · `InlineSelect` · `InlineMemberSelect` · `InlineDatePicker` ·
|
|
72
|
-
`InlineTimePicker` (
|
|
72
|
+
`InlineTimePicker`; a READ-ONLY field in that same column uses `InlineStatic` (matches the
|
|
73
|
+
editor box exactly, no input chrome, so it aligns pixel-for-pixel) (see §Data entry).
|
|
73
74
|
- **Tasks / to-dos** — there is NO Task component (a task row varies too much app-to-app to abstract
|
|
74
75
|
well — a Reminders row and a Linear column-grid share almost nothing). Compose the row directly:
|
|
75
76
|
`CheckCircle` (the completion ring — fills with a spring, distinct from the square `Checkbox`) + an
|
|
@@ -112,10 +113,11 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
112
113
|
`CardFooter`), `Section` (the card-less twin of `Card`: bare gap-spaced region + `SectionHeading`/
|
|
113
114
|
`SectionHeadingTitle`/`SectionHeadingMeta`; no body component — children are the body), `SectionCard`,
|
|
114
115
|
`PageHeader` /
|
|
115
|
-
`PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper
|
|
116
|
+
`PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper`,
|
|
117
|
+
`DangerZone` (the destructive section — delete/archive — set apart at the bottom of a record/settings surface).
|
|
116
118
|
- **Rows & registers** — `PressableRow` (THE register row), `ListItem`, `MenuButton`,
|
|
117
|
-
`MenuListItem`, `DetailRow` (label+value for drawers/peeks
|
|
118
|
-
(bulk-select bar).
|
|
119
|
+
`MenuListItem`, `DetailRow` (label+value for drawers/peeks; optional `trailing` slot for a
|
|
120
|
+
right-side action/badge/unit), `ActionMenu` (⋯), `FloatingActionBar` (bulk-select bar).
|
|
119
121
|
- **Filters & view controls** — `SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`,
|
|
120
122
|
`Counter`), `Chip`.
|
|
121
123
|
- **Overlays** — `Dialog` (centered card over a scrim), `Modal` (+ `ModalHeader`/`ModalBody`/
|
|
@@ -188,15 +190,22 @@ hover reveals its border (it's a field — no grey tint, no pencil that shifts),
|
|
|
188
190
|
reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
|
|
189
191
|
`controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
|
|
190
192
|
(`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats an
|
|
191
|
-
`OptionList` in a popover so the row never grows;
|
|
192
|
-
|
|
193
|
+
`OptionList` in a popover so the row never grows; the RESTING value renders like its option
|
|
194
|
+
(`renderOptionContent` by default; `renderSelected` overrides) — a colored `OptionBadge`, not just a label) · `InlineMemberSelect` (a `MemberChip` at rest →
|
|
193
195
|
member picker; the inline twin of `MemberSelect`) · `InlineDatePicker` (`format="datetime"` for
|
|
194
196
|
always-on time; `optionalTime` to let the user ADD/REMOVE a time — the value's own shape, date vs
|
|
195
197
|
datetime, is the source of truth) · `InlineTimePicker` — all
|
|
196
198
|
on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
|
|
197
199
|
saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
|
|
198
|
-
without losing the edit. Pair with `DetailRow` (label left, inline value right).
|
|
199
|
-
|
|
200
|
+
without losing the edit. Pair with `DetailRow` (label left, inline value right). A READ-ONLY value
|
|
201
|
+
in the same column — a computed total, a system ID, a synced/locked field — is `InlineStatic`: it
|
|
202
|
+
copies the editor box exactly (height, padding, 1px transparent border) but is non-interactive, so
|
|
203
|
+
it sits FLUSH with the editors instead of reading as a disabled input. To hang a right-side
|
|
204
|
+
action/badge/unit off a row, use `DetailRow`'s `trailing` slot (wrap the editor in a `flex:1` View
|
|
205
|
+
so it takes the slack and the trailing pins right) — see the "Row anatomy" section of
|
|
206
|
+
`tpl_record_plain`, which also reads top→bottom as a full drawer record (header → fields →
|
|
207
|
+
`DangerZone`). Not every field is a same-height swap — a tag field, a status, or an attachment grid
|
|
208
|
+
edit in place too (below).
|
|
200
209
|
|
|
201
210
|
### Fieldset form — fields lay out on a RESPONSIVE two-column grid
|
|
202
211
|
Never hard-code columns, never 3-up. A fieldset is `flexDirection:row, flexWrap:wrap,
|
|
@@ -818,7 +827,7 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
|
|
|
818
827
|
transactional form: find-or-create + line items + attachment CRUD + 2-col grid) · `tpl_billing`
|
|
819
828
|
(charges→invoice→collect: the invoice DOCUMENT is the unit — editable charge lines + method,
|
|
820
829
|
live total, status, inline-gated issue; validated receipt; separate deposit) · `tpl_quick`
|
|
821
|
-
(quick-capture log) · `tpl_wizard` (`Stepper` form + review) · `
|
|
830
|
+
(quick-capture log) · `tpl_wizard` (`Stepper` form + review) · `tpl_record_plain` (inline edit + Stage
|
|
822
831
|
disposition + `Select` tags + Documents grid; `tpl_record_plain` = card-less).
|
|
823
832
|
- **Records & lookup** — `tpl_detail` (full record + tabs) · `tpl_directory` (searchable register) ·
|
|
824
833
|
`tpl_timeline` (audit feed).
|
|
@@ -856,8 +865,9 @@ text_input_field · number_input · search_input · form_field · checkbox · ch
|
|
|
856
865
|
radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
|
|
857
866
|
inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
|
|
858
867
|
inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
|
|
859
|
-
`InlineEditView`; `InlineSelect`/`InlineMemberSelect`
|
|
860
|
-
|
|
868
|
+
`InlineEditView`; `InlineSelect`/`InlineMemberSelect` render the resting value like its option — `renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not just text) ·
|
|
869
|
+
inline_static (InlineStatic — a READ-ONLY value matching the Inline* box metrics EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column; non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number column) ·
|
|
870
|
+
list · list_item · menu_button · menu_list_item · detail_row (DetailRow — label+value row for drawer/peek detail; optional `trailing` slot renders a right-side action/badge/unit after the value) · danger_zone (DangerZone — the destructive section: a soft danger-tinted frame (`tint`/`solid`, never raw hex) + a danger heading + a description + a destructive action slot (children, e.g. a `danger` Button); sits APART at the bottom of a record/settings surface) · pressable_row ·
|
|
861
871
|
check_circle (CheckCircle — the completion ring: an empty ring that springs to a filled check when done, distinct from the square checkbox; the task/to-do/checklist toggle. Compose task rows directly, no Task component) · action_menu ·
|
|
862
872
|
floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
|
|
863
873
|
columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
+
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
5
|
import { SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
6
6
|
import { Divider } from "@lotics/ui/divider";
|
|
7
7
|
import { DetailRow } from "@lotics/ui/detail_row";
|
|
8
8
|
import { Badge } from "@lotics/ui/badge";
|
|
9
|
+
import { Button } from "@lotics/ui/button";
|
|
10
|
+
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
11
|
+
import { InlineStatic } from "@lotics/ui/inline_static";
|
|
9
12
|
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
10
13
|
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
11
14
|
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
@@ -13,10 +16,17 @@ import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
|
13
16
|
import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
|
|
14
17
|
|
|
15
18
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
-
// Template · Inline record (plain) —
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
19
|
+
// Template · Inline record (plain) — a full record DRAWER, card-less. It reads
|
|
20
|
+
// top → bottom like a real drawer: a HEADER surfacing the record's identity +
|
|
21
|
+
// status + key facts, then the in-place inline fields, then a DANGER ZONE for
|
|
22
|
+
// the destructive action. Sections are set off by a `SectionHeading` + hairline
|
|
23
|
+
// and generous rhythm, not card chrome — the lighter look for a single
|
|
24
|
+
// full-screen record where banded cards would be visual noise.
|
|
25
|
+
//
|
|
26
|
+
// It's also the reference for the THREE-PART ROW — label · inline editor ·
|
|
27
|
+
// optional `trailing` (an action / badge / unit) — and for read-only alignment:
|
|
28
|
+
// an `InlineStatic` value sits PIXEL-FOR-PIXEL in the same column as the editors
|
|
29
|
+
// (see the "Row anatomy" section).
|
|
20
30
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
31
|
|
|
22
32
|
const PLAN = [
|
|
@@ -37,6 +47,20 @@ const HEALTH_DOT: Record<string, string> = {
|
|
|
37
47
|
churning: colors.red[500],
|
|
38
48
|
};
|
|
39
49
|
|
|
50
|
+
// The same health states as a Badge color family (the header's status chip).
|
|
51
|
+
const HEALTH_BADGE: Record<string, ColorName> = {
|
|
52
|
+
healthy: "emerald",
|
|
53
|
+
at_risk: "amber",
|
|
54
|
+
churning: "red",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const STAGE = [
|
|
58
|
+
{ value: "lead", label: "Lead" },
|
|
59
|
+
{ value: "qualified", label: "Qualified" },
|
|
60
|
+
{ value: "proposal", label: "Proposal" },
|
|
61
|
+
{ value: "won", label: "Won" },
|
|
62
|
+
];
|
|
63
|
+
|
|
40
64
|
function persist<T>(set: (v: T) => void) {
|
|
41
65
|
return (v: T) =>
|
|
42
66
|
new Promise<void>((resolve) => {
|
|
@@ -47,9 +71,9 @@ function persist<T>(set: (v: T) => void) {
|
|
|
47
71
|
});
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
74
|
+
function Field({ label, children, trailing }: { label: string; children: React.ReactNode; trailing?: React.ReactNode }) {
|
|
51
75
|
return (
|
|
52
|
-
<DetailRow label={label} labelWidth={130} minHeight={40}>
|
|
76
|
+
<DetailRow label={label} labelWidth={130} minHeight={40} trailing={trailing}>
|
|
53
77
|
<View style={{ flex: 1 }}>{children}</View>
|
|
54
78
|
</DetailRow>
|
|
55
79
|
);
|
|
@@ -67,6 +91,20 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|
|
67
91
|
);
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
// A compact label-over-value pair for the header's key-facts strip.
|
|
95
|
+
function HeaderStat({ label, value }: { label: string; value: string }) {
|
|
96
|
+
return (
|
|
97
|
+
<View style={{ gap: 2 }}>
|
|
98
|
+
<Text size="xs" color="muted" transform="uppercase">
|
|
99
|
+
{label}
|
|
100
|
+
</Text>
|
|
101
|
+
<Text size="sm" weight="medium" tabular>
|
|
102
|
+
{value}
|
|
103
|
+
</Text>
|
|
104
|
+
</View>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
export function TplRecordPlain() {
|
|
71
109
|
const [name, setName] = useState("Northwind Traders");
|
|
72
110
|
const [contact, setContact] = useState("Mara Lindqvist");
|
|
@@ -79,21 +117,42 @@ export function TplRecordPlain() {
|
|
|
79
117
|
const [renews, setRenews] = useState("2026-05-22");
|
|
80
118
|
const [review, setReview] = useState("2026-04-10T14:30");
|
|
81
119
|
const [opens, setOpens] = useState("09:00");
|
|
120
|
+
// "Row anatomy" fields.
|
|
121
|
+
const [taxId, setTaxId] = useState("SE556677-8899");
|
|
122
|
+
const [stage, setStage] = useState("proposal");
|
|
123
|
+
const [seats, setSeats] = useState<number | null>(25);
|
|
82
124
|
|
|
83
125
|
const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
|
|
126
|
+
const planLabel = PLAN.find((p) => p.value === plan)?.label ?? "—";
|
|
127
|
+
const healthLabel = HEALTH.find((h) => h.value === health)?.label ?? "";
|
|
128
|
+
// Read-only, system-owned values that still align with the editors above.
|
|
129
|
+
const accountId = "ACC-4821";
|
|
130
|
+
const lifetimeValue = 128400;
|
|
84
131
|
|
|
85
132
|
return (
|
|
86
133
|
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
87
134
|
<View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 28 }}>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
{/* Record header — identity + status + the facts you'd want at a glance,
|
|
136
|
+
composed in-place (not worth a primitive until a 2nd screen needs it). */}
|
|
137
|
+
<View style={{ gap: 12 }}>
|
|
138
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
139
|
+
<Text size="xl" weight="semibold">
|
|
140
|
+
{name}
|
|
141
|
+
</Text>
|
|
142
|
+
<Badge label={healthLabel} color={HEALTH_BADGE[health]} />
|
|
143
|
+
<View style={{ flex: 1 }} />
|
|
144
|
+
<Text size="xs" color="muted">
|
|
145
|
+
Click any value to edit
|
|
146
|
+
</Text>
|
|
147
|
+
</View>
|
|
148
|
+
<Text size="sm" color="muted">
|
|
149
|
+
{contact} · {email}
|
|
96
150
|
</Text>
|
|
151
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 28 }}>
|
|
152
|
+
<HeaderStat label="Plan" value={planLabel} />
|
|
153
|
+
<HeaderStat label="Annual value" value={money(value) || "—"} />
|
|
154
|
+
<HeaderStat label="City" value={city || "—"} />
|
|
155
|
+
</View>
|
|
97
156
|
</View>
|
|
98
157
|
|
|
99
158
|
<Section title="Contact">
|
|
@@ -148,6 +207,52 @@ export function TplRecordPlain() {
|
|
|
148
207
|
<InlineTimePicker value={opens} onSave={persist(setOpens)} accessibilityLabel="Support opens" />
|
|
149
208
|
</Field>
|
|
150
209
|
</Section>
|
|
210
|
+
|
|
211
|
+
{/* Row anatomy — the three-part row (label · editor · trailing) and
|
|
212
|
+
read-only alignment, ALL in one 130px-label column so the editors and
|
|
213
|
+
the InlineStatic values line up to the pixel. Copy these shapes. */}
|
|
214
|
+
<Section title="Row anatomy">
|
|
215
|
+
{/* editor + a trailing ACTION */}
|
|
216
|
+
<Field
|
|
217
|
+
label="Tax ID"
|
|
218
|
+
trailing={<Button title="Verify" color="secondary" onPress={() => {}} />}
|
|
219
|
+
>
|
|
220
|
+
<InlineTextInput value={taxId} onSave={persist(setTaxId)} placeholder="Add tax ID…" accessibilityLabel="Tax ID" />
|
|
221
|
+
</Field>
|
|
222
|
+
{/* editor + a trailing STATUS dot */}
|
|
223
|
+
<Field
|
|
224
|
+
label="Pipeline stage"
|
|
225
|
+
trailing={<Badge variant="dot" color="emerald" label="Active" />}
|
|
226
|
+
>
|
|
227
|
+
<InlineSelect value={stage} onSave={persist(setStage)} options={STAGE} accessibilityLabel="Pipeline stage" />
|
|
228
|
+
</Field>
|
|
229
|
+
{/* editor + a trailing UNIT label */}
|
|
230
|
+
<Field
|
|
231
|
+
label="Licensed seats"
|
|
232
|
+
trailing={
|
|
233
|
+
<Text size="sm" color="muted">
|
|
234
|
+
seats
|
|
235
|
+
</Text>
|
|
236
|
+
}
|
|
237
|
+
>
|
|
238
|
+
<InlineNumberInput value={seats} onSave={persist(setSeats)} min={0} accessibilityLabel="Licensed seats" />
|
|
239
|
+
</Field>
|
|
240
|
+
{/* READ-ONLY value — aligns with the editors above, no input chrome */}
|
|
241
|
+
<Field label="Account ID">
|
|
242
|
+
<InlineStatic value={accountId} tabular />
|
|
243
|
+
</Field>
|
|
244
|
+
{/* READ-ONLY value + a trailing MARK */}
|
|
245
|
+
<Field label="Lifetime value" trailing={<Badge label="Computed" color="zinc" />}>
|
|
246
|
+
<InlineStatic value={money(lifetimeValue)} tabular />
|
|
247
|
+
</Field>
|
|
248
|
+
</Section>
|
|
249
|
+
|
|
250
|
+
<DangerZone
|
|
251
|
+
title="Delete account"
|
|
252
|
+
description="Permanently remove this account and all of its contacts, deals, and history. This can't be undone."
|
|
253
|
+
>
|
|
254
|
+
<Button title="Delete account" color="danger" onPress={() => {}} />
|
|
255
|
+
</DangerZone>
|
|
151
256
|
</View>
|
|
152
257
|
</ScrollView>
|
|
153
258
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
"./badge": "./src/badge.tsx",
|
|
123
123
|
"./callout": "./src/callout.tsx",
|
|
124
124
|
"./inline_edit": "./src/inline_edit.tsx",
|
|
125
|
+
"./inline_static": "./src/inline_static.tsx",
|
|
125
126
|
"./inline_text_input": "./src/inline_text_input.tsx",
|
|
126
127
|
"./inline_number_input": "./src/inline_number_input.tsx",
|
|
127
128
|
"./inline_select": "./src/inline_select.tsx",
|
|
@@ -160,6 +161,7 @@
|
|
|
160
161
|
"./table": "./src/table.tsx",
|
|
161
162
|
"./data_grid": "./src/data_grid.tsx",
|
|
162
163
|
"./detail_row": "./src/detail_row.tsx",
|
|
164
|
+
"./danger_zone": "./src/danger_zone.tsx",
|
|
163
165
|
"./scan_field": "./src/scan_field.tsx",
|
|
164
166
|
"./completion_state": "./src/completion_state.tsx",
|
|
165
167
|
"./remainder_meter": "./src/remainder_meter.tsx",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { solid, tint } from "./colors";
|
|
6
|
+
|
|
7
|
+
export interface DangerZoneProps {
|
|
8
|
+
/** Section heading. Default "Danger zone". */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** A short line spelling out the consequence of the action (it's usually
|
|
11
|
+
* irreversible — say so here). */
|
|
12
|
+
description?: string;
|
|
13
|
+
/** The destructive action(s) — typically a `danger` `Button`. */
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A destructive section set visually APART — for the irreversible record actions
|
|
19
|
+
* (delete, archive, transfer ownership) that belong at the BOTTOM of a record or
|
|
20
|
+
* settings surface, away from the everyday fields. A soft danger-tinted frame +
|
|
21
|
+
* a danger heading mark it as a hazard without shouting (the kit's `tint`/`solid`
|
|
22
|
+
* discipline, never a raw hex); the consequence goes in `description`, and the
|
|
23
|
+
* action(s) go in `children`.
|
|
24
|
+
*/
|
|
25
|
+
export function DangerZone(props: DangerZoneProps) {
|
|
26
|
+
const { title = "Danger zone", description, children } = props;
|
|
27
|
+
return (
|
|
28
|
+
<View style={styles.zone}>
|
|
29
|
+
<View style={styles.heading}>
|
|
30
|
+
<Icon name="triangle-alert" size={16} color={solid("red")} />
|
|
31
|
+
<Text size="sm" weight="semibold" color="danger">
|
|
32
|
+
{title}
|
|
33
|
+
</Text>
|
|
34
|
+
</View>
|
|
35
|
+
{description ? (
|
|
36
|
+
<Text size="sm" color="muted">
|
|
37
|
+
{description}
|
|
38
|
+
</Text>
|
|
39
|
+
) : null}
|
|
40
|
+
<View style={styles.actions}>{children}</View>
|
|
41
|
+
</View>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const styles = StyleSheet.create({
|
|
46
|
+
// A soft danger frame: a low-alpha red hairline over the faintest red wash —
|
|
47
|
+
// the "set apart, not shouting" treatment via the kit's tint discipline.
|
|
48
|
+
zone: {
|
|
49
|
+
gap: 8,
|
|
50
|
+
padding: 16,
|
|
51
|
+
borderRadius: 10,
|
|
52
|
+
borderWidth: 1,
|
|
53
|
+
borderColor: tint("red", 0.3),
|
|
54
|
+
backgroundColor: tint("red", 0.03),
|
|
55
|
+
},
|
|
56
|
+
heading: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
57
|
+
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
|
|
58
|
+
});
|
package/src/detail_row.tsx
CHANGED
|
@@ -7,6 +7,12 @@ export interface DetailRowProps {
|
|
|
7
7
|
label: ReactNode;
|
|
8
8
|
/** The value — any node (a `Text`, a `Badge`, a stack). */
|
|
9
9
|
children: ReactNode;
|
|
10
|
+
/** An optional right-side slot, rendered as the LAST child of the row (after
|
|
11
|
+
* `children`) — a small action `Button`, a status `Badge`, a unit label, an
|
|
12
|
+
* `IconButton`. For it to sit at the right edge, wrap the value/editor in a
|
|
13
|
+
* `flex:1` View so the value takes the slack and the trailing pins right (the
|
|
14
|
+
* inline-record templates do this). */
|
|
15
|
+
trailing?: ReactNode;
|
|
10
16
|
/** Fixed label-column width (px) to align labels in a left column — for a peek
|
|
11
17
|
* or form-like stack. Omit for a spread row: the label takes the slack and the
|
|
12
18
|
* value sits at the right edge (the default drawer-detail look). */
|
|
@@ -24,13 +30,14 @@ export interface DetailRowProps {
|
|
|
24
30
|
* with the values flowing immediately after instead.
|
|
25
31
|
*/
|
|
26
32
|
export function DetailRow(props: DetailRowProps) {
|
|
27
|
-
const { label, children, labelWidth, labelSize = "sm", minHeight = 28 } = props;
|
|
33
|
+
const { label, children, trailing, labelWidth, labelSize = "sm", minHeight = 28 } = props;
|
|
28
34
|
return (
|
|
29
35
|
<View style={[styles.row, { minHeight }]}>
|
|
30
36
|
<Text size={labelSize} color="muted" style={labelWidth != null ? { width: labelWidth } : styles.flexLabel}>
|
|
31
37
|
{label}
|
|
32
38
|
</Text>
|
|
33
39
|
{children}
|
|
40
|
+
{trailing != null ? trailing : null}
|
|
34
41
|
</View>
|
|
35
42
|
);
|
|
36
43
|
}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
|
2
|
-
import { View, StyleSheet, type GestureResponderEvent } from "react-native";
|
|
2
|
+
import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
5
|
import { ActivityIndicator } from "./activity_indicator";
|
|
@@ -232,9 +232,19 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
232
232
|
);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
// The
|
|
236
|
-
//
|
|
237
|
-
|
|
235
|
+
// The canonical text treatment for an inline value AT REST — the input's own
|
|
236
|
+
// metrics (font, size, line height, letter spacing) so the value reads
|
|
237
|
+
// identically and occupies the same box in view and edit mode. A read-only
|
|
238
|
+
// `InlineStatic` reuses this so its baseline lines up with the editors.
|
|
239
|
+
export const inlineValueTextStyle: TextStyle = {
|
|
240
|
+
...getInputTextStyle(),
|
|
241
|
+
fontFamily: fontFamilyRegular,
|
|
242
|
+
letterSpacing: -0.4,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// In the view ROW the value text takes the flex slot so the trailing adornment
|
|
246
|
+
// right-aligns; `InlineStatic` uses the base style above without this flex.
|
|
247
|
+
const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
|
|
238
248
|
|
|
239
249
|
const styles = StyleSheet.create({
|
|
240
250
|
view: {
|
package/src/inline_select.tsx
CHANGED
|
@@ -17,8 +17,9 @@ export interface InlineSelectProps<T extends string> {
|
|
|
17
17
|
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
18
18
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
19
19
|
/** Render the SELECTED value in the resting view as a node (an avatar chip, a
|
|
20
|
-
* colored badge) instead of its plain label. Falls back to
|
|
21
|
-
*
|
|
20
|
+
* colored badge) instead of its plain label. Falls back to `renderOptionContent`
|
|
21
|
+
* (so the trigger renders like the options), then the plain label, when omitted;
|
|
22
|
+
* the empty state always shows the placeholder. */
|
|
22
23
|
renderSelected?: (selected: PickerOption<T>) => React.ReactNode;
|
|
23
24
|
placeholder?: string;
|
|
24
25
|
disabled?: boolean;
|
|
@@ -28,11 +29,11 @@ export interface InlineSelectProps<T extends string> {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* An inline-editable single-select. The value
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* An inline-editable single-select. The selected value renders like its option
|
|
33
|
+
* (`renderOptionContent`) — a badge stays a badge in the resting trigger, not just
|
|
34
|
+
* text; clicking it floats the option list (an `OptionList`) in a popover anchored
|
|
35
|
+
* to the view, so the row never changes height. Picking commits; dismissing reverts.
|
|
36
|
+
* Pass `renderOptionContent` for rich options, or omit it for a plain label list.
|
|
36
37
|
*/
|
|
37
38
|
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
38
39
|
const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false } = props;
|
|
@@ -64,7 +65,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
64
65
|
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
65
66
|
<PopoverTrigger>
|
|
66
67
|
<InlineEditView
|
|
67
|
-
display={selected ? (renderSelected ? renderSelected(selected) : (selected.label ?? "")) : ""}
|
|
68
|
+
display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
|
|
68
69
|
placeholder={placeholder}
|
|
69
70
|
disabled={disabled}
|
|
70
71
|
active={open && !disabled}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { CONTROL_RADIUS } from "./control_surface";
|
|
5
|
+
import { INLINE_CONTROL_HEIGHT, inlineValueTextStyle } from "./inline_edit";
|
|
6
|
+
|
|
7
|
+
export interface InlineStaticProps {
|
|
8
|
+
/** The value to show. Empty → `placeholder` (default "—"). */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Shown when `value` is empty. Default "—". */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** Mute the value (zinc-600) — a secondary or derived figure. */
|
|
13
|
+
muted?: boolean;
|
|
14
|
+
/** Fixed-width (tabular) numerals — for a value sitting in a NUMBER column so
|
|
15
|
+
* its digits line up with the `InlineNumberInput`s above and below it. */
|
|
16
|
+
tabular?: boolean;
|
|
17
|
+
/** Horizontal alignment — `right` for a number column. Default `left`. */
|
|
18
|
+
align?: "left" | "right";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A READ-ONLY value that aligns PIXEL-FOR-PIXEL with the `Inline*` editors in a
|
|
23
|
+
* record: the same control height, horizontal padding, and a 1px TRANSPARENT
|
|
24
|
+
* border, so a non-editable field — a computed total, a system ID, a synced or
|
|
25
|
+
* locked value — sits flush in the same column as the editable rows. It is
|
|
26
|
+
* NON-interactive (no hover, no pointer, no focus ring): it reads as a plain
|
|
27
|
+
* value, NOT a disabled input. Pair with `DetailRow` exactly like the `Inline*`
|
|
28
|
+
* editors; wrap it in a `flex:1` View when the row also carries a `trailing`
|
|
29
|
+
* slot, so the value takes the slack and the trailing pins right.
|
|
30
|
+
*/
|
|
31
|
+
export function InlineStatic(props: InlineStaticProps) {
|
|
32
|
+
const { value, placeholder, muted, tabular, align = "left" } = props;
|
|
33
|
+
const isEmpty = value.length === 0;
|
|
34
|
+
const display = isEmpty ? (placeholder ?? "—") : value;
|
|
35
|
+
return (
|
|
36
|
+
<View style={styles.box}>
|
|
37
|
+
<Text
|
|
38
|
+
numberOfLines={1}
|
|
39
|
+
tabular={tabular}
|
|
40
|
+
align={align}
|
|
41
|
+
color={muted ? "muted" : "default"}
|
|
42
|
+
style={[inlineValueTextStyle, isEmpty ? styles.placeholder : null]}
|
|
43
|
+
>
|
|
44
|
+
{display}
|
|
45
|
+
</Text>
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
// Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
|
|
52
|
+
// transparent border) so the value's baseline aligns with the editors — minus
|
|
53
|
+
// the FocusRingPressable, so it never reads as an interactive control.
|
|
54
|
+
box: {
|
|
55
|
+
minHeight: INLINE_CONTROL_HEIGHT,
|
|
56
|
+
borderRadius: CONTROL_RADIUS,
|
|
57
|
+
borderWidth: 1,
|
|
58
|
+
borderColor: "transparent",
|
|
59
|
+
paddingHorizontal: 8,
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
},
|
|
62
|
+
placeholder: { color: colors.zinc[400] },
|
|
63
|
+
});
|
package/examples/tpl_record.tsx
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors, solid, type ColorName } from "@lotics/ui/colors";
|
|
5
|
-
import { Card, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
6
|
-
import { DetailRow } from "@lotics/ui/detail_row";
|
|
7
|
-
import { Badge } from "@lotics/ui/badge";
|
|
8
|
-
import { Button } from "@lotics/ui/button";
|
|
9
|
-
import { Popover, PopoverContent, PopoverTrigger } from "@lotics/ui/popover";
|
|
10
|
-
import { MenuButton } from "@lotics/ui/menu_button";
|
|
11
|
-
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
12
|
-
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
13
|
-
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
14
|
-
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
15
|
-
import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
|
|
16
|
-
import { Select } from "@lotics/ui/select";
|
|
17
|
-
import { Chip } from "@lotics/ui/chip";
|
|
18
|
-
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
19
|
-
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
20
|
-
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
21
|
-
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
22
|
-
|
|
23
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
-
// Template · Inline record — a record edited entirely IN PLACE. Every value
|
|
25
|
-
// reads as plain text; hovering tints it, clicking swaps the field in at the
|
|
26
|
-
// same height (no reflow), and it saves on its own. The data-capture
|
|
27
|
-
// alternative to a form: no screen-wide edit mode, no one big Save — each field
|
|
28
|
-
// is its own atomic edit. Banded cards over zinc-50, label-left / value-right.
|
|
29
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
const PLAN = [
|
|
32
|
-
{ value: "starter", label: "Starter" },
|
|
33
|
-
{ value: "growth", label: "Growth" },
|
|
34
|
-
{ value: "enterprise", label: "Enterprise" },
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const HEALTH = [
|
|
38
|
-
{ value: "healthy", label: "Healthy" },
|
|
39
|
-
{ value: "at_risk", label: "At risk" },
|
|
40
|
-
{ value: "churning", label: "Churning" },
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
const HEALTH_DOT: Record<string, string> = {
|
|
44
|
-
healthy: colors.emerald[500],
|
|
45
|
-
at_risk: colors.amber[500],
|
|
46
|
-
churning: colors.red[500],
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// Tags — an inline MULTI-select: chips render in the field, search to add, ✕ to
|
|
50
|
-
// remove, free-entry to coin a new tag (`allowCustom`).
|
|
51
|
-
const TAG_OPTIONS = [
|
|
52
|
-
{ value: "vip", label: "VIP" },
|
|
53
|
-
{ value: "priority", label: "Priority support" },
|
|
54
|
-
{ value: "renewal_risk", label: "Renewal risk" },
|
|
55
|
-
{ value: "expansion", label: "Expansion" },
|
|
56
|
-
{ value: "reference", label: "Referenceable" },
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
// Seed the attachment grid with self-contained SVG data-URI "scans" so the
|
|
60
|
-
// thumbnails AND the preview modal work offline; dropped files are real blobs.
|
|
61
|
-
const scan = (id: string, label: string, bg: string): DisplayFile => ({
|
|
62
|
-
id,
|
|
63
|
-
filename: `${label.toLowerCase().replaceAll(" ", "-")}.svg`,
|
|
64
|
-
mimeType: "image/svg+xml",
|
|
65
|
-
url:
|
|
66
|
-
"data:image/svg+xml," +
|
|
67
|
-
encodeURIComponent(
|
|
68
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="420" height="560"><rect width="100%" height="100%" fill="${bg}"/><text x="210" y="290" fill="#ffffff" font-family="sans-serif" font-size="30" text-anchor="middle">${label}</text></svg>`,
|
|
69
|
-
),
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const SEED_DOCS: DisplayFile[] = [
|
|
73
|
-
scan("doc_1", "Master agreement", "#1e3a8a"),
|
|
74
|
-
scan("doc_2", "Tax certificate", "#065f46"),
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
// Simulate an async persist so the saving state is visible.
|
|
78
|
-
function persist<T>(set: (v: T) => void) {
|
|
79
|
-
return (v: T) =>
|
|
80
|
-
new Promise<void>((resolve) => {
|
|
81
|
-
setTimeout(() => {
|
|
82
|
-
set(v);
|
|
83
|
-
resolve();
|
|
84
|
-
}, 350);
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
89
|
-
return (
|
|
90
|
-
<DetailRow label={label} labelWidth={130} minHeight={40}>
|
|
91
|
-
<View style={{ flex: 1 }}>{children}</View>
|
|
92
|
-
</DetailRow>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function Group({ title, children }: { title: string; children: React.ReactNode }) {
|
|
97
|
-
return (
|
|
98
|
-
<Card style={{ padding: 0 }}>
|
|
99
|
-
<CardHeader>
|
|
100
|
-
<CardHeaderTitle>{title}</CardHeaderTitle>
|
|
101
|
-
</CardHeader>
|
|
102
|
-
<View style={{ paddingHorizontal: 16, paddingVertical: 8, gap: 2 }}>{children}</View>
|
|
103
|
-
</Card>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── Lifecycle disposition (NOT a plain select). "Lead" is the OPEN default;
|
|
108
|
-
// "Closed"/"Lost" are terminal OUTCOMES the user decides. The control is
|
|
109
|
-
// asymmetric by phase: while open it surfaces the two outcomes as ACTIONS;
|
|
110
|
-
// once resolved it shows the colored STATE with a quiet, reversible Change
|
|
111
|
-
// (reopen to Lead, or switch to the other outcome — valence lives on the badge,
|
|
112
|
-
// not the buttons). States/colors/labels vary per domain, so it's a composition
|
|
113
|
-
// (Badge + Button + Popover/MenuButton), not a shipped primitive.
|
|
114
|
-
type Stage = "lead" | "closed" | "lost";
|
|
115
|
-
|
|
116
|
-
const STAGE_META: Record<Stage, { label: string; color: ColorName }> = {
|
|
117
|
-
lead: { label: "Lead", color: "blue" },
|
|
118
|
-
closed: { label: "Closed", color: "emerald" },
|
|
119
|
-
lost: { label: "Lost", color: "red" },
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
function StageField({ value, onChange }: { value: Stage; onChange: (next: Stage) => void }) {
|
|
123
|
-
const [open, setOpen] = useState(false);
|
|
124
|
-
const meta = STAGE_META[value];
|
|
125
|
-
|
|
126
|
-
// Pending: the decision is the action.
|
|
127
|
-
if (value === "lead") {
|
|
128
|
-
return (
|
|
129
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
130
|
-
<Badge variant="dot" label={meta.label} color={meta.color} />
|
|
131
|
-
<View style={{ flex: 1 }} />
|
|
132
|
-
<Button title="Mark closed" color="primary" onPress={() => onChange("closed")} />
|
|
133
|
-
<Button title="Mark lost" color="danger-secondary" onPress={() => onChange("lost")} />
|
|
134
|
-
</View>
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Resolved: the badge is the state; Change opens a quiet, colored state
|
|
139
|
-
// switcher (each option a dot + label, current marked) — a status control,
|
|
140
|
-
// not a generic text menu.
|
|
141
|
-
return (
|
|
142
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
143
|
-
<Badge variant="dot" label={meta.label} color={meta.color} />
|
|
144
|
-
<View style={{ flex: 1 }} />
|
|
145
|
-
<Popover open={open} onOpenChange={setOpen} side="bottom" align="end">
|
|
146
|
-
<PopoverTrigger>
|
|
147
|
-
<Button title="Change" color="muted" onPress={() => setOpen(true)} />
|
|
148
|
-
</PopoverTrigger>
|
|
149
|
-
<PopoverContent>
|
|
150
|
-
<View style={{ gap: 2, padding: 4, minWidth: 200 }}>
|
|
151
|
-
{(["lead", "closed", "lost"] as Stage[]).map((s) => (
|
|
152
|
-
<MenuButton
|
|
153
|
-
key={s}
|
|
154
|
-
icon={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: solid(STAGE_META[s].color) }} />}
|
|
155
|
-
title={s === value ? `${STAGE_META[s].label} · current` : STAGE_META[s].label}
|
|
156
|
-
selected={s === value}
|
|
157
|
-
disabled={s === value}
|
|
158
|
-
onPress={() => { onChange(s); setOpen(false); }}
|
|
159
|
-
/>
|
|
160
|
-
))}
|
|
161
|
-
</View>
|
|
162
|
-
</PopoverContent>
|
|
163
|
-
</Popover>
|
|
164
|
-
</View>
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function TplRecord() {
|
|
169
|
-
const [name, setName] = useState("Northwind Traders");
|
|
170
|
-
const [contact, setContact] = useState("Mara Lindqvist");
|
|
171
|
-
const [email, setEmail] = useState("mara@northwind.co");
|
|
172
|
-
const [phone, setPhone] = useState("");
|
|
173
|
-
const [city, setCity] = useState("Gothenburg");
|
|
174
|
-
const [value, setValue] = useState<number | null>(48000);
|
|
175
|
-
const [plan, setPlan] = useState("growth");
|
|
176
|
-
const [health, setHealth] = useState("healthy");
|
|
177
|
-
const [renews, setRenews] = useState("2026-05-22");
|
|
178
|
-
const [review, setReview] = useState("2026-04-10T14:30");
|
|
179
|
-
const [opens, setOpens] = useState("09:00");
|
|
180
|
-
const [stage, setStage] = useState<Stage>("lead");
|
|
181
|
-
const [tags, setTags] = useState<string[]>([TAG_OPTIONS[0].value, TAG_OPTIONS[3].value]);
|
|
182
|
-
const [docs, setDocs] = useState<DisplayFile[]>(SEED_DOCS);
|
|
183
|
-
const [activeDoc, setActiveDoc] = useState<number | null>(null);
|
|
184
|
-
|
|
185
|
-
const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
|
|
186
|
-
|
|
187
|
-
return (
|
|
188
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
189
|
-
<View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 16 }}>
|
|
190
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
191
|
-
<Text size="xl" weight="semibold">
|
|
192
|
-
{name}
|
|
193
|
-
</Text>
|
|
194
|
-
<Badge label="Customer" color="blue" />
|
|
195
|
-
<View style={{ flex: 1 }} />
|
|
196
|
-
<Text size="xs" color="muted">
|
|
197
|
-
Click any value to edit
|
|
198
|
-
</Text>
|
|
199
|
-
</View>
|
|
200
|
-
|
|
201
|
-
<Group title="Contact">
|
|
202
|
-
<Field label="Account name">
|
|
203
|
-
<InlineTextInput value={name} onSave={persist(setName)} accessibilityLabel="Account name" />
|
|
204
|
-
</Field>
|
|
205
|
-
<Field label="Primary contact">
|
|
206
|
-
<InlineTextInput value={contact} onSave={persist(setContact)} accessibilityLabel="Primary contact" />
|
|
207
|
-
</Field>
|
|
208
|
-
<Field label="Email">
|
|
209
|
-
<InlineTextInput value={email} onSave={persist(setEmail)} placeholder="Add email…" accessibilityLabel="Email" />
|
|
210
|
-
</Field>
|
|
211
|
-
<Field label="Phone">
|
|
212
|
-
<InlineTextInput value={phone} onSave={persist(setPhone)} placeholder="Add phone…" accessibilityLabel="Phone" />
|
|
213
|
-
</Field>
|
|
214
|
-
<Field label="City">
|
|
215
|
-
<InlineTextInput value={city} onSave={persist(setCity)} accessibilityLabel="City" />
|
|
216
|
-
</Field>
|
|
217
|
-
</Group>
|
|
218
|
-
|
|
219
|
-
<Group title="Commercial">
|
|
220
|
-
<Field label="Stage">
|
|
221
|
-
<StageField value={stage} onChange={setStage} />
|
|
222
|
-
</Field>
|
|
223
|
-
<Field label="Annual value">
|
|
224
|
-
<InlineNumberInput value={value} onSave={persist(setValue)} format={money} accessibilityLabel="Annual value" />
|
|
225
|
-
</Field>
|
|
226
|
-
<Field label="Plan">
|
|
227
|
-
<InlineSelect value={plan} onSave={persist(setPlan)} options={PLAN} accessibilityLabel="Plan" />
|
|
228
|
-
</Field>
|
|
229
|
-
<Field label="Health">
|
|
230
|
-
<InlineSelect
|
|
231
|
-
value={health}
|
|
232
|
-
onSave={persist(setHealth)}
|
|
233
|
-
options={HEALTH}
|
|
234
|
-
accessibilityLabel="Health"
|
|
235
|
-
renderOptionContent={(o) => (
|
|
236
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
237
|
-
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: HEALTH_DOT[o.value] }} />
|
|
238
|
-
<Text size="sm">{o.label}</Text>
|
|
239
|
-
</View>
|
|
240
|
-
)}
|
|
241
|
-
/>
|
|
242
|
-
</Field>
|
|
243
|
-
<Field label="Tags">
|
|
244
|
-
<Select multi searchable allowCustom value={tags} onValueChange={setTags} options={TAG_OPTIONS} renderSelected={(item, { remove }) => <Chip onDismiss={remove}><Text size="sm">{item.label}</Text></Chip>} placeholder="Add tags…" accessibilityLabel="Tags" />
|
|
245
|
-
</Field>
|
|
246
|
-
</Group>
|
|
247
|
-
|
|
248
|
-
<Group title="Schedule">
|
|
249
|
-
<Field label="Renews on">
|
|
250
|
-
<InlineDatePicker value={renews} onSave={persist(setRenews)} locale="en-US" accessibilityLabel="Renews on" />
|
|
251
|
-
</Field>
|
|
252
|
-
<Field label="Next review">
|
|
253
|
-
<InlineDatePicker value={review} onSave={persist(setReview)} format="datetime" locale="en-US" accessibilityLabel="Next review" />
|
|
254
|
-
</Field>
|
|
255
|
-
<Field label="Support opens">
|
|
256
|
-
<InlineTimePicker value={opens} onSave={persist(setOpens)} accessibilityLabel="Support opens" />
|
|
257
|
-
</Field>
|
|
258
|
-
</Group>
|
|
259
|
-
|
|
260
|
-
{/* Documents — a richer in-place field: drop to add, click a thumbnail to
|
|
261
|
-
preview in the gallery, ✕ to delete. Not every inline field is a
|
|
262
|
-
same-height swap; an attachment grid edits in place too. */}
|
|
263
|
-
<Card style={{ padding: 0 }}>
|
|
264
|
-
<CardHeader>
|
|
265
|
-
<CardHeaderTitle>Documents</CardHeaderTitle>
|
|
266
|
-
</CardHeader>
|
|
267
|
-
<View style={{ paddingHorizontal: 16, paddingVertical: 12, gap: 12 }}>
|
|
268
|
-
{docs.length > 0 ? (
|
|
269
|
-
<FileThumbnailGrid
|
|
270
|
-
files={docs}
|
|
271
|
-
itemSize={84}
|
|
272
|
-
onFilePress={(f) => setActiveDoc(docs.findIndex((x) => x.id === f.id))}
|
|
273
|
-
onRemove={(id) => setDocs((prev) => prev.filter((x) => x.id !== id))}
|
|
274
|
-
/>
|
|
275
|
-
) : null}
|
|
276
|
-
<FileDropzone
|
|
277
|
-
height={96}
|
|
278
|
-
label="Drop contracts or scans"
|
|
279
|
-
hint="or click to browse · PDF, images"
|
|
280
|
-
dropLabel="Release to attach"
|
|
281
|
-
accept="application/pdf,image/*"
|
|
282
|
-
accessibilityLabel="Attach documents"
|
|
283
|
-
onFiles={(dropped) =>
|
|
284
|
-
setDocs((prev) => [
|
|
285
|
-
...prev,
|
|
286
|
-
...dropped.map((f, i) => ({
|
|
287
|
-
id: `${f.name}-${prev.length + i}`,
|
|
288
|
-
filename: f.name,
|
|
289
|
-
mimeType: f.type || "application/octet-stream",
|
|
290
|
-
url: URL.createObjectURL(f),
|
|
291
|
-
})),
|
|
292
|
-
])
|
|
293
|
-
}
|
|
294
|
-
/>
|
|
295
|
-
</View>
|
|
296
|
-
</Card>
|
|
297
|
-
</View>
|
|
298
|
-
|
|
299
|
-
<FileGalleryModal files={docs} activeIndex={activeDoc} onIndexChange={setActiveDoc} />
|
|
300
|
-
</ScrollView>
|
|
301
|
-
);
|
|
302
|
-
}
|