@lotics/ui 26.4.1 → 27.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +70 -0
- package/docs/catalog.md +64 -25
- package/docs/composition.md +17 -15
- package/docs/templates.md +30 -20
- package/examples/tpl_item_list.tsx +5 -6
- package/examples/tpl_record.tsx +159 -15
- package/examples/tpl_task_board.tsx +1 -2
- package/package.json +1 -2
- package/src/filter_chip.tsx +2 -2
- package/src/ledger.tsx +164 -27
- package/src/option_list.tsx +8 -5
- package/src/reference_field.tsx +25 -11
- package/src/select.tsx +21 -6
- package/src/summary_line.tsx +14 -0
- package/src/text_input_field.tsx +8 -1
- package/src/text_link.tsx +9 -5
- package/src/text_button.tsx +0 -139
package/examples/tpl_record.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import { FileRows } from "@lotics/ui/file_rows";
|
|
|
35
35
|
import { FileGrid } from "@lotics/ui/file_grid";
|
|
36
36
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
37
37
|
import { InlineStatic } from "@lotics/ui/inline_static";
|
|
38
|
+
import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
|
|
38
39
|
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
39
40
|
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
40
41
|
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
@@ -59,7 +60,6 @@ import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
|
59
60
|
import { ChipGroup, type ChipOption } from "@lotics/ui/chip_group";
|
|
60
61
|
import { Sequence, SequenceItem, SEQUENCE_INSET } from "@lotics/ui/sequence";
|
|
61
62
|
import { ReferenceField } from "@lotics/ui/reference_field";
|
|
62
|
-
import { TextButton } from "@lotics/ui/text_button";
|
|
63
63
|
import { InlineButton } from "@lotics/ui/inline_button";
|
|
64
64
|
import { useSelection } from "@lotics/ui/use_selection";
|
|
65
65
|
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
@@ -136,7 +136,11 @@ interface Customer {
|
|
|
136
136
|
// inline Tax ID fix-up in the Customer section.
|
|
137
137
|
const KNOWN_CUSTOMERS: Customer[] = [
|
|
138
138
|
{ id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg", address: "Ringvägen 118, 4 tr, 116 61 Stockholm, Sweden", since: "2019-03-14" },
|
|
139
|
-
|
|
139
|
+
// DELIBERATELY too long for one line. A register of tidy two-word names cannot
|
|
140
|
+
// show what a reference does when it does not fit — which is truncate in the
|
|
141
|
+
// field and wrap in the peek — so the one fixture the template opens with is the
|
|
142
|
+
// realistic worst case. Real customer names look like this.
|
|
143
|
+
{ id: "cus_02", name: "Harbor Freight Lines & Coastal Forwarding Group", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands", since: "2021-11-02" },
|
|
140
144
|
{ id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore", address: "9 Tuas Bay Walk, #03-14, Singapore 637803", since: "2023-06-19" },
|
|
141
145
|
{ id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg", address: "Grosser Grasbrook 9, 20457 Hamburg, Germany", since: "2018-01-30" },
|
|
142
146
|
{ id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp", address: "Noorderlaan 127, 2030 Antwerpen, Belgium", since: "2022-09-08" },
|
|
@@ -278,9 +282,12 @@ interface Invoice {
|
|
|
278
282
|
|
|
279
283
|
const BILLING_INITIAL: Invoice[] = [
|
|
280
284
|
{
|
|
285
|
+
// Issued AND multi-line, which is the row that exercises the precedence: `peek` wins,
|
|
286
|
+
// so its lookup link renders INSIDE the popover and the flat `reference` is ignored.
|
|
287
|
+
// Storage is the mirror case (one charge, issued) and takes the flat link instead.
|
|
281
288
|
key: "delivery",
|
|
282
289
|
title: "Delivery",
|
|
283
|
-
ref: "",
|
|
290
|
+
ref: "INV-0029",
|
|
284
291
|
charges: [
|
|
285
292
|
{ key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
|
|
286
293
|
{ key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
|
|
@@ -293,13 +300,19 @@ const BILLING_INITIAL: Invoice[] = [
|
|
|
293
300
|
charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
|
|
294
301
|
},
|
|
295
302
|
{
|
|
303
|
+
// Seeded ISSUED, so the section renders both states at rest: the Re-issue path, the
|
|
304
|
+
// band's lookup link, and the ledger's trailing `reference`. A fixture where nothing
|
|
305
|
+
// has happened yet only ever exercises the first half of a flow.
|
|
296
306
|
key: "storage",
|
|
297
307
|
title: "Storage",
|
|
298
|
-
ref: "",
|
|
308
|
+
ref: "INV-0031",
|
|
299
309
|
charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
|
|
300
310
|
},
|
|
301
311
|
];
|
|
302
312
|
|
|
313
|
+
/** A settled credit against the record — the statement's third side, and its only line. */
|
|
314
|
+
const CREDIT = { key: "cn-0031", label: "Credit note CN-0031", amount: 120_000, meta: "Storage waived, 3 days" };
|
|
315
|
+
|
|
303
316
|
const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
|
|
304
317
|
const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
|
|
305
318
|
|
|
@@ -640,6 +653,38 @@ interface Stop {
|
|
|
640
653
|
* The kit draws the rail, the reorder/remove controls and the spacing; the
|
|
641
654
|
* template supplies what a stop CONTAINS and what its position MEANS.
|
|
642
655
|
*/
|
|
656
|
+
/**
|
|
657
|
+
* What sits behind ONE invoice figure in the closing statement — the charges that add up
|
|
658
|
+
* to it, and the lookup link once it is issued.
|
|
659
|
+
*
|
|
660
|
+
* The link belongs in HERE rather than beside the row: a `peek` row is already a button,
|
|
661
|
+
* and a button inside a button is invalid markup. That is also why the flat `reference`
|
|
662
|
+
* link and `peek` are alternatives, never companions — the component renders `reference`
|
|
663
|
+
* only on a row that is not a door.
|
|
664
|
+
*
|
|
665
|
+
* Unpriced charges are listed too, as "—" rather than 0: a charge nobody has priced is
|
|
666
|
+
* not a charge of nothing, and this is the popover that explains why the figure above is
|
|
667
|
+
* smaller than the invoice's line count suggests.
|
|
668
|
+
*/
|
|
669
|
+
function InvoiceParticulars({ inv }: { inv: Invoice }) {
|
|
670
|
+
return (
|
|
671
|
+
<View style={{ gap: 8, padding: 12, minWidth: 240 }}>
|
|
672
|
+
<Text size="xs" color="muted">{inv.title}</Text>
|
|
673
|
+
{inv.charges.map((c) => (
|
|
674
|
+
<View key={c.key} style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
|
|
675
|
+
<Text size="sm" style={{ flexGrow: 1, flexShrink: 1 }}>{c.label}</Text>
|
|
676
|
+
<Text size="sm" tabular color={c.amount > 0 ? undefined : "muted"}>
|
|
677
|
+
{c.amount > 0 ? formatMoney(c.amount) : "—"}
|
|
678
|
+
</Text>
|
|
679
|
+
</View>
|
|
680
|
+
))}
|
|
681
|
+
{inv.ref ? (
|
|
682
|
+
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
683
|
+
) : null}
|
|
684
|
+
</View>
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
643
688
|
function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[]) => void }) {
|
|
644
689
|
const patch = (id: string, next: Partial<Stop>) =>
|
|
645
690
|
onChange(stops.map((s) => (s.id === id ? { ...s, ...next } : s)));
|
|
@@ -691,7 +736,9 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
|
|
|
691
736
|
<View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
|
|
692
737
|
{/* A stop is added BEFORE the destination — a new leg is always in the
|
|
693
738
|
middle of a journey; nobody adds one past the end. */}
|
|
694
|
-
<
|
|
739
|
+
<Button
|
|
740
|
+
title="Add stop"
|
|
741
|
+
color="secondary"
|
|
695
742
|
onPress={() =>
|
|
696
743
|
onChange([
|
|
697
744
|
...stops.slice(0, -1),
|
|
@@ -699,9 +746,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
|
|
|
699
746
|
stops[stops.length - 1],
|
|
700
747
|
])
|
|
701
748
|
}
|
|
702
|
-
|
|
703
|
-
Add stop
|
|
704
|
-
</TextButton>
|
|
749
|
+
/>
|
|
705
750
|
</View>
|
|
706
751
|
</View>
|
|
707
752
|
);
|
|
@@ -1282,6 +1327,22 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1282
1327
|
// total that will actually be billed, including a charge that landed after the press.
|
|
1283
1328
|
const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
|
|
1284
1329
|
const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
|
|
1330
|
+
// A charge carrying a METHOD has been paid — the statement below is derived from
|
|
1331
|
+
// that, not from a second "paid" flag somebody has to keep in step with it. The
|
|
1332
|
+
// record already knows; asking again is how the two answers start disagreeing.
|
|
1333
|
+
//
|
|
1334
|
+
// ONE list, and both the rows and the subtotal read it. Deriving them from two
|
|
1335
|
+
// separate predicates is how a ledger comes to disagree with itself: filter the
|
|
1336
|
+
// rows on `method && amount > 0` while summing on `method` alone and any charge
|
|
1337
|
+
// that fails only the second test lands in the total with no line to explain it.
|
|
1338
|
+
// The component cannot catch that — it renders the `total` it is handed.
|
|
1339
|
+
const receipts = invoices.flatMap((inv) =>
|
|
1340
|
+
inv.charges.filter((c) => c.method !== "" && c.amount > 0).map((c) => ({ inv, charge: c })),
|
|
1341
|
+
);
|
|
1342
|
+
const received = receipts.reduce((sum, r) => sum + r.charge.amount, 0);
|
|
1343
|
+
// Three sides now, so the total states the arithmetic rather than a difference of two:
|
|
1344
|
+
// what was billed, less what was credited, less what arrived.
|
|
1345
|
+
const outstanding = grandTotal - CREDIT.amount - received;
|
|
1285
1346
|
const allMissing = invoices.flatMap(missingMethods);
|
|
1286
1347
|
|
|
1287
1348
|
const issue = (inv: Invoice) => {
|
|
@@ -2441,12 +2502,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2441
2502
|
convention, which is the kit's. It replaces the
|
|
2442
2503
|
DrawerFooter that used to pin it.
|
|
2443
2504
|
|
|
2444
|
-
`
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2505
|
+
Solid `danger`, which is the destructive convention
|
|
2506
|
+
page-wide (composition rule 9) — not a text-weight act.
|
|
2507
|
+
A quiet destructive verb had been tried here and the
|
|
2508
|
+
surface-less rung is gone: an act now always carries a
|
|
2509
|
+
control, and only NAVIGATION is underlined text. The trash
|
|
2510
|
+
glyph stays off — "Remove fee" already names the object,
|
|
2511
|
+
which is the safeguard the icon was only decorating.
|
|
2450
2512
|
|
|
2451
2513
|
NO Divider. Every hairline a `Table` draws is
|
|
2452
2514
|
full-bleed (the row `Divider`s carry no padding, the
|
|
@@ -2457,7 +2519,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2457
2519
|
wrapper is required, not decoration: a `Button` alone
|
|
2458
2520
|
in a column View stretches to full width. */}
|
|
2459
2521
|
<View style={{ flexDirection: "row" }}>
|
|
2460
|
-
<
|
|
2522
|
+
<Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
|
|
2461
2523
|
</View>
|
|
2462
2524
|
</View>
|
|
2463
2525
|
}
|
|
@@ -2591,6 +2653,88 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2591
2653
|
})}
|
|
2592
2654
|
</SubsectionStack>
|
|
2593
2655
|
|
|
2656
|
+
{/* THE CLOSING STATEMENT — `Ledger`, the money grammar, and the answer to
|
|
2657
|
+
the one question the bands above cannot give: what does this record come
|
|
2658
|
+
to, and how much of it has arrived. Each invoice states its own total on
|
|
2659
|
+
its lead line, but three totals down the page are three facts the reader
|
|
2660
|
+
has to add up, and "is anything still owed" was nowhere on the section.
|
|
2661
|
+
|
|
2662
|
+
It is a STATEMENT, not another band: no `Subsection`, no heading rung. It
|
|
2663
|
+
closes the invoices, so it sits directly under them and above the record's
|
|
2664
|
+
own fields — a conclusion goes after what it concludes.
|
|
2665
|
+
|
|
2666
|
+
THREE sides, which is what makes the total state arithmetic rather than a
|
|
2667
|
+
difference: billed, less credited, less received. Money coming back is
|
|
2668
|
+
NEGATIVE (the component renders "− ") and `success`-toned, because a
|
|
2669
|
+
receipt and a credit are both the good outcome; `zeroLabel` gives a settled
|
|
2670
|
+
record "Paid in full" instead of a proud zero.
|
|
2671
|
+
|
|
2672
|
+
The DEPOSIT is deliberately absent. It is refundable — never part of the
|
|
2673
|
+
total to collect — so folding it in would make the closing figure answer a
|
|
2674
|
+
different question than the one it is labelled with. A ledger earns trust
|
|
2675
|
+
by summing exactly what its label claims.
|
|
2676
|
+
|
|
2677
|
+
A row opens where there is something to open, and the two ways are not
|
|
2678
|
+
interchangeable. `peek` floats an invoice's PARTICULARS — the charges behind
|
|
2679
|
+
its one figure — and the lookup link lives INSIDE that popover, never beside
|
|
2680
|
+
the trigger, because a button inside a button is invalid. `reference` is the
|
|
2681
|
+
flat alternative for a row with nothing to expand: one charge IS its own
|
|
2682
|
+
particulars, so Storage carries a trailing link instead of a door.
|
|
2683
|
+
|
|
2684
|
+
The `Adjustments` group holds ONE row and therefore no `total`: a sum over a
|
|
2685
|
+
single line states the same fact twice, at two weights. That is the honest
|
|
2686
|
+
fixture rather than a tidy one — real groups are built from data, so some
|
|
2687
|
+
arrive with one row — and it puts the grammar's open question on screen: an
|
|
2688
|
+
unsummed group captions from ABOVE while a summed one closes from below, so
|
|
2689
|
+
this statement names its groups in two places. */}
|
|
2690
|
+
<Ledger formatValue={money}>
|
|
2691
|
+
<LedgerGroup label="Invoiced" total={grandTotal}>
|
|
2692
|
+
{invoices.map((inv) => {
|
|
2693
|
+
/* NO meta on this side. It briefly carried "awaiting payment method",
|
|
2694
|
+
and that was `meta` doing two jobs: on the Received rows it holds a
|
|
2695
|
+
neutral QUALIFIER (how the money arrived), here it held a PROBLEM.
|
|
2696
|
+
Two unlike things in one treatment is what makes a caption read as
|
|
2697
|
+
inconsistent — the size was never the fault.
|
|
2698
|
+
The problem also had a home already: the charge row above carries
|
|
2699
|
+
`warning="Choose how this was paid"`, on the line with the editor that
|
|
2700
|
+
fixes it. A statement's job is the arithmetic, and the arithmetic
|
|
2701
|
+
already says it — `Outstanding` IS the unpaid money. Saying it again in
|
|
2702
|
+
weaker words adds a second voice for one fact. */
|
|
2703
|
+
/* A door where there are PARTICULARS, and the count that decides it is the
|
|
2704
|
+
charges, not the priced ones. An invoice whose second line is unpriced is
|
|
2705
|
+
exactly the row whose figure looks too small for it, which is the question
|
|
2706
|
+
the popover answers — gating on `amount > 0` would hide the door precisely
|
|
2707
|
+
when it is most useful. */
|
|
2708
|
+
return (
|
|
2709
|
+
<LedgerRow
|
|
2710
|
+
key={inv.key}
|
|
2711
|
+
label={inv.title}
|
|
2712
|
+
value={invoiceTotal(inv)}
|
|
2713
|
+
peek={inv.charges.length > 1 ? <InvoiceParticulars inv={inv} /> : undefined}
|
|
2714
|
+
reference={inv.ref ? { label: inv.ref, onPress: () => {} } : undefined}
|
|
2715
|
+
/>
|
|
2716
|
+
);
|
|
2717
|
+
})}
|
|
2718
|
+
</LedgerGroup>
|
|
2719
|
+
<LedgerGroup label="Adjustments">
|
|
2720
|
+
<LedgerRow label={CREDIT.label} meta={CREDIT.meta} value={-CREDIT.amount} tone="success" />
|
|
2721
|
+
</LedgerGroup>
|
|
2722
|
+
{received > 0 ? (
|
|
2723
|
+
<LedgerGroup label="Received" total={-received}>
|
|
2724
|
+
{receipts.map(({ inv, charge }) => (
|
|
2725
|
+
<LedgerRow
|
|
2726
|
+
key={`${inv.key}-${charge.key}`}
|
|
2727
|
+
label={charge.label}
|
|
2728
|
+
meta={METHODS.find((m) => m.value === charge.method)?.label}
|
|
2729
|
+
value={-charge.amount}
|
|
2730
|
+
tone="success"
|
|
2731
|
+
/>
|
|
2732
|
+
))}
|
|
2733
|
+
</LedgerGroup>
|
|
2734
|
+
) : null}
|
|
2735
|
+
<LedgerTotal label="Outstanding" value={outstanding} tone={outstanding > 0 ? "danger" : "default"} zeroLabel="Paid in full" />
|
|
2736
|
+
</Ledger>
|
|
2737
|
+
|
|
2594
2738
|
{/* ONE action row for the section, on the control column. The deposit
|
|
2595
2739
|
is a field of the record, not a band of its own — its receipt is a
|
|
2596
2740
|
second act on the same row. */}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { TextButton } from "@lotics/ui/text_button";
|
|
5
4
|
import { colors, solid, asColorName } from "@lotics/ui/colors";
|
|
6
5
|
import { Icon } from "@lotics/ui/icon";
|
|
7
6
|
import { CheckCircle } from "@lotics/ui/check_circle";
|
|
@@ -527,7 +526,7 @@ function RowActionCell({ task, result, onAttach, onApprove }: { task: Task; resu
|
|
|
527
526
|
const onPress = a.kind === "attach" ? () => onAttach(task.id) : a.kind === "approve" ? () => onApprove(task.id) : () => {};
|
|
528
527
|
return (
|
|
529
528
|
<View style={styles.action}>
|
|
530
|
-
<
|
|
529
|
+
<Button title={a.label} color="muted" onPress={onPress} />
|
|
531
530
|
</View>
|
|
532
531
|
);
|
|
533
532
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "27.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -179,7 +179,6 @@
|
|
|
179
179
|
"./counter": "./src/counter.tsx",
|
|
180
180
|
"./link": "./src/link.tsx",
|
|
181
181
|
"./reference_field": "./src/reference_field.tsx",
|
|
182
|
-
"./text_button": "./src/text_button.tsx",
|
|
183
182
|
"./text_link": "./src/text_link.tsx",
|
|
184
183
|
"./sort_header": "./src/sort_header.tsx",
|
|
185
184
|
"./skeleton": "./src/skeleton.tsx",
|
package/src/filter_chip.tsx
CHANGED
|
@@ -3,11 +3,11 @@ import { StyleSheet, View } from "react-native";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
5
|
import { colors } from "./colors";
|
|
6
|
-
import { TextButton } from "./text_button";
|
|
7
6
|
import { Chip } from "./chip";
|
|
8
7
|
import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
|
|
9
8
|
import type { PopoverSide, PopoverAlign } from "./popover";
|
|
10
9
|
import { useLoticsLocale } from "./locale";
|
|
10
|
+
import { Button } from "./button";
|
|
11
11
|
|
|
12
12
|
export interface FilterChipProps {
|
|
13
13
|
/** The dimension name — shown alone when inactive ("Owner"), prefixed when
|
|
@@ -110,7 +110,7 @@ export function FilterChip(props: FilterChipProps) {
|
|
|
110
110
|
<PopoverFooter>{footer}</PopoverFooter>
|
|
111
111
|
) : showClear ? (
|
|
112
112
|
<PopoverFooter align="start">
|
|
113
|
-
<
|
|
113
|
+
<Button title={clearLabel} color="muted" onPress={onClear} />
|
|
114
114
|
</PopoverFooter>
|
|
115
115
|
) : null}
|
|
116
116
|
</PopoverContent>
|
package/src/ledger.tsx
CHANGED
|
@@ -9,9 +9,12 @@ import { useLoticsLocale } from "./locale";
|
|
|
9
9
|
import { Text } from "./text";
|
|
10
10
|
|
|
11
11
|
// The record-level money list — a compact financial STATEMENT for one record's
|
|
12
|
-
// drawer/section: charge/receipt GROUPS
|
|
13
|
-
//
|
|
14
|
-
// emphasized total.
|
|
12
|
+
// drawer/section: charge/receipt GROUPS each closed by their own sum, every
|
|
13
|
+
// figure on ONE right-aligned tabular column, closed by a divider-set
|
|
14
|
+
// emphasized total. Three ascending rungs and no more — a row, a group's
|
|
15
|
+
// subtotal, the total — so the reader's eye lands on the conclusion.
|
|
16
|
+
//
|
|
17
|
+
// A row with `peek` is a door: pressing it floats the fee's
|
|
15
18
|
// PARTICULARS (basis, who charged it, the invoice it landed on) in an anchored
|
|
16
19
|
// popover — the list stays flat and scannable, the depth is on demand. A row
|
|
17
20
|
// with `reference` carries a trailing link instead (an issued invoice, a
|
|
@@ -63,31 +66,125 @@ export function Ledger(props: LedgerProps) {
|
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
export interface LedgerGroupProps {
|
|
66
|
-
/** The group name ("Charges", "Received")
|
|
69
|
+
/** The group name ("Charges", "Received", a category). It travels WITH the sum when
|
|
70
|
+
* there is one, and captions the rows from above when there is not. */
|
|
67
71
|
label: string;
|
|
68
|
-
/** The group's sum,
|
|
69
|
-
*
|
|
72
|
+
/** The group's sum, closing the rows BELOW it. Sign renders as "−" (pass receipts
|
|
73
|
+
* negative). It must EQUAL the rows it closes — derive the rows and the sum from one
|
|
74
|
+
* list, never from two predicates that can drift apart.
|
|
75
|
+
*
|
|
76
|
+
* Omit it for a group that only NAMES its rows: a sum earns its place by adding
|
|
77
|
+
* something the rows do not already say, which a one-row group's sum does not. */
|
|
70
78
|
total?: number;
|
|
71
79
|
children: ReactNode;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* One side of the ledger — a run of rows, closed by its sum.
|
|
84
|
+
*
|
|
85
|
+
* **The sum FOLLOWS its rows.** It sat in the header for a version, which is how a
|
|
86
|
+
* caption behaves, not how a subtotal does: every invoice, statement and receipt puts
|
|
87
|
+
* a subtotal after the lines it sums, and above them the reader either trusts a figure
|
|
88
|
+
* before seeing its parts or reads down and jumps back up to find it.
|
|
89
|
+
*
|
|
90
|
+
* It also sat at the LABEL's weight — xs muted — so the number summarising three rows
|
|
91
|
+
* came out smaller and greyer than the rows themselves. A subtotal is of the same
|
|
92
|
+
* magnitude class as its lines, so it takes their SIZE and one step of weight above
|
|
93
|
+
* them: `sm/medium` against rows at `sm/regular`, under a total at `md/semibold`. Three
|
|
94
|
+
* rungs, each one step, and the eye can rank them without reading a word.
|
|
95
|
+
*
|
|
96
|
+
* The LABEL travels with the sum rather than being repeated: a group that closes with
|
|
97
|
+
* "Invoiced 1.430.000 ₫" does not also need to announce "INVOICED" before its rows —
|
|
98
|
+
* that is the same word twice for one group. A group with no `total` therefore keeps the
|
|
99
|
+
* caption ABOVE its rows, because there is nothing for its label to travel with — and a
|
|
100
|
+
* group can legitimately have no sum: naming a category is one job, summing it is
|
|
101
|
+
* another, and a one-row group's sum only restates the row.
|
|
102
|
+
*
|
|
103
|
+
* **Do not mix the two inside one `Ledger`.** The label sits above an unsummed group and
|
|
104
|
+
* below a summed one, so a statement holding both moves its own group names around and
|
|
105
|
+
* reads as though the placement were an accident. Pick one shape per statement: if any
|
|
106
|
+
* group earns a sum, give every group one (grouping a single row is then the thing to
|
|
107
|
+
* avoid — let that row stand bare, or fold it into a neighbouring group).
|
|
108
|
+
*
|
|
109
|
+
* Naming a side at the BOTTOM means the reader meets its rows before its name, so the
|
|
110
|
+
* rows must say which side they are on by themselves — which they do when the sides
|
|
111
|
+
* carry opposite SIGNS (charges positive, receipts "−" and `tone="success"`). Two
|
|
112
|
+
* same-signed groups are not two sides of one statement; they are two statements, and
|
|
113
|
+
* they want two `Ledger`s under their own headings.
|
|
114
|
+
*/
|
|
75
115
|
export function LedgerGroup(props: LedgerGroupProps) {
|
|
76
116
|
const { label, total, children } = props;
|
|
77
117
|
const { format } = useLedger();
|
|
118
|
+
const closed = total != null;
|
|
78
119
|
return (
|
|
79
120
|
<View style={styles.group}>
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
121
|
+
{!closed ? (
|
|
122
|
+
<View style={styles.groupHead}>
|
|
123
|
+
<Text size="xs" weight="medium" color="muted" style={styles.grow}>
|
|
124
|
+
{label}
|
|
125
|
+
</Text>
|
|
126
|
+
</View>
|
|
127
|
+
) : null}
|
|
128
|
+
{children}
|
|
129
|
+
{closed ? (
|
|
130
|
+
<View style={styles.row}>
|
|
131
|
+
<Text size="sm" weight="medium" style={styles.grow}>
|
|
132
|
+
{label}
|
|
133
|
+
</Text>
|
|
134
|
+
<Text size="sm" weight="medium" tabular>
|
|
86
135
|
{signed(format, total)}
|
|
87
136
|
</Text>
|
|
88
|
-
|
|
137
|
+
</View>
|
|
138
|
+
) : null}
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface LedgerBasisProps {
|
|
144
|
+
/** What the figure IS ("Customs value (CIF)", "Gross revenue", "Principal"). */
|
|
145
|
+
label: string;
|
|
146
|
+
value: number;
|
|
147
|
+
/** How the figure was arrived at — the FX rate it was converted at, the period
|
|
148
|
+
* it covers. Not a second label: it qualifies the number, it does not name it. */
|
|
149
|
+
meta?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The figure the rows below are computed FROM — a statement's premise, not one of
|
|
154
|
+
* its lines.
|
|
155
|
+
*
|
|
156
|
+
* Some statements are not `charges → total`. Duty is `customs value → levies →
|
|
157
|
+
* payable`; commission is `gross → rates → owed`; interest is `principal → accrual
|
|
158
|
+
* → balance`. In all of them one number is the BASE and the rest are derived from
|
|
159
|
+
* it, and a ledger that can only say "row" has to lie about which is which.
|
|
160
|
+
*
|
|
161
|
+
* Rendering the base as a `LedgerRow` claims it is a peer of the levies — that you
|
|
162
|
+
* could add it to them. Putting it in a one-row `LedgerGroup` is worse: a group's
|
|
163
|
+
* sum earns its place only when it sums more than one visible row, so a single-row
|
|
164
|
+
* group states the same fact twice, at two weights, and the reader has to work out
|
|
165
|
+
* that the repetition means nothing.
|
|
166
|
+
*
|
|
167
|
+
* So it takes the THIRD treatment on the ledger's one money column — between a
|
|
168
|
+
* plain row and the total — and its `Divider` sits BELOW it, because a premise
|
|
169
|
+
* separates itself from what it feeds. (`LedgerTotal`'s rule sits above, closing
|
|
170
|
+
* what came before.) Order is the caller's: a basis first reads as "from this",
|
|
171
|
+
* which is the only order that makes the levies beneath it legible.
|
|
172
|
+
*/
|
|
173
|
+
export function LedgerBasis(props: LedgerBasisProps) {
|
|
174
|
+
const { label, value, meta } = props;
|
|
175
|
+
const { format } = useLedger();
|
|
176
|
+
return (
|
|
177
|
+
<View style={styles.basis}>
|
|
178
|
+
<View style={[styles.row, styles.baselineRow]}>
|
|
179
|
+
<View style={styles.grow}>
|
|
180
|
+
<Text size="sm" weight="medium">{label}</Text>
|
|
181
|
+
{meta != null ? <Text size="xs" color="muted">{meta}</Text> : null}
|
|
182
|
+
</View>
|
|
183
|
+
<Text size="sm" weight="medium" tabular>
|
|
184
|
+
{signed(format, value)}
|
|
185
|
+
</Text>
|
|
89
186
|
</View>
|
|
90
|
-
|
|
187
|
+
<Divider />
|
|
91
188
|
</View>
|
|
92
189
|
);
|
|
93
190
|
}
|
|
@@ -95,7 +192,18 @@ export function LedgerGroup(props: LedgerGroupProps) {
|
|
|
95
192
|
export interface LedgerRowProps {
|
|
96
193
|
/** The line's name — what was charged / received. */
|
|
97
194
|
label: string;
|
|
98
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Inline context after the label — a date, a method, a basis, a period. A neutral
|
|
197
|
+
* QUALIFIER on the figure, rendered as a caption (xs, muted) because it is
|
|
198
|
+
* subordinate to the thing it qualifies.
|
|
199
|
+
*
|
|
200
|
+
* Never a PROBLEM or a state ("awaiting approval", "missing method"). It reads
|
|
201
|
+
* inconsistent the moment one row's meta is a fact and another's is a complaint —
|
|
202
|
+
* two unlike things in one treatment — and a caption is the wrong weight for
|
|
203
|
+
* something that wants acting on. A problem belongs on the row that can FIX it (a
|
|
204
|
+
* `DetailRow`'s `warning` beside its editor), and in a statement the arithmetic has
|
|
205
|
+
* usually said it already: an unpaid line is what the outstanding total IS.
|
|
206
|
+
*/
|
|
99
207
|
meta?: string;
|
|
100
208
|
/** The amount. Negative renders "− <abs>" (a receipt). */
|
|
101
209
|
value: number;
|
|
@@ -174,7 +282,23 @@ export interface LedgerTotalProps {
|
|
|
174
282
|
zeroLabel?: string;
|
|
175
283
|
}
|
|
176
284
|
|
|
177
|
-
/**
|
|
285
|
+
/**
|
|
286
|
+
* The divider-set closing line — the ledger's ONE emphasized number.
|
|
287
|
+
*
|
|
288
|
+
* **Both sides take the same size.** The figure spent a version at `lg` against an
|
|
289
|
+
* `sm` label, which on web is 20px beside 14px (the scale steps `lg` up past 768px) —
|
|
290
|
+
* a 1.4× mismatch between two texts that are PEERS on one line, and a mismatch on one
|
|
291
|
+
* line reads as a slip however principled its reason. Raising the label to match is
|
|
292
|
+
* worse: `lg` puts it within a few px of the section heading above it and the record's
|
|
293
|
+
* conclusion starts competing with the record's structure.
|
|
294
|
+
*
|
|
295
|
+
* So the line is `md/semibold` throughout, and the emphasis comes from everything
|
|
296
|
+
* except size: it is the only line above a `Divider`, the only one at semibold, and
|
|
297
|
+
* the only one allowed a tone. Against rows at `sm/regular` and subtotals at
|
|
298
|
+
* `sm/medium` that is still a clear third rung — 2px and a weight step, plus a rule
|
|
299
|
+
* and a colour — and the ledger ends up using exactly two type sizes for its money
|
|
300
|
+
* and one for its captions.
|
|
301
|
+
*/
|
|
178
302
|
export function LedgerTotal(props: LedgerTotalProps) {
|
|
179
303
|
const { label, value, tone = "default", zeroLabel } = props;
|
|
180
304
|
const { format } = useLedger();
|
|
@@ -183,10 +307,10 @@ export function LedgerTotal(props: LedgerTotalProps) {
|
|
|
183
307
|
<View style={styles.total}>
|
|
184
308
|
<Divider />
|
|
185
309
|
<View style={styles.row}>
|
|
186
|
-
<Text size="
|
|
310
|
+
<Text size="md" weight="semibold" style={styles.grow}>
|
|
187
311
|
{label}
|
|
188
312
|
</Text>
|
|
189
|
-
<Text size="
|
|
313
|
+
<Text size="md" weight="semibold" tabular color={settled ? "success" : tone === "default" ? undefined : tone}>
|
|
190
314
|
{settled ? zeroLabel : signed(format, value)}
|
|
191
315
|
</Text>
|
|
192
316
|
</View>
|
|
@@ -199,13 +323,15 @@ const styles = StyleSheet.create({
|
|
|
199
323
|
// the money column align with the section heading and the container edges;
|
|
200
324
|
// the pressable door's wash bleeds into the gutter instead of squeezing text.
|
|
201
325
|
// Grouping reads by PROXIMITY: the gap between groups must be decisively larger
|
|
202
|
-
// than the gap inside one, or
|
|
203
|
-
//
|
|
326
|
+
// than the gap inside one, or a line floats midway and belongs to neither the
|
|
327
|
+
// group above nor the rows below. 16 between, 0 within — a row's own 28px
|
|
328
|
+
// minHeight already sets the beat inside a group, so adding a gap on top of it
|
|
329
|
+
// would loosen the group toward the between-group distance and undo the ranking.
|
|
204
330
|
ledger: { gap: 16, marginHorizontal: -8 },
|
|
205
331
|
group: { gap: 0 },
|
|
206
|
-
// EVERY line (group
|
|
207
|
-
// labels and the money column sit on one edge whether a row peeks or
|
|
208
|
-
// the pressable door's wash simply fills the same padded box.
|
|
332
|
+
// EVERY line (rows, a group's caption or sum, the basis, the total) shares the 8px
|
|
333
|
+
// text inset, so labels and the money column sit on one edge whether a row peeks or
|
|
334
|
+
// not — the pressable door's wash simply fills the same padded box.
|
|
209
335
|
row: {
|
|
210
336
|
flexDirection: "row",
|
|
211
337
|
alignItems: "center",
|
|
@@ -213,9 +339,8 @@ const styles = StyleSheet.create({
|
|
|
213
339
|
minHeight: 28,
|
|
214
340
|
paddingHorizontal: 8,
|
|
215
341
|
},
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// of sitting in a full 28px row box.
|
|
342
|
+
// An unsummed group's caption labels the rows beneath it, so it takes only the height
|
|
343
|
+
// its xs text needs — hugging what it labels instead of sitting in a full 28px row.
|
|
219
344
|
groupHead: {
|
|
220
345
|
flexDirection: "row",
|
|
221
346
|
alignItems: "center",
|
|
@@ -230,4 +355,16 @@ const styles = StyleSheet.create({
|
|
|
230
355
|
grow: { flexGrow: 1, flexShrink: 1 },
|
|
231
356
|
shrink: { flexShrink: 1 },
|
|
232
357
|
total: { gap: 6 },
|
|
358
|
+
// The BASIS row, the only one whose two sides can differ in height: its label may
|
|
359
|
+
// carry a stacked `meta` while the figure stays one line. Centring then measures the
|
|
360
|
+
// figure against the whole block and lands it in the gap between the label and its
|
|
361
|
+
// caption instead of level with the label it belongs to. On a shared baseline the
|
|
362
|
+
// figure sits on the label's FIRST line — the law `DetailRow` already follows for a
|
|
363
|
+
// wrapped label, and the alignment `KPICard`, `RemainderMeter`, `ProgressBar` and
|
|
364
|
+
// `Funnel` already use wherever a figure meets smaller text on one row. Every other
|
|
365
|
+
// line here is same-size on both sides, where centring and baseline agree.
|
|
366
|
+
baselineRow: { alignItems: "baseline" },
|
|
367
|
+
// Mirrors `total`'s gap so the premise and the conclusion frame the levies with
|
|
368
|
+
// the same air — the rule just sits on the other side.
|
|
369
|
+
basis: { gap: 6 },
|
|
233
370
|
});
|
package/src/option_list.tsx
CHANGED
|
@@ -7,7 +7,6 @@ import { Checkbox } from "./checkbox";
|
|
|
7
7
|
import { MenuButton } from "./menu_button";
|
|
8
8
|
import { MenuListItem } from "./menu_list_item";
|
|
9
9
|
import { Button } from "./button";
|
|
10
|
-
import { TextButton } from "./text_button";
|
|
11
10
|
import { ActivityIndicator } from "./activity_indicator";
|
|
12
11
|
import { TextInputField } from "./text_input_field";
|
|
13
12
|
import { useScreenSize } from "./use_screen_size";
|
|
@@ -176,9 +175,9 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
176
175
|
|
|
177
176
|
{list.showSelectAll || list.showDeselectAll ? (
|
|
178
177
|
<View style={styles.selectAllContainer}>
|
|
179
|
-
{list.showSelectAll ? <
|
|
178
|
+
{list.showSelectAll ? <Button title={selectAllLabel} color="muted" onPress={list.selectAll} /> : null}
|
|
180
179
|
{list.showDeselectAll ? (
|
|
181
|
-
<
|
|
180
|
+
<Button title={deselectAllLabel} color="muted" onPress={list.deselectAll} />
|
|
182
181
|
) : null}
|
|
183
182
|
</View>
|
|
184
183
|
) : null}
|
|
@@ -200,8 +199,12 @@ const styles = StyleSheet.create({
|
|
|
200
199
|
alignItems: "center",
|
|
201
200
|
gap: 16,
|
|
202
201
|
paddingVertical: 4,
|
|
203
|
-
// Matches a MenuButton option's 8px
|
|
204
|
-
//
|
|
202
|
+
// Matches a MenuButton option's 8px inset, so the Button's SURFACE starts on the
|
|
203
|
+
// same edge as the option rows above it. Not its ink: a Button's ink sits 10px
|
|
204
|
+
// further in (measured 458 against option labels at 480, which begin after the
|
|
205
|
+
// checkbox), and no inset here makes text-to-text alignment possible in a list
|
|
206
|
+
// whose rows lead with a control. The surface is the edge a surfaced control
|
|
207
|
+
// aligns on.
|
|
205
208
|
paddingHorizontal: 8,
|
|
206
209
|
borderTopWidth: 1,
|
|
207
210
|
borderTopColor: colors.border,
|