@lotics/ui 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -25,7 +25,8 @@ 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
32
  `CardSelectItem`.
@@ -72,6 +73,7 @@ This is the most common thing to get right. Match the JOB to the pattern:
72
73
  | a brand-NEW record (a form) | **fieldset form** (`FormField` + the 2-col grid) | structured entry + a Save |
73
74
  | a RELATED record (pick or make) | **find-or-create** (`Combobox allowCustom`) | one control covers both |
74
75
  | REPEATING rows you build & revise | **line items** (create→preview→edit) | add / edit / remove, live totals |
76
+ | CHARGES that bill onto documents | **billing** (`tpl_billing`) | the invoice document is the unit |
75
77
  | a multi-value TAG field | **`TagInput`** | a chip box, not a search input |
76
78
  | a STATUS with terminal outcomes | **disposition** (open → resolve → revise) | guides the decision |
77
79
  | FILES | **attachment field** (dropzone + grid + gallery) | add / preview / delete |
@@ -122,6 +124,20 @@ snapshot the item on Edit so Cancel reverts, or DISCARDS a freshly-added one. Du
122
124
  the destructive **Delete is `danger-secondary`**; Cancel + the Save/Edit toggle sit right. Every
123
125
  screen composes its own (~15 lines) so the preview rows + form fit the data.
124
126
 
127
+ ### Billing & invoicing — the invoice DOCUMENT is the unit (`tpl_billing`)
128
+ When charges get grouped into issuable documents (an e-invoice, a bill) and then collected, don't
129
+ split the screen into "enter fees here, issue there" — that smears one job across two places. Make
130
+ **each invoice a `Card` that holds its own editable charge lines** (amount input + payment method),
131
+ its **live total**, its **status badge** (nothing-to-bill · draft · issued + ref), and its **issue
132
+ action** in the footer. A charge never lives apart from the document it bills on. Issuing is gated
133
+ **inline, never a dead end**: when a prerequisite is missing (a bill-to tax ID, a method on a
134
+ charged line) the issue button disables with one muted line saying what's needed; the payment-method
135
+ picker turns required the instant a line carries an amount. Issuing a real e-invoice is irreversible
136
+ → confirm in a `Dialog` (stage gate). A grand-total **receipt** validates first — surface the EXACT
137
+ missing methods (`Alert.alert` listing each) rather than a vague "incomplete." A refundable
138
+ **deposit** is its own card and its own receipt — never folded into the total due. Composition over
139
+ `Card` + `NumberInput` + `Picker` + `Badge`; no new primitive.
140
+
125
141
  ### Tag field — `TagInput`, NOT `Combobox multi`
126
142
  A tag field's resting state should be a tidy CHIP BOX: `TagInput` is a bordered box of removable
127
143
  chips + an Add affordance; searching/creating happens in a POPOVER (a searchable, checkable list +
@@ -191,6 +207,17 @@ the "this is a gate" framing.
191
207
  weight="semibold"` + an optional `xs muted` subtitle.
192
208
  - **Time-constrained data gets a period filter** in the header band — `DateRangeFilterField`, never
193
209
  a static period badge. Every period-dependent number MUST follow the selection.
210
+ - **Keyboard & focus — use `tabIndex`, never `focusable`.** RN-Web's `Pressable` silently ignores
211
+ `focusable` (it writes its own `tabIndex`), so set a pressable control's tab-stop status with
212
+ `tabIndex={0 | -1}` (`focusable` only works on a plain `View`/`TextInput`). Roving widgets
213
+ (Tabs/SegmentedControl/RadioPicker) keep ONE stop at `0`, the rest `-1`. And NEVER let a FOCUSED
214
+ control unmount — a conditional pointer affordance that vanishes on use (e.g. an "apply suggested
215
+ value" pill shown only while a field is empty) must be `tabIndex={-1}`, or the browser drops focus
216
+ to `<body>` and the next Tab jumps to the page's first focusable. A mouse-opened popover trigger
217
+ (Picker/Combobox/InlineSelect/InlineDatePicker) gets no `:focus-visible` ring, so it wears
218
+ **`ACTIVE_RING`** (`control_surface.ts`, a `0 0 0 2px zinc-900` box-shadow mirroring the focus
219
+ outline) on its open state — reuse that token, don't hand-roll a thin 1px edge. Full rules + the
220
+ keyboard test: `docs/accessibility.md` → Focus & tab order.
194
221
  - **Cards are banded — and composable** (all from `@lotics/ui/card`):
195
222
  ```tsx
196
223
  <Card style={{ padding: 0 }}>
@@ -290,7 +317,9 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
290
317
  `tpl_dispatch` (capacity allocation) · `tpl_batch` (compose from parts) · `tpl_pick` (guided
291
318
  `ScanField` run) · `tpl_allocate` (`RemainderMeter` split) · `tpl_run` (preview → resolve → post).
292
319
  - **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`
320
+ transactional form: find-or-create + line items + attachment CRUD + 2-col grid) · `tpl_billing`
321
+ (charges→invoice→collect: the invoice DOCUMENT is the unit — editable charge lines + method,
322
+ live total, status, inline-gated issue; validated receipt; separate deposit) · `tpl_quick`
294
323
  (quick-capture log) · `tpl_wizard` (`Stepper` form + review) · `tpl_record` (inline edit + Stage
295
324
  disposition + `TagInput` + Documents grid; `tpl_record_plain` = card-less).
296
325
  - **Records & lookup** — `tpl_detail` (full record + tabs) · `tpl_directory` (searchable register) ·
@@ -304,7 +333,7 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
304
333
 
305
334
  text · card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
306
335
  section_heading (SectionHeading · SectionHeadingTitle — compound, owns no margin) · badge ·
307
- status_badge · button · icon_button · link_button · pill_button · tabs · segmented_control ·
336
+ status_badge · button · icon_button · link · link_button · pill_button · tabs · segmented_control ·
308
337
  picker · combobox · tag_input (TagInput — chip box + Add-popover; for tags, not Combobox multi) ·
309
338
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
310
339
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -110,6 +110,7 @@
110
110
  "./filter_pill": "./src/filter_pill.tsx",
111
111
  "./range_slider": "./src/range_slider.tsx",
112
112
  "./counter": "./src/counter.tsx",
113
+ "./link": "./src/link.tsx",
113
114
  "./link_button": "./src/link_button.tsx",
114
115
  "./sort_header": "./src/sort_header.tsx",
115
116
  "./skeleton": "./src/skeleton.tsx",
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
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
+ }
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";