@lotics/ui 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -25,10 +25,24 @@ Pick by capability, not by name. (→ the source file for the API.)
25
25
  - **Actions** — `Button` (labelled; `color` = emphasis/risk, `shape` pill|rounded),
26
26
  `IconButton` (icon-only, needs `accessibilityLabel`/`tooltip`), `LinkButton` (quiet ghost
27
27
  action — Clear / Select all), `PillButton` (dismissible facet chip). A button is never a raw
28
- `Pressable`.
28
+ `Pressable`. For a link OUT (a URL / record / document) use `Link` — underline+blue, the
29
+ destination signal; the opposite of `LinkButton` (a quiet in-app action, never underlined).
29
30
  - **Pick from a list** — `Picker` (known options, native typeahead, single/multi/custom-render,
30
31
  no search box). Search-as-you-type / async / create-new → `Combobox`. A selectable card row →
31
- `CardSelectItem`.
32
+ `CardSelectItem`. For a SELECT-FIELD picker, render each option as its colored chip
33
+ (`renderOptionContent={(o) => <OptionBadge value={o} />}`).
34
+ - **Pick member(s)** — `MemberSelect` (a `Picker` that renders each option as a `MemberChip`,
35
+ single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
36
+ Don't re-wire `Picker` + `renderOptionContent` + a directory by hand.
37
+ - **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
38
+ ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
39
+ resolve the member from your directory (`useMembers`) and pass `name` / `image`; never hand-roll
40
+ `Avatar` + `Text`. (The product's `MemberBadge` is just `memberId → MemberChip`; `MemberSelect`
41
+ renders these per option.)
42
+ - **A select-field value** — `OptionBadge` (a stored `select` value as its CONFIGURED colored
43
+ badge) — never hand-map option-key → color. Feed it a resolved option (`useFieldOptions` for a
44
+ picker option, or `byKey(readSelect(cell)[0]?.key)` for a stored value); multi-select wraps to
45
+ one badge each; a missing/unknown color token degrades to neutral.
32
46
  - **Tags / multi-select chip box** — `TagInput` (chips + an Add-popover checklist + create), NOT
33
47
  `Combobox multi` (see §Data entry).
34
48
  - **Text & form** — `TextInputField`, `NumberInput`, `SearchInput`; wrap with `FormField`;
@@ -72,7 +86,9 @@ This is the most common thing to get right. Match the JOB to the pattern:
72
86
  | a brand-NEW record (a form) | **fieldset form** (`FormField` + the 2-col grid) | structured entry + a Save |
73
87
  | a RELATED record (pick or make) | **find-or-create** (`Combobox allowCustom`) | one control covers both |
74
88
  | REPEATING rows you build & revise | **line items** (create→preview→edit) | add / edit / remove, live totals |
89
+ | CHARGES that bill onto documents | **billing** (`tpl_billing`) | the invoice document is the unit |
75
90
  | a multi-value TAG field | **`TagInput`** | a chip box, not a search input |
91
+ | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker`) | required single-select, one tap, every option visible |
76
92
  | a STATUS with terminal outcomes | **disposition** (open → resolve → revise) | guides the decision |
77
93
  | FILES | **attachment field** (dropzone + grid + gallery) | add / preview / delete |
78
94
  | many entries FAST | **quick capture** (one row, Enter to add) | repeat-entry speed |
@@ -122,6 +138,20 @@ snapshot the item on Edit so Cancel reverts, or DISCARDS a freshly-added one. Du
122
138
  the destructive **Delete is `danger-secondary`**; Cancel + the Save/Edit toggle sit right. Every
123
139
  screen composes its own (~15 lines) so the preview rows + form fit the data.
124
140
 
141
+ ### Billing & invoicing — the invoice DOCUMENT is the unit (`tpl_billing`)
142
+ When charges get grouped into issuable documents (an e-invoice, a bill) and then collected, don't
143
+ split the screen into "enter fees here, issue there" — that smears one job across two places. Make
144
+ **each invoice a `Card` that holds its own editable charge lines** (amount input + payment method),
145
+ its **live total**, its **status badge** (nothing-to-bill · draft · issued + ref), and its **issue
146
+ action** in the footer. A charge never lives apart from the document it bills on. Issuing is gated
147
+ **inline, never a dead end**: when a prerequisite is missing (a bill-to tax ID, a method on a
148
+ charged line) the issue button disables with one muted line saying what's needed; the payment-method
149
+ picker turns required the instant a line carries an amount. Issuing a real e-invoice is irreversible
150
+ → confirm in a `Dialog` (stage gate). A grand-total **receipt** validates first — surface the EXACT
151
+ missing methods (`Alert.alert` listing each) rather than a vague "incomplete." A refundable
152
+ **deposit** is its own card and its own receipt — never folded into the total due. Composition over
153
+ `Card` + `NumberInput` + `Picker` + `Badge`; no new primitive.
154
+
125
155
  ### Tag field — `TagInput`, NOT `Combobox multi`
126
156
  A tag field's resting state should be a tidy CHIP BOX: `TagInput` is a bordered box of removable
127
157
  chips + an Add affordance; searching/creating happens in a POPOVER (a searchable, checkable list +
@@ -191,6 +221,17 @@ the "this is a gate" framing.
191
221
  weight="semibold"` + an optional `xs muted` subtitle.
192
222
  - **Time-constrained data gets a period filter** in the header band — `DateRangeFilterField`, never
193
223
  a static period badge. Every period-dependent number MUST follow the selection.
224
+ - **Keyboard & focus — use `tabIndex`, never `focusable`.** RN-Web's `Pressable` silently ignores
225
+ `focusable` (it writes its own `tabIndex`), so set a pressable control's tab-stop status with
226
+ `tabIndex={0 | -1}` (`focusable` only works on a plain `View`/`TextInput`). Roving widgets
227
+ (Tabs/SegmentedControl/RadioPicker) keep ONE stop at `0`, the rest `-1`. And NEVER let a FOCUSED
228
+ control unmount — a conditional pointer affordance that vanishes on use (e.g. an "apply suggested
229
+ value" pill shown only while a field is empty) must be `tabIndex={-1}`, or the browser drops focus
230
+ to `<body>` and the next Tab jumps to the page's first focusable. A mouse-opened popover trigger
231
+ (Picker/Combobox/InlineSelect/InlineDatePicker) gets no `:focus-visible` ring, so it wears
232
+ **`ACTIVE_RING`** (`control_surface.ts`, a `0 0 0 2px zinc-900` box-shadow mirroring the focus
233
+ outline) on its open state — reuse that token, don't hand-roll a thin 1px edge. Full rules + the
234
+ keyboard test: `docs/accessibility.md` → Focus & tab order.
194
235
  - **Cards are banded — and composable** (all from `@lotics/ui/card`):
195
236
  ```tsx
196
237
  <Card style={{ padding: 0 }}>
@@ -290,7 +331,9 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
290
331
  `tpl_dispatch` (capacity allocation) · `tpl_batch` (compose from parts) · `tpl_pick` (guided
291
332
  `ScanField` run) · `tpl_allocate` (`RemainderMeter` split) · `tpl_run` (preview → resolve → post).
292
333
  - **Data capture** — `tpl_intake` (multi-fieldset form + attachments) · `tpl_order` (the
293
- transactional form: find-or-create + line items + attachment CRUD + 2-col grid) · `tpl_quick`
334
+ transactional form: find-or-create + line items + attachment CRUD + 2-col grid) · `tpl_billing`
335
+ (charges→invoice→collect: the invoice DOCUMENT is the unit — editable charge lines + method,
336
+ live total, status, inline-gated issue; validated receipt; separate deposit) · `tpl_quick`
294
337
  (quick-capture log) · `tpl_wizard` (`Stepper` form + review) · `tpl_record` (inline edit + Stage
295
338
  disposition + `TagInput` + Documents grid; `tpl_record_plain` = card-less).
296
339
  - **Records & lookup** — `tpl_detail` (full record + tabs) · `tpl_directory` (searchable register) ·
@@ -304,7 +347,10 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
304
347
 
305
348
  text · card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
306
349
  section_heading (SectionHeading · SectionHeadingTitle — compound, owns no margin) · badge ·
307
- status_badge · button · icon_button · link_button · pill_button · tabs · segmented_control ·
350
+ option_badge (OptionBadge a select value as its configured colored badge) ·
351
+ member_chip (MemberChip — avatar + name; the universal person render) ·
352
+ member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
353
+ status_badge · button · icon_button · link · link_button · pill_button · tabs · segmented_control ·
308
354
  picker · combobox · tag_input (TagInput — chip box + Add-popover; for tags, not Combobox multi) ·
309
355
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
310
356
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
@@ -320,4 +366,5 @@ status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_m
320
366
  scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_preview ·
321
367
  file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
322
368
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
323
- comments_thread · format_money · format_date · colors (solid · tint · ramp · ColorName).
369
+ comments_thread · format_money · format_date · colors (solid · tint · ramp · ColorName ·
370
+ isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback).
@@ -0,0 +1,344 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { Card, CardBody, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
7
+ import { Badge } from "@lotics/ui/badge";
8
+ import { Link } from "@lotics/ui/link";
9
+ import { Icon } from "@lotics/ui/icon";
10
+ import { Divider } from "@lotics/ui/divider";
11
+ import { NumberInput } from "@lotics/ui/number_input";
12
+ import { Picker } from "@lotics/ui/picker";
13
+ import type { PickerOption } from "@lotics/ui/picker";
14
+ import { Callout, CalloutText } from "@lotics/ui/callout";
15
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
16
+ import { Alert } from "@lotics/ui/alert";
17
+ import { formatMoney } from "@lotics/ui/format_money";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Template · Billing & invoicing — the charges→invoice→collect flow as ONE
21
+ // banded card, not a stack of disconnected cards. It's a single document, so it
22
+ // reads as one: the bill-to, each invoice, the collect total, and the deposit
23
+ // are BANDS separated by hairline Dividers. The INVOICE DOCUMENT is the unit —
24
+ // each band is anchored by its own title + status badge and owns its editable
25
+ // charge lines (amount + how it's paid), its live total, and its issue action,
26
+ // so a charge never lives apart from the document it bills on. Issuing a real
27
+ // e-invoice is irreversible and needs a bill-to tax ID, so it's gated inline
28
+ // (never a dead end) and confirmed in a Dialog. A refundable DEPOSIT is its own
29
+ // (tinted) band — collected separately, never folded into the total due.
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ type Method = "cash" | "transfer" | "card";
33
+ const METHODS: PickerOption<Method>[] = [
34
+ { value: "cash", label: "Cash" },
35
+ { value: "transfer", label: "Bank transfer" },
36
+ { value: "card", label: "Card" },
37
+ ];
38
+
39
+ interface Charge {
40
+ key: string;
41
+ label: string;
42
+ /** The expected list price — offered as a one-tap fill while the line is unset. */
43
+ standard: number;
44
+ amount: number;
45
+ method: Method | "";
46
+ }
47
+
48
+ interface Invoice {
49
+ key: string;
50
+ title: string;
51
+ charges: Charge[];
52
+ /** Lookup code once issued to the e-invoice provider; "" = not issued. */
53
+ ref: string;
54
+ }
55
+
56
+ const INITIAL: Invoice[] = [
57
+ {
58
+ key: "service",
59
+ title: "Service",
60
+ ref: "",
61
+ charges: [
62
+ { key: "labor", label: "Labor", standard: 1_200_000, amount: 1_200_000, method: "cash" },
63
+ { key: "parts", label: "Parts", standard: 0, amount: 0, method: "" },
64
+ ],
65
+ },
66
+ {
67
+ key: "inspection",
68
+ title: "Inspection",
69
+ ref: "",
70
+ charges: [{ key: "insp", label: "Inspection fee", standard: 150_000, amount: 150_000, method: "" }],
71
+ },
72
+ {
73
+ key: "disposal",
74
+ title: "Disposal",
75
+ ref: "INV-2026-0414",
76
+ charges: [{ key: "disp", label: "Disposal fee", standard: 80_000, amount: 80_000, method: "transfer" }],
77
+ },
78
+ ];
79
+
80
+ type Status = "none" | "draft" | "issued";
81
+ const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
82
+ const invoiceStatus = (inv: Invoice): Status => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
83
+ const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
84
+
85
+ /** One charge: label, an amount that reads blank until entered (a one-tap
86
+ * Standard fill + "Set 0" while unset), and the payment method — which turns
87
+ * required the moment the line carries an amount. */
88
+ function ChargeLine({
89
+ charge, onAmount, onMethod, disabled,
90
+ }: {
91
+ charge: Charge;
92
+ onAmount: (v: number) => void;
93
+ onMethod: (m: Method) => void;
94
+ disabled: boolean;
95
+ }) {
96
+ const unset = charge.amount <= 0;
97
+ const needsMethod = charge.amount > 0 && !charge.method;
98
+ return (
99
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, flexWrap: "wrap" }}>
100
+ <Text size="sm" color="muted" style={{ width: 116, paddingTop: 10 }}>{charge.label}</Text>
101
+ <View style={{ flexGrow: 1, flexBasis: 130, gap: 4 }}>
102
+ <NumberInput
103
+ value={unset ? null : charge.amount}
104
+ onValueChange={(v) => onAmount(v ?? 0)}
105
+ min={0}
106
+ disabled={disabled}
107
+ accessibilityLabel={`${charge.label} amount`}
108
+ />
109
+ {unset && !disabled ? (
110
+ <View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap" }}>
111
+ {charge.standard > 0 ? (
112
+ <Button title={`Standard ${formatMoney(charge.standard)}`} color="secondary" onPress={() => onAmount(charge.standard)} />
113
+ ) : null}
114
+ <Button title="Set 0" color="secondary" onPress={() => onAmount(0)} />
115
+ </View>
116
+ ) : null}
117
+ </View>
118
+ <View style={{ flexBasis: 168, gap: 4 }}>
119
+ <Picker
120
+ options={METHODS}
121
+ value={charge.method || null}
122
+ onValueChange={onMethod}
123
+ placeholder="How paid…"
124
+ disabled={disabled || unset}
125
+ accessibilityLabel={`Payment method · ${charge.label}`}
126
+ />
127
+ {needsMethod ? <Text size="xs" color="danger">Choose a method</Text> : null}
128
+ </View>
129
+ </View>
130
+ );
131
+ }
132
+
133
+ /** One invoice document, rendered as a BAND inside the single billing card: a
134
+ * title + status header, its charge lines, and a total + issue row. The leading
135
+ * Divider sets it off from the band above without a second card. */
136
+ function InvoiceBand({
137
+ inv, taxId, onAmount, onMethod, onIssue,
138
+ }: {
139
+ inv: Invoice;
140
+ taxId: string;
141
+ onAmount: (chKey: string, v: number) => void;
142
+ onMethod: (chKey: string, m: Method) => void;
143
+ onIssue: (inv: Invoice) => void;
144
+ }) {
145
+ const total = invoiceTotal(inv);
146
+ const status = invoiceStatus(inv);
147
+ const missing = missingMethods(inv);
148
+ const canIssue = total > 0 && missing.length === 0 && !!taxId;
149
+ const blockReason = !taxId ? "Add the customer's tax ID to issue." : missing.length ? "Choose a payment method for every charged line." : "";
150
+ return (
151
+ <>
152
+ <Divider />
153
+ <CardBody style={{ gap: 10 }}>
154
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
155
+ <Text weight="semibold">{inv.title}</Text>
156
+ <View style={{ flex: 1 }} />
157
+ {status === "issued" ? (
158
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
159
+ <Badge variant="dot" label="Issued" color="emerald" />
160
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
161
+ </View>
162
+ ) : status === "draft" ? (
163
+ <Badge variant="dot" label="Draft" color="amber" />
164
+ ) : (
165
+ <Badge variant="dot" label="Nothing to bill" color="zinc" />
166
+ )}
167
+ </View>
168
+ {inv.charges.map((c) => (
169
+ <ChargeLine
170
+ key={c.key}
171
+ charge={c}
172
+ disabled={status === "issued"}
173
+ onAmount={(v) => onAmount(c.key, v)}
174
+ onMethod={(m) => onMethod(c.key, m)}
175
+ />
176
+ ))}
177
+ {total > 0 ? (
178
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap", paddingTop: 2 }}>
179
+ <Text size="sm" color="muted" style={{ flex: 1 }}>
180
+ Total <Text weight="semibold" color="default" tabular>{formatMoney(total)}</Text>
181
+ </Text>
182
+ {status === "issued" ? (
183
+ <>
184
+ <Button title="Re-issue" color="secondary" disabled={!canIssue} onPress={() => onIssue(inv)} />
185
+ </>
186
+ ) : (
187
+ <Button title="Issue invoice" color="primary" disabled={!canIssue} onPress={() => onIssue(inv)} />
188
+ )}
189
+ </View>
190
+ ) : null}
191
+ {total > 0 && status !== "issued" && !canIssue ? <Text size="xs" color="muted">{blockReason}</Text> : null}
192
+ </CardBody>
193
+ </>
194
+ );
195
+ }
196
+
197
+ export function TplBilling() {
198
+ const [invoices, setInvoices] = useState<Invoice[]>(INITIAL);
199
+ // The bill-to: issuing an e-invoice needs a tax ID. Toggle it to see the gate.
200
+ const [taxId, setTaxId] = useState("0312456780");
201
+ const [deposit, setDeposit] = useState(0);
202
+ const [confirm, setConfirm] = useState<Invoice | null>(null);
203
+ const seq = useState(() => ({ n: 414 }))[0];
204
+
205
+ const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
206
+ setInvoices((prev) =>
207
+ prev.map((inv) =>
208
+ inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) },
209
+ ),
210
+ );
211
+
212
+ const grandTotal = invoices.reduce((s, inv) => s + invoiceTotal(inv), 0);
213
+ const allMissing = invoices.flatMap(missingMethods);
214
+ const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
215
+
216
+ const issue = (inv: Invoice) => {
217
+ seq.n += 1;
218
+ const ref = `INV-2026-${String(seq.n).padStart(4, "0")}`;
219
+ setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
220
+ setConfirm(null);
221
+ };
222
+
223
+ const printReceipt = () => {
224
+ if (allMissing.length > 0) {
225
+ Alert.alert(
226
+ "Missing payment method",
227
+ `Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`,
228
+ [{ text: "OK" }],
229
+ );
230
+ return;
231
+ }
232
+ Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
233
+ };
234
+
235
+ return (
236
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
237
+ <View style={{ width: "100%", maxWidth: 640, alignSelf: "center", gap: 16 }}>
238
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
239
+ <Text size="xl" weight="semibold">Billing</Text>
240
+ <Badge label="Order #4821" color="zinc" />
241
+ </View>
242
+
243
+ {/* ONE banded card — bill-to · each invoice · collect · deposit */}
244
+ <Card style={{ padding: 0 }}>
245
+ <CardHeader>
246
+ <CardHeaderTitle info="One document: the bill-to, each invoice, the total, and the deposit are bands of the same card.">
247
+ Phí &amp; hóa đơn
248
+ </CardHeaderTitle>
249
+ <CardHeaderMeta>{formatMoney(grandTotal)}</CardHeaderMeta>
250
+ </CardHeader>
251
+
252
+ {/* bill-to band — issuing needs a tax ID; the toggle shows the gate */}
253
+ <CardBody>
254
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
255
+ <Icon name="building-2" size={16} color={colors.zinc[500]} />
256
+ <Text weight="medium">Harbor Freight Lines</Text>
257
+ <Text size="sm" color="muted">·</Text>
258
+ {taxId ? (
259
+ <Text size="sm" color="muted" tabular>MST {taxId}</Text>
260
+ ) : (
261
+ <Text size="sm" color="danger">No tax ID — required to issue</Text>
262
+ )}
263
+ <View style={{ flex: 1 }} />
264
+ <Button title={taxId ? "Remove tax ID" : "Add tax ID"} color="muted" onPress={() => setTaxId((t) => (t ? "" : "0312456780"))} />
265
+ </View>
266
+ </CardBody>
267
+
268
+ {/* invoice bands */}
269
+ {invoices.map((inv) => (
270
+ <InvoiceBand
271
+ key={inv.key}
272
+ inv={inv}
273
+ taxId={taxId}
274
+ onAmount={(chKey, v) => patchCharge(inv.key, chKey, { amount: v })}
275
+ onMethod={(chKey, m) => patchCharge(inv.key, chKey, { method: m })}
276
+ onIssue={setConfirm}
277
+ />
278
+ ))}
279
+
280
+ {/* collect band */}
281
+ <Divider />
282
+ <CardBody style={{ gap: 10 }}>
283
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
284
+ <Text weight="semibold" style={{ flex: 1 }}>Tổng thu</Text>
285
+ <Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
286
+ </View>
287
+ {allMissing.length > 0 ? (
288
+ <Callout tone="warning">
289
+ <CalloutText>
290
+ {allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
291
+ </CalloutText>
292
+ </Callout>
293
+ ) : null}
294
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
295
+ <Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
296
+ <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
297
+ </View>
298
+ </CardBody>
299
+
300
+ {/* deposit band — tinted + labelled separate, never part of the total */}
301
+ <Divider />
302
+ <CardBody style={{ backgroundColor: colors.zinc[50], gap: 8 }}>
303
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
304
+ <Text weight="semibold">Deposit</Text>
305
+ <Badge label="Collected separately" color="zinc" />
306
+ </View>
307
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
308
+ <Text size="sm" color="muted" style={{ width: 116 }}>Refundable</Text>
309
+ <View style={{ flexGrow: 1, flexBasis: 130 }}>
310
+ <NumberInput value={deposit || null} onValueChange={(v) => setDeposit(v ?? 0)} min={0} accessibilityLabel="Deposit amount" />
311
+ </View>
312
+ <Button
313
+ title="Deposit receipt"
314
+ color="secondary"
315
+ disabled={deposit <= 0}
316
+ onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
317
+ />
318
+ </View>
319
+ </CardBody>
320
+ </Card>
321
+ </View>
322
+
323
+ {/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
324
+ <Dialog width={460} open={confirm !== null} onOpenChange={(o) => { if (!o) setConfirm(null); }}>
325
+ <DialogHeader>
326
+ <DialogHeaderTitle>{confirm?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
327
+ </DialogHeader>
328
+ <View style={{ paddingHorizontal: 24, paddingVertical: 12 }}>
329
+ <Callout tone="warning">
330
+ <CalloutText>
331
+ {confirm
332
+ ? `Issue a real e-invoice for "${confirm.title}" (${formatMoney(invoiceTotal(confirm))}) to the provider. This writes a lookup code to the record and can't be undone.`
333
+ : ""}
334
+ </CalloutText>
335
+ </Callout>
336
+ </View>
337
+ <DialogFooter>
338
+ <Button title="Cancel" color="secondary" onPress={() => setConfirm(null)} />
339
+ <Button title="Issue" color="primary" onPress={() => confirm && issue(confirm)} />
340
+ </DialogFooter>
341
+ </Dialog>
342
+ </ScrollView>
343
+ );
344
+ }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
7
7
  "./colors": "./src/colors.ts",
