@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 +32 -3
- package/examples/tpl_billing.tsx +344 -0
- package/package.json +2 -1
- package/src/combobox.tsx +11 -2
- package/src/control_surface.ts +8 -0
- package/src/index.css +6 -3
- package/src/inline_date_picker.tsx +1 -0
- package/src/inline_edit.tsx +12 -2
- package/src/inline_select.tsx +1 -0
- package/src/link.tsx +32 -0
- package/src/picker.tsx +4 -1
- package/src/popover.tsx +10 -1
- package/src/pressable_row.tsx +4 -1
- package/src/radio_picker.tsx +3 -1
- package/src/segmented_control.tsx +3 -2
- package/src/tabs.tsx +4 -2
- package/src/text.tsx +1 -1
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) · `
|
|
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í & 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.
|
|
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={
|
|
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,
|
package/src/control_surface.ts
CHANGED
|
@@ -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
|
-
/*
|
|
340
|
-
|
|
341
|
-
|
|
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);
|
package/src/inline_edit.tsx
CHANGED
|
@@ -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" },
|
package/src/inline_select.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/src/pressable_row.tsx
CHANGED
|
@@ -55,7 +55,10 @@ export function PressableRow(props: PressableRowProps) {
|
|
|
55
55
|
return (
|
|
56
56
|
<Pressable
|
|
57
57
|
onPress={onPress}
|
|
58
|
-
focusable
|
|
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,
|
package/src/radio_picker.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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";
|