8
+ "./option_badge": "./src/option_badge.tsx",
9
+ "./member_chip": "./src/member_chip.tsx",
10
+ "./member_select": "./src/member_select.tsx",
8
11
  "./mime": "./src/mime.ts",
9
12
  "./download": "./src/download.ts",
10
13
  "./file_picker": "./src/file_picker.ts",
@@ -110,6 +113,7 @@
110
113
  "./filter_pill": "./src/filter_pill.tsx",
111
114
  "./range_slider": "./src/range_slider.tsx",
112
115
  "./counter": "./src/counter.tsx",
116
+ "./link": "./src/link.tsx",
113
117
  "./link_button": "./src/link_button.tsx",
114
118
  "./sort_header": "./src/sort_header.tsx",
115
119
  "./skeleton": "./src/skeleton.tsx",
@@ -3,17 +3,19 @@ import { Text } from "./text";
3
3
  import { PressableHighlight } from "./pressable_highlight";
4
4
  import { pillSurfaceStyle } from "./control_surface";
5
5
 
6
- // Exclusive filter chips the view-control sibling of the form-input
7
- // selectors. Picking between the one-of-N controls:
8
- // - ChipGroup: a SMALL, HOT filter set (≤ ~10 short options) the user flips
9
- // between constantly — every option stays visible, switching is one tap,
10
- // the row wraps on narrow widths. Reads as "narrow this view".
11
- // - Picker: many options or tight space compact, but hides the set behind
12
- // a click. Reads as "choose a value".
13
- // - RadioPicker: a form input that SETS data on a record (radio circles
14
- // signal "this writes"), not a view filter.
15
- // Chips carry quiet zinc styling: bordered white at rest, dark fill when
16
- // active color stays reserved for status semantics and primary actions.
6
+ // One-of-N chips: every option visible, one tap to switch, the row wraps on
7
+ // narrow widths. Quiet zinc styling bordered white at rest, dark fill when
8
+ // active (color stays reserved for status + primary actions). Two jobs, one
9
+ // control:
10
+ // - a SMALL, HOT FILTER the user flips between constantly ("narrow this
11
+ // view") model the unfiltered state as an explicit option (e.g. "All").
12
+ // - a small REQUIRED single-select in a form / composer (a call outcome, a
13
+ // 1–N grade) pills keep every choice in view and entry to one tap; an
14
+ // empty initial value (nothing chosen yet) is fine here.
15
+ // Reach for `Picker` when options are many or space is tight (it hides the set
16
+ // behind a click); for `RadioPicker` when the radio-circle "this writes"
17
+ // affordance suits a denser form. It's affordance + density, not
18
+ // filter-vs-write — the same ChipGroup serves both.
17
19
 
18
20
  export interface ChipOption<T extends string = string> {
19
21
  label: string;
@@ -29,8 +31,9 @@ export interface ChipGroupProps<T extends string = string> {
29
31
  */
30
32
  accessibilityLabel: string;
31
33
  options: ChipOption<T>[];
32
- /** The active option exactly one; include an explicit "all" option for
33
- * the unfiltered state rather than modelling it as no selection. */
34
+ /** The active option. As a FILTER, model the unfiltered state as an explicit
35
+ * option (e.g. "All"); as a FORM selector, an empty value (nothing chosen
36
+ * yet) is valid. */
34
37
  value: T;
35
38
  onValueChange: (value: T) => void;
36
39
  }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isColorName, asColorName } from "./colors";
3
+
4
+ describe("isColorName", () => {
5
+ it("accepts palette family names", () => {
6
+ expect(isColorName("blue")).toBe(true);
7
+ expect(isColorName("emerald")).toBe(true);
8
+ expect(isColorName("zinc")).toBe(true);
9
+ });
10
+
11
+ it("rejects role keys and scalar colors (not selectable families)", () => {
12
+ expect(isColorName("border")).toBe(false);
13
+ expect(isColorName("border_shadow")).toBe(false);
14
+ expect(isColorName("background")).toBe(false);
15
+ expect(isColorName("shadow")).toBe(false);
16
+ expect(isColorName("black")).toBe(false);
17
+ expect(isColorName("white")).toBe(false);
18
+ });
19
+
20
+ it("rejects unknown tokens and non-strings", () => {
21
+ expect(isColorName("chartreuse")).toBe(false);
22
+ expect(isColorName("")).toBe(false);
23
+ expect(isColorName(undefined)).toBe(false);
24
+ expect(isColorName(null)).toBe(false);
25
+ expect(isColorName(42)).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe("asColorName", () => {
30
+ it("passes a valid family through", () => {
31
+ expect(asColorName("purple")).toBe("purple");
32
+ });
33
+
34
+ it("degrades an unknown/absent token to the neutral default", () => {
35
+ // The graceful-degradation contract: a select option whose color token this
36
+ // build doesn't recognize, or none at all, renders neutral rather than break.
37
+ expect(asColorName("chartreuse")).toBe("zinc");
38
+ expect(asColorName(undefined)).toBe("zinc");
39
+ expect(asColorName(null)).toBe("zinc");
40
+ });
41
+
42
+ it("honors a custom fallback", () => {
43
+ expect(asColorName(undefined, "slate")).toBe("slate");
44
+ });
45
+ });
package/src/colors.ts CHANGED
@@ -359,3 +359,28 @@ export function ramp(name: ColorName, count: number): string[] {
359
359
  return scale[RAMP_STOPS[idx]];
360
360
  });
361
361
  }
362
+
363
+ /**
364
+ * Is `value` a usable {@link ColorName} — a palette FAMILY, not a role key
365
+ * (`border`/`background`/…) and not `black`/`white`? A family resolves to a
366
+ * shade object; the role/scalar keys resolve to a string, so "value is an
367
+ * object" is the test (no name list to keep in sync).
368
+ */
369
+ export function isColorName(value: unknown): value is ColorName {
370
+ return (
371
+ typeof value === "string" &&
372
+ value in colors &&
373
+ typeof (colors as Record<string, unknown>)[value] === "object"
374
+ );
375
+ }
376
+
377
+ /**
378
+ * Coerce an arbitrary color token to a {@link ColorName}, falling back to a
379
+ * neutral. The single graceful-degradation point for stored option/status
380
+ * colors: a select option's `color` may be a token a newer table config
381
+ * introduced that this UI build predates, or absent entirely — either way a
382
+ * component renders a neutral badge instead of breaking. Used by `OptionBadge`.
383
+ */
384
+ export function asColorName(value: unknown, fallback: ColorName = "zinc"): ColorName {
385
+ return isColorName(value) ? value : fallback;
386
+ }
package/src/combobox.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "react-native";
10
10
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
11
11
  import { colors } from "./colors";
12
+ import { ACTIVE_RING } from "./control_surface";
12
13
  import { Text } from "./text";
13
14
  import { Icon, type IconName } from "./icon";
14
15
  import { TextInputField } from "./text_input_field";
@@ -289,7 +290,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
289
290
 
290
291
  return (
291
292
  <View style={style}>
292
- <View ref={triggerRef} style={multi ? styles.multiBox : undefined}>
293
+ <View ref={triggerRef} style={multi ? [styles.multiBox, open && styles.openRing] : undefined}>
293
294
  {multi
294
295
  ? chips.map((chip) => (
295
296
  <View key={chip.value} style={styles.chip}>
@@ -337,7 +338,12 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
337
338
  autoFocus={autoFocus}
338
339
  autoCapitalize="none"
339
340
  autoCorrect={false}
340
- style={multi ? styles.multiInput : showChevron ? styles.selectInput : undefined}
341
+ style={[
342
+ multi ? styles.multiInput : showChevron ? styles.selectInput : undefined,
343
+ // Single-select wears the open ring on the input (which holds the
344
+ // border); multi wears it on the wrapper above (the input is borderless).
345
+ !multi && open ? styles.openRing : undefined,
346
+ ]}
341
347
  role="combobox"
342
348
  aria-expanded={showList}
343
349
  aria-controls={listboxId}
@@ -455,6 +461,9 @@ const styles = StyleSheet.create({
455
461
  borderWidth: 0,
456
462
  height: 28,
457
463
  },
464
+ openRing: {
465
+ boxShadow: ACTIVE_RING,
466
+ },
458
467
  // Select mode: room on the right for the trailing chevron.
459
468
  selectInput: {
460
469
  paddingRight: 32,
@@ -5,6 +5,14 @@ import { colors } from "./colors";
5
5
  * buttons) aligns to it so a toolbar row reads as one band. */
6
6
  export const CONTROL_HEIGHT = 40;
7
7
 
8
+ /** The active/open edge for an interactive control — a 2px zinc-900 ring flush
9
+ * against the box (offset 0), MIRRORING the keyboard `:focus-visible` outline
10
+ * (index.css). A mouse-opened popover trigger (Picker / Combobox / InlineSelect /
11
+ * InlineDatePicker) never receives `:focus-visible`, so apply this on its open
12
+ * state to read identically to a focused input — a lone 1px border looks thin
13
+ * beside the kit's focus outline and selected-pill ring. */
14
+ export const ACTIVE_RING = `0 0 0 2px ${colors.zinc[900]}`;
15
+
8
16
  /**
9
17
  * THE pill-surface contract — the single definition of the bordered, white,
10
18
  * rounded interactive surface shared by `ChipGroup` chips and `PillButton` (and
@@ -20,8 +20,9 @@ export interface FileDropzoneProps {
20
20
  hint?: string;
21
21
  /** Main line while a drag hovers the zone ("Thả để tải lên"). */
22
22
  dropLabel?: string;
23
- /** Zone height — size it to the surface (compact 120 in a form field,
24
- * 200+ as a screen's main affordance). Default 160. */
23
+ /** Minimum zone height — it grows to fit the icon + labels + padding, so a
24
+ * small value never crops the content. Size it to the surface (compact ~120 in
25
+ * a form field, 200+ as a screen's main affordance). Default 160. */
25
26
  height?: number;
26
27
  disabled?: boolean;
27
28
  accessibilityLabel?: string;
@@ -127,7 +128,7 @@ export function FileDropzone(props: FileDropzoneProps) {
127
128
  onHoverOut={() => setHovered(false)}
128
129
  style={[
129
130
  styles.zone,
130
- { height },
131
+ { minHeight: height },
131
132
  hovered && !dragging ? styles.zoneHovered : null,
132
133
  dragging ? styles.zoneDragging : null,
133
134
  disabled ? styles.zoneDisabled : null,
@@ -160,6 +161,7 @@ const styles = StyleSheet.create({
160
161
  borderColor: colors.zinc[300],
161
162
  backgroundColor: colors.zinc[50],
162
163
  paddingHorizontal: 20,
164
+ paddingVertical: 16,
163
165
  },
164
166
  zoneHovered: {
165
167
  borderColor: colors.zinc[400],
package/src/index.css CHANGED
@@ -336,9 +336,12 @@ html {
336
336
  color: var(--color-zinc-900);
337
337
  }
338
338
 
339
- /* Keyboard focus ring. `:focus-visible` only matches keyboard-driven focus,
340
- so pointer/touch interactions stay visually unchanged. The ring sits flush
341
- against the element's border (offset 0, no gap) and, being an outline, never
339
+ /* Focus ring — keyboard-driven only (`:focus-visible`), so pointer/touch
340
+ interactions stay clutter-free (no stray ring stacked on a clicked button,
341
+ tab, or segment that already shows its own state). A SELECT shows its own
342
+ active edge on a mouse-open via `ACTIVE_RING` (control_surface.ts) — that, not
343
+ this rule, is what rings the combobox/picker on a pointer open. The ring sits
344
+ flush against the border (offset 0, no gap) and, being an outline, never
342
345
  reflows layout. */
343
346
  :focus-visible {
344
347
  outline: 2px solid var(--color-zinc-900);
@@ -77,6 +77,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
77
77
  display={display}
78
78
  placeholder={placeholder}
79
79
  disabled={disabled}
80
+ active={open && !disabled}
80
81
  accessibilityLabel={accessibilityLabel}
81
82
  trailing={
82
83
  saving ? (
@@ -5,6 +5,7 @@ import { IconButton } from "./icon_button";
5
5
  import { ActivityIndicator } from "./activity_indicator";
6
6
  import { PressableHighlight } from "./pressable_highlight";
7
7
  import { colors } from "./colors";
8
+ import { ACTIVE_RING } from "./control_surface";
8
9
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
9
10
 
10
11
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
@@ -109,6 +110,9 @@ interface InlineEditViewProps {
109
110
  accessibilityLabel?: string;
110
111
  /** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving. */
111
112
  trailing?: ReactNode;
113
+ /** True while the field's popover (select/date) is open: wears the 2px active
114
+ * ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
115
+ active?: boolean;
112
116
  ref?: Ref<View>;
113
117
  }
114
118
 
@@ -121,7 +125,7 @@ interface InlineEditViewProps {
121
125
  * select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
122
126
  */
123
127
  export function InlineEditView(props: InlineEditViewProps) {
124
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, ref } = props;
128
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, ref } = props;
125
129
  return (
126
130
  <PressableHighlight
127
131
  ref={ref}
@@ -130,7 +134,7 @@ export function InlineEditView(props: InlineEditViewProps) {
130
134
  accessibilityRole="button"
131
135
  accessibilityLabel={accessibilityLabel}
132
136
  userSelect="none"
133
- style={styles.view}
137
+ style={[styles.view, active && styles.viewActive]}
134
138
  >
135
139
  <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
136
140
  {display || placeholder || "—"}
@@ -219,6 +223,12 @@ const styles = StyleSheet.create({
219
223
  alignItems: "center",
220
224
  gap: 6,
221
225
  },
226
+ // Open (popover showing): the transparent edge fills in to the kit's 1px border
227
+ // under the 2px active ring — identical to a focused input.
228
+ viewActive: {
229
+ borderColor: colors.border,
230
+ boxShadow: ACTIVE_RING,
231
+ },
222
232
  placeholder: { color: colors.zinc[400] },
223
233
  editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
224
234
  editControl: { flex: 1, position: "relative" },
@@ -61,6 +61,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
61
61
  display={selected?.label ?? ""}
62
62
  placeholder={placeholder}
63
63
  disabled={disabled}
64
+ active={open && !disabled}
64
65
  accessibilityLabel={accessibilityLabel}
65
66
  trailing={
66
67
  saving ? (
package/src/link.tsx ADDED
@@ -0,0 +1,32 @@
1
+ import { type ReactNode } from "react";
2
+ import { Pressable } from "react-native";
3
+ import { Text, type TextSize } from "./text";
4
+ import { colors } from "./colors";
5
+
6
+ export interface LinkProps {
7
+ children: ReactNode;
8
+ /** Opening the link is the consumer's job (a URL via the app SDK's openExternal,
9
+ * an in-app route, …) — Link is pure presentation + the press target. */
10
+ onPress: () => void;
11
+ size?: TextSize;
12
+ accessibilityLabel?: string;
13
+ testID?: string;
14
+ }
15
+
16
+ /**
17
+ * An EXTERNAL hyperlink — underline + blue, the universal "this opens somewhere
18
+ * else" signal (a document URL, a record, an invoice). The deliberate counterpart
19
+ * to `LinkButton`: `LinkButton` is a QUIET IN-APP ACTION (a ghost button — "Clear",
20
+ * "Show more") and intentionally NOT underlined; `Link` is a link OUT and IS
21
+ * underlined-blue so it reads as a destination, not an action. The consumer wires
22
+ * `onPress` to its opener.
23
+ */
24
+ export function Link({ children, onPress, size = "sm", accessibilityLabel, testID }: LinkProps) {
25
+ return (
26
+ <Pressable onPress={onPress} accessibilityRole="link" accessibilityLabel={accessibilityLabel} testID={testID}>
27
+ <Text size={size} decoration="underline" style={{ color: colors.blue[600] }}>
28
+ {children}
29
+ </Text>
30
+ </Pressable>
31
+ );
32
+ }
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
3
+ import { Avatar } from "./avatar";
4
+ import { Text } from "./text";
5
+
6
+ interface MemberChipProps {
7
+ /** Display name. Empty/blank falls back to a neutral label — pass
8
+ * `name={member.name || member.email}` to prefer email when unnamed. */
9
+ name?: string | null;
10
+ /** Avatar image URL (a member's `image` from `useMembers`). Absent → initials. */
11
+ image?: string | null;
12
+ /** Optional secondary line under the name — e.g. email, role, department. */
13
+ secondary?: string | null;
14
+ /** Avatar diameter in px. Default 28. */
15
+ size?: number;
16
+ style?: StyleProp<ViewStyle>;
17
+ }
18
+
19
+ /**
20
+ * Render a member/person as an avatar + name (+ optional secondary line) — the
21
+ * one canonical way to show a person inline: a member-picker option, an assignee,
22
+ * a `select_member` cell in a register or detail. The {@link Avatar} falls back
23
+ * to initials when there's no image.
24
+ *
25
+ * Pure: pass the member's fields in (from `useMembers`, or a resolved
26
+ * `select_member` cell joined against that roster); this component fetches
27
+ * nothing and carries no domain types.
28
+ */
29
+ export function MemberChip({ name, image, secondary, size = 28, style }: MemberChipProps) {
30
+ const displayName = name?.trim() || "Unknown";
31
+ return (
32
+ <View style={[styles.row, style]}>
33
+ <Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
34
+ <View style={styles.text}>
35
+ <Text userSelect="none" numberOfLines={1}>
36
+ {displayName}
37
+ </Text>
38
+ {secondary ? (
39
+ <Text userSelect="none" size="sm" color="zinc-500" numberOfLines={1}>
40
+ {secondary}
41
+ </Text>
42
+ ) : null}
43
+ </View>
44
+ </View>
45
+ );
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ row: { flexDirection: "row", alignItems: "center", gap: 6 },
50
+ text: { flexShrink: 1 },
51
+ });
@@ -0,0 +1,81 @@
1
+ import React, { useCallback, useMemo } from "react";
2
+ import { StyleProp, ViewStyle } from "react-native";
3
+ import {
4
+ Picker,
5
+ type PickerOption,
6
+ type PickerValue,
7
+ type PickerOnValueChange,
8
+ type PickerOnClose,
9
+ } from "./picker";
10
+ import { MemberChip } from "./member_chip";
11
+
12
+ /** A candidate member for {@link MemberSelect}. Shaped to accept a `useMembers`
13
+ * row from `@lotics/app-sdk` (or any roster) directly. */
14
+ export interface MemberSelectMember {
15
+ id: string;
16
+ name?: string | null;
17
+ image?: string | null;
18
+ email?: string | null;
19
+ }
20
+
21
+ interface MemberSelectProps<MULTI extends boolean = false> {
22
+ /**
23
+ * The candidate roster. An app feeds `useMembers()`; the product feeds any
24
+ * directory. Each member carries its own avatar (`image`), so options render
25
+ * with no side lookup.
26
+ */
27
+ members: MemberSelectMember[];
28
+ value?: PickerValue<string, MULTI> | null;
29
+ onValueChange?: PickerOnValueChange<string, MULTI>;
30
+ onClose?: PickerOnClose<string, MULTI>;
31
+ multi?: MULTI;
32
+ placeholder?: string;
33
+ disabled?: boolean;
34
+ autoFocus?: boolean;
35
+ includeEmptyOption?: boolean;
36
+ style?: StyleProp<ViewStyle>;
37
+ testID?: string;
38
+ accessibilityLabel?: string;
39
+ }
40
+
41
+ /**
42
+ * Choose member(s) — a {@link Picker} whose every option renders as a
43
+ * {@link MemberChip} (avatar + name). The one member-picker shape, so an app
44
+ * never re-wires `Picker` + `renderOptionContent` + a directory by hand. PURE:
45
+ * pass the candidate `members` (an app feeds `useMembers`, the product any
46
+ * roster); this fetches nothing. Single or multi via `multi`. Options are
47
+ * single-line by design — a picker row identifies a person by name + avatar;
48
+ * for a denser identity (email/role), render {@link MemberChip} with `secondary`
49
+ * on a roomier surface (a register row, a detail), not in a picker.
50
+ *
51
+ * ```tsx
52
+ * const { members } = useMembers();
53
+ * <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
54
+ * ```
55
+ */
56
+ export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectProps<MULTI>) {
57
+ const { members, ...picker } = props;
58
+
59
+ const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
60
+ const options = useMemo<PickerOption<string>[]>(
61
+ () => members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
62
+ [members],
63
+ );
64
+
65
+ const renderOptionContent = useCallback(
66
+ (option: PickerOption<string>) => {
67
+ const member = byId.get(option.value);
68
+ if (!member) return null;
69
+ return <MemberChip name={member.name || member.email} image={member.image} />;
70
+ },
71
+ [byId],
72
+ );
73
+
74
+ return (
75
+ <Picker<string, MULTI>
76
+ options={options}
77
+ renderOptionContent={renderOptionContent}
78
+ {...picker}
79
+ />
80
+ );
81
+ }
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
3
+ import { Badge } from "./badge";
4
+ import { asColorName } from "./colors";
5
+
6
+ /**
7
+ * One select option, as carried by a query CELL (`readSelect`) or the option
8
+ * LIST (`useFieldOptions`) in `@lotics/app-sdk`. `key` is optional — used only
9
+ * as a stable React key. `color` is a palette token; it may be absent (a cell
10
+ * carries only key + label) or a token this UI build doesn't recognize — either
11
+ * way the badge degrades to a neutral color.
12
+ */
13
+ export interface OptionValue {
14
+ key?: string;
15
+ label: string;
16
+ color?: string | null;
17
+ }
18
+
19
+ interface OptionBadgeProps {
20
+ /**
21
+ * A single option, an array (a multi-select cell → one badge each), or
22
+ * null/empty (renders nothing — pair with your own placeholder).
23
+ */
24
+ value?: OptionValue | OptionValue[] | null;
25
+ /** Badge weight — see {@link Badge}. Default "tonal". */
26
+ variant?: "tonal" | "dot";
27
+ style?: StyleProp<ViewStyle>;
28
+ }
29
+
30
+ /**
31
+ * Render a select-field value as colored {@link Badge}(s) with the option's
32
+ * CONFIGURED color resolved automatically — so an app never hand-maintains an
33
+ * option-key → color map, and a freshly added or renamed option just renders.
34
+ * Multi-select wraps to one badge per option; an empty value renders nothing; an
35
+ * option with a missing/unrecognized color token falls back to a neutral badge.
36
+ *
37
+ * Feed it straight from `@lotics/app-sdk`: a `useFieldOptions` option for a
38
+ * picker, or `byKey(readSelect(cell)[0]?.key)` for a stored value.
39
+ */
40
+ export function OptionBadge({ value, variant, style }: OptionBadgeProps) {
41
+ const options = value == null ? [] : Array.isArray(value) ? value : [value];
42
+ if (options.length === 0) return null;
43
+ if (options.length === 1) {
44
+ const o = options[0];
45
+ return <Badge label={o.label} color={asColorName(o.color)} variant={variant} style={style} />;
46
+ }
47
+ return (
48
+ <View style={[styles.wrap, style]}>
49
+ {options.map((o, i) => (
50
+ <Badge key={o.key ?? String(i)} label={o.label} color={asColorName(o.color)} variant={variant} />
51
+ ))}
52
+ </View>
53
+ );
54
+ }
55
+
56
+ const styles = StyleSheet.create({
57
+ wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 4 },
58
+ });
package/src/picker.tsx CHANGED
@@ -2,6 +2,7 @@ import { Picker as RNPicker } from "@react-native-picker/picker";
2
2
  import { StyleSheet, View, Pressable, StyleProp, ViewStyle, TextStyle } from "react-native";
3
3
  import { useState, useCallback, useMemo, useEffect, useRef } from "react";
4
4
  import { colors } from "./colors";
5
+ import { ACTIVE_RING } from "./control_surface";
5
6
  import { Text } from "./text";
6
7
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
7
8
  import { Icon } from "./icon";
@@ -328,7 +329,9 @@ const styles = StyleSheet.create({
328
329
  height: 40,
329
330
  },
330
331
  opened: {
331
- borderColor: colors.zinc["900"],
332
+ // Mouse-opened, so no `:focus-visible` — wear the same 2px zinc ring a
333
+ // focused input gets, over the unchanged 1px border (not a lone 1px edge).
334
+ boxShadow: ACTIVE_RING,
332
335
  },
333
336
  disabled: {
334
337
  opacity: 0.5,
package/src/popover.tsx CHANGED
@@ -254,8 +254,17 @@ export function PopoverContent(props: PopoverContentProps) {
254
254
 
255
255
  return () => {
256
256
  cancelAnimationFrame(focusFrame);
257
- const target = returnFocusRef.current;
257
+ const prior = returnFocusRef.current;
258
258
  returnFocusRef.current = null;
259
+ // Return focus to the TRIGGER (WAI-ARIA: focus returns to the element that
260
+ // invoked the popup). `previouslyFocused` is an unreliable proxy — RN-Web's
261
+ // Pressable triggers don't take focus on mouse press, so for a mouse-opened
262
+ // popover (a Picker/InlineSelect/menu) it is <body>; restoring there strands
263
+ // focus and the next Tab jumps to the page's first focusable. Fall back to
264
+ // the prior element only if the trigger is gone (e.g. a hover-revealed menu
265
+ // button that unmounted under the overlay).
266
+ const trigger = triggerRef.current;
267
+ const target = trigger && trigger.isConnected ? trigger : prior;
259
268
  if (target && typeof target.focus === "function") {
260
269
  target.focus();
261
270
  }
@@ -55,7 +55,10 @@ export function PressableRow(props: PressableRowProps) {
55
55
  return (
56
56
  <Pressable
57
57
  onPress={onPress}
58
- focusable={false}
58
+ // Non-focusable surface: tabIndex, NOT focusable — RN-Web's Pressable
59
+ // ignores `focusable` and writes its own tabIndex (the keyboard target is
60
+ // the nested "Open …" button, not this row).
61
+ tabIndex={-1}
59
62
  {...mouseProps}
60
63
  style={({ pressed }) => [
61
64
  styles.row,
@@ -128,7 +128,9 @@ function RadioOption<T extends string | number | symbol>(
128
128
  accessibilityLabel={description ? `${label}, ${description}` : label}
129
129
  aria-checked={selected}
130
130
  // Roving tabindex: exactly one radio is the tab-stop. Arrow keys cycle.
131
- focusable={isTabStop}
131
+ // tabIndex, NOT focusable — RN-Web's Pressable ignores `focusable` and
132
+ // writes its own tabIndex.
133
+ tabIndex={isTabStop ? 0 : -1}
132
134
  onKeyDown={onKeyDown}
133
135
  >
134
136
  <View
@@ -152,8 +152,9 @@ function Segment<T extends string>(props: SegmentProps<T>) {
152
152
  aria-checked={selected}
153
153
  aria-disabled={disabled}
154
154
  // Roving tabindex: only the selected segment (or the fallback) is a tab
155
- // stop; the rest are reached with arrow keys.
156
- focusable={isTabStop && !disabled}
155
+ // stop; the rest are reached with arrow keys. tabIndex, NOT focusable —
156
+ // RN-Web's Pressable ignores `focusable` and writes its own tabIndex.
157
+ tabIndex={isTabStop && !disabled ? 0 : -1}
157
158
  >
158
159
  {(state) => {
159
160
  const hovered = (state as { hovered?: boolean }).hovered;
package/src/tabs.tsx CHANGED
@@ -125,8 +125,10 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
125
125
  // Roving tabindex: the selected tab is the tab-stop, others are reachable
126
126
  // via arrow keys. When no tab matches the current selection (props.value
127
127
  // is stale), the first tab is the fallback so the group stays keyboard-
128
- // reachable.
129
- focusable={isTabStop}
128
+ // reachable. Drive it with tabIndex, NOT focusable: RN-Web's Pressable
129
+ // ignores `focusable` (it writes its own tabIndex), so a focusable-based
130
+ // roving model silently leaves every tab a tab-stop.
131
+ tabIndex={isTabStop ? 0 : -1}
130
132
  >
131
133
  {(state) => {
132
134
  const { pressed } = state;
package/src/text.tsx CHANGED
@@ -47,7 +47,7 @@ export interface TextProps {
47
47
 
48
48
  export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
49
49
 
50
- type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
50
+ export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
51
51
  type TextAlign = "left" | "right" | "center";
52
52
  type TextDecorationLine = "underline" | "lineThrough" | "underline lineThrough";
53
53
  type TextWeight = "regular" | "medium" | "semibold";