@lotics/ui 26.4.1 → 27.6.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 +57 -24
- 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 +154 -14
- 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/select.tsx +21 -6
- package/src/summary_line.tsx +14 -0
- package/src/text_link.tsx +9 -5
- package/src/text_button.tsx +0 -139
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,76 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 27.4.0 — a `Select` trigger announces as `combobox`, not `button`
|
|
8
|
+
|
|
9
|
+
No API change, but the DOM and the accessibility tree changed, so **a test or script that
|
|
10
|
+
finds a Select by its button role must be updated**:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
getByRole("button", { name: "Tags" }) // BEFORE — a <button> element
|
|
14
|
+
getByRole("combobox", { name: "Tags" }) // AFTER — a <div role="combobox">
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The trigger had to stop being a `<button>` element: the sanctioned chip box
|
|
18
|
+
(`renderSelected` → `<Chip onDismiss>`) puts a remove button inside the trigger, and
|
|
19
|
+
`<button>`'s content model forbids interactive descendants — so every multi Select with
|
|
20
|
+
chips rendered invalid HTML and threw a React error on mount. `combobox` is also the role
|
|
21
|
+
the APG pattern this control already follows calls for. Focus, Enter/Space, pointer open,
|
|
22
|
+
Escape, and chip removal are unchanged.
|
|
23
|
+
|
|
24
|
+
Note for `searchable`: `OptionList`'s filter input is a combobox too, so scope such a query
|
|
25
|
+
to the trigger (or use `.first()`) rather than assuming one match per Select.
|
|
26
|
+
|
|
27
|
+
## 27.2.0 — a `LedgerGroup`'s sum moved below its rows
|
|
28
|
+
|
|
29
|
+
No API change; the rendering moved. A summed group used to print `LABEL … sum` as an eyebrow
|
|
30
|
+
ABOVE its rows and now closes them: the rows come first, then `Label sum` at `sm/medium`,
|
|
31
|
+
with no eyebrow (the label travels with the sum instead of being said twice).
|
|
32
|
+
|
|
33
|
+
**If you mix summed and unsummed groups in one `Ledger`, that now looks inconsistent** — an
|
|
34
|
+
unsummed group still captions from above, so the group names land in two different places.
|
|
35
|
+
This is the one case that wants an edit: give every group a `total`, and let a lone row stand
|
|
36
|
+
bare rather than grouping it (a one-row group's sum only restates the row). Omitting `total`
|
|
37
|
+
remains fully supported for a statement whose groups all name categories without summing them.
|
|
38
|
+
|
|
39
|
+
## 27.0.0 — `TextButton` is REMOVED; an act carries a control surface
|
|
40
|
+
|
|
41
|
+
**`@lotics/ui/text_button` is gone, with no replacement component.** It was the wrong
|
|
42
|
+
abstraction, and 25.0.0 had already retreated from it inside `ReferenceField`.
|
|
43
|
+
|
|
44
|
+
It existed to solve one real problem: a fill-less `Button`'s optical edge is its INK, inset by its
|
|
45
|
+
padding, so it reads as indented against a column of labels. But paying for that with a third rung
|
|
46
|
+
cost more than it bought — **underline came to mean two things at once**, separable only by ink
|
|
47
|
+
(blue navigates, zinc acts), so the affordance stopped answering the only question a reader has:
|
|
48
|
+
does this take me away, or does it do something here. And it duplicated the `Button` colour ladder,
|
|
49
|
+
where `muted` already IS the quiet rung.
|
|
50
|
+
|
|
51
|
+
**The new law: an ACT carries a control surface; only NAVIGATION is underlined text.** Chrome has
|
|
52
|
+
two rungs — `Button` (40px, a surface) and `InlineButton` (28px, filled, on a field's own surface).
|
|
53
|
+
`Link`/`TextLink` stay underlined and go somewhere.
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<TextButton onPress={selectAll}>Select all</TextButton> // BEFORE
|
|
57
|
+
<Button title="Select all" color="muted" onPress={selectAll} /> // AFTER
|
|
58
|
+
|
|
59
|
+
<TextButton color="danger" onPress={del}>Remove fee</TextButton> // BEFORE
|
|
60
|
+
<Button title="Remove fee" color="danger" onPress={del} /> // AFTER — rule 9's convention
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Per call site, pick by WHERE the act sits, not by how quiet you want it to look:**
|
|
64
|
+
|
|
65
|
+
- In **chrome** — a `PopoverFooter`, a heading row, a select-all band — use `Button color="muted"`.
|
|
66
|
+
Nothing there establishes a text edge, so the padding inset costs nothing. (`FilterChip`'s Clear
|
|
67
|
+
and `OptionList`'s select-all/deselect-all moved this way inside the kit.)
|
|
68
|
+
- **Destructive** — `Button color="danger"`, bottom-left after the entity's fields, per composition
|
|
69
|
+
rule 9. That is the page-wide convention a text-weight verb was quietly opting out of.
|
|
70
|
+
- **An add** — `Button` below the list it extends, per rule 8.
|
|
71
|
+
- Still tempted to align a quiet verb to a column of TEXT? **Move the act, don't restyle it.** That
|
|
72
|
+
pull is what produced the rung in the first place.
|
|
73
|
+
|
|
74
|
+
`TextLink` is unchanged. Its `onPress`-free forms — an `href` link, or a passive underlined marker
|
|
75
|
+
on a value whose press opens a peek — were never this component's job and keep working.
|
|
76
|
+
|
|
7
77
|
## 26.0.0 — a section's ADD moves to its heading row; `EmptyState` loses its verb
|
|
8
78
|
|
|
9
79
|
**`EmptyState.action` is REMOVED — no replacement on that component.** Two different things
|
package/docs/catalog.md
CHANGED
|
@@ -467,8 +467,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
467
467
|
|
|
468
468
|
- **`button`** — `Button`: the labelled action; `title` MANDATORY (it is the accessible
|
|
469
469
|
name), `color` = emphasis/risk. A fill-less `Button` shows no box, so its optical
|
|
470
|
-
edge is its INK, inset by its padding — don't align one to a column of TEXT
|
|
471
|
-
|
|
470
|
+
edge is its INK, inset by its padding — don't align one to a column of TEXT. There is
|
|
471
|
+
no text-weight rung to reach for: move the act into chrome (a `PopoverFooter`, a
|
|
472
|
+
heading row) where no text edge exists to betray, or give it the filled tier.
|
|
472
473
|
- **`icon_button`** — `IconButton`: the icon-only circular action (see
|
|
473
474
|
[Actions](#actions)).
|
|
474
475
|
- **`back_button`** — `BackButton`: the one go-back control — don't hand-roll it (an
|
|
@@ -485,18 +486,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
485
486
|
with no `href` it stays NEUTRAL: plain underlined text to wrap in your own pressable,
|
|
486
487
|
or a MARKER that a value leads somewhere (a record reference whose press opens a peek,
|
|
487
488
|
not a trip). `color` overrides either way; inherits every `Text` prop. It does NOT
|
|
488
|
-
act
|
|
489
|
-
|
|
490
|
-
"Clear", "Add stop") — the third rung of chrome after `Button` (40px surface) and
|
|
491
|
-
`InlineButton` (28px, filled, on a field's surface). **Underlined, in neutral ink**:
|
|
492
|
-
underline marks interactive, blue marks navigation — so `Link` goes somewhere and
|
|
493
|
-
this acts. Choose by what the press DOES. The underline is not optional (position is
|
|
494
|
-
a learned convention, hover is absent on touch). `TextButtonColor` is
|
|
495
|
-
`default | danger` only — `muted` would mute the thing carrying the act (use
|
|
496
|
-
`Button color="muted"`). `onPress` REQUIRED; `disabled` greys to zinc-400, drops the
|
|
497
|
-
press + wash, announces `aria-disabled`. Hover wash, focus ring and a 40px target
|
|
498
|
-
(32px box + `hitSlop`) come from `PressableHighlight` — a pressable `Text` has
|
|
499
|
-
none of them. ONE size, no icon (a verb needing a glyph is a `Button`).
|
|
489
|
+
act: an act carries a control surface (`Button`/`InlineButton`), and underlined text
|
|
490
|
+
is the NAVIGATION affordance only.
|
|
500
491
|
- **`chip`** — `Chip`: the generic pill — pressable when `onPress` (announces as a button;
|
|
501
492
|
pass `accessibilityLabel` when children aren't self-describing text) + an
|
|
502
493
|
absolutely-positioned dismiss ✕ sibling when `onDismiss` (its name = `dismissTooltip` ??
|
|
@@ -588,7 +579,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
588
579
|
also home of the shared `PickerOption` type.
|
|
589
580
|
- **`select`** — `Select`: rich/custom-rendered, single/multi, select-all, chips via
|
|
590
581
|
`renderSelected(item, { remove })` + `searchable` + `allowCustom` — the tag field is just
|
|
591
|
-
a multi Select; opens `OptionList`.
|
|
582
|
+
a multi Select; opens `OptionList`. Its trigger is `role="combobox"` (a `<div>`), never
|
|
583
|
+
`role="button"`: the chip box legitimately puts a remove button INSIDE the trigger, and a
|
|
584
|
+
real `<button>` may not contain one. **Any trigger that renders caller-supplied content
|
|
585
|
+
must not take `accessibilityRole="button"`** — react-native-web picks the element from the
|
|
586
|
+
role, and `<button>`'s content model forbids interactive descendants, so the first caller
|
|
587
|
+
to pass a chip, link, or menu makes the markup invalid. Either take a non-button role, or
|
|
588
|
+
keep the verbs OUTSIDE the pressable as siblings (`Chip`'s absolute ✕, `InlineEditView`'s
|
|
589
|
+
shell-owned `actions`).
|
|
592
590
|
- **`option_list`** — `OptionList`: the ONE shared searchable listbox body every selector
|
|
593
591
|
opens — single/multi, optional internal search, create row, keyboard + native-`<select>`
|
|
594
592
|
typeahead; host it directly in a `Popover`/`Dialog` for a command palette.
|
|
@@ -838,14 +836,44 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
838
836
|
`metric` {label,value,tone,note} pinned right, the band's ONE accent. The record's FIELDS
|
|
839
837
|
never live in the header: compose them as `DetailTable`s in the sections below. Replaces
|
|
840
838
|
hand-rolled record headers (mixed scales, several competing figures, color noise).
|
|
841
|
-
- **`ledger`** — `Ledger` + `LedgerGroup` + `LedgerRow` + `LedgerTotal` — the
|
|
842
|
-
money list:
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
839
|
+
- **`ledger`** — `Ledger` + `LedgerGroup` + `LedgerBasis` + `LedgerRow` + `LedgerTotal` — the
|
|
840
|
+
record-level money list: every figure on ONE right-aligned tabular column, `peek` turns a row
|
|
841
|
+
into a pressable door floating its particulars in an anchored popover (put links INSIDE the
|
|
842
|
+
peek — never a button in a button; `reference` is the trailing-link alternative for static
|
|
843
|
+
rows), `LedgerTotal` = the divider-set emphasized close with `zeroLabel` for settled. **`meta` is a
|
|
844
|
+
neutral QUALIFIER** (a date, a method) at caption weight — never a problem or a state: one
|
|
845
|
+
row's meta being a fact and another's a complaint is what makes a column read inconsistent,
|
|
846
|
+
and a caption is the wrong weight for something wanting action. A problem goes on the row
|
|
847
|
+
that can FIX it, and in a statement the arithmetic has usually said it already.
|
|
848
|
+
**Pick the shape by what the statement IS**, because there are two and they read differently:
|
|
849
|
+
- **`charges → total`** — `LedgerGroup`s, each **closed by its own sum** ("Charges" vs
|
|
850
|
+
"Received"), giving three ascending rungs: row `sm/regular`, subtotal `sm/medium`, total
|
|
851
|
+
`md/semibold` over its rule. A summed group's label travels WITH its sum and takes no
|
|
852
|
+
eyebrow, so its rows are met before its name and must carry the side themselves, by opposite
|
|
853
|
+
SIGNS. Two same-signed groups are two statements, not two sides; give them two `Ledger`s.
|
|
854
|
+
A `total` must equal the rows it closes — derive the rows and the sum from ONE list, never
|
|
855
|
+
from two predicates that can drift apart — and it earns its place only by adding what the
|
|
856
|
+
rows don't already say, which a one-row group's sum doesn't. **Omitting `total` is a shape,
|
|
857
|
+
not a degradation**: the label becomes a caption ABOVE the rows, for a group that names a
|
|
858
|
+
category rather than summing a side. **Don't mix the two in one `Ledger`** — the label would
|
|
859
|
+
sit above some groups and below others, reading as an accident; if any group earns a sum,
|
|
860
|
+
give every group one and let a lone row stand bare instead of grouping it.
|
|
861
|
+
- **`basis → derived → total`** — duty off a customs value, commission off gross, interest
|
|
862
|
+
off principal. The base is **`LedgerBasis`** (label + value + optional `meta` for how it was
|
|
863
|
+
arrived at), which takes the third treatment on the money column and sets its `Divider`
|
|
864
|
+
BELOW it, since a premise separates itself from what it feeds. Do not render a base as a
|
|
865
|
+
`LedgerRow` — that claims it is a peer you could add to the rows under it — and do not wrap
|
|
866
|
+
it in a one-row group. With one side there is no group at all: the levies are bare
|
|
867
|
+
`LedgerRow`s between the basis and the total.
|
|
868
|
+
Worked example: [`tpl_record`](../examples/tpl_record.tsx) § Billing — a three-sided closing
|
|
869
|
+
statement (billed, less credited, less received) exercising every row state: a `peek` door
|
|
870
|
+
whose lookup link sits INSIDE the popover, a flat `reference` on the row with nothing to
|
|
871
|
+
expand, `meta` + `success` tone on money coming back, and a one-row `Adjustments` group that
|
|
872
|
+
takes no `total`. `Received` is derived from the charges carrying a payment METHOD rather than
|
|
873
|
+
a second flag to keep in step, and the refundable deposit stays OUT because a ledger earns
|
|
874
|
+
trust by summing exactly what its label claims. `LedgerBasis` is demoed on the gallery's
|
|
875
|
+
Charts page instead — a delivery order has no base to compute from, and inventing one to
|
|
876
|
+
place a component is how invented needs start.
|
|
849
877
|
- **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
|
|
850
878
|
FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
|
|
851
879
|
like the editors above and below it), whose press opens a PEEK of that record's facts.
|
|
@@ -948,7 +976,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
948
976
|
- **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
|
|
949
977
|
facet (options, a `Slider range`, a `Counter`).
|
|
950
978
|
- **`summary_line`** — `SummaryLine`: the light inline summary of a register/list's FILTERED
|
|
951
|
-
view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band.
|
|
979
|
+
view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band. Every item is an
|
|
980
|
+
AGGREGATE over the rows in view (a count, a sum, a fill), and the strip goes with the set it
|
|
981
|
+
summarizes — including a record's child list (a consol's houses, an order's lines). **Never on
|
|
982
|
+
a record's identity band reporting that record's own state**: derived verdicts on one record
|
|
983
|
+
("documents incomplete", "2 fields need checking") are the checklist a record must not carry
|
|
984
|
+
(§`pipeline`) and restate at a distance what the sections below state in place.
|
|
952
985
|
- **`use_selection`** — `useSelection`: always-on multi-select state for a register/list —
|
|
953
986
|
the `selected` Set + `toggle`/`setAll`/`allSelected`/`indeterminate`/`count`/`clear`;
|
|
954
987
|
selectability gating stays with the caller. The checkbox-always-visible counterpart to
|
package/docs/composition.md
CHANGED
|
@@ -471,17 +471,19 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
|
|
|
471
471
|
`danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis. A
|
|
472
472
|
fill-less `Button` (`danger-secondary`, `muted`, un-colored) shows no box, so its optical edge is
|
|
473
473
|
its INK, inset by its padding: **never align one to a column of TEXT** — it reads as indented
|
|
474
|
-
against every label starting on the true edge
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
474
|
+
against every label starting on the true edge. The answer is to move the act, not to invent a
|
|
475
|
+
rung for it: a quiet verb belongs in CHROME (a `PopoverFooter`, a heading row, a select-all
|
|
476
|
+
band) where nothing establishes a text edge to betray, and a destructive one belongs where rule 9
|
|
477
|
+
already puts it. Chrome has TWO rungs: `Button` (a 40px control with a surface) →
|
|
478
|
+
`InlineButton` (28px, filled, on a field's own surface).
|
|
479
|
+
|
|
480
|
+
**AN ACT CARRIES A CONTROL; ONLY NAVIGATION IS UNDERLINED TEXT.** `Link`/`TextLink` are the
|
|
481
|
+
underlined pair and they GO somewhere; everything that acts is a `Button` or an `InlineButton`.
|
|
482
|
+
A surface-less ACT was tried as a third rung and removed: it made underline mean two things at
|
|
483
|
+
once, separable only by ink, so the affordance stopped answering the one question a reader has —
|
|
484
|
+
does this take me away, or does it do something here. It also competed with the `Button` colour
|
|
485
|
+
ladder for the same job, since `muted` already IS the quiet rung. Pick by what the press DOES,
|
|
486
|
+
and let the surface say which kind it is. Never a `muted` text action: it mutes
|
|
485
487
|
the one thing carrying the act. No
|
|
486
488
|
"success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
|
|
487
489
|
(colored `Text`), not the buttons.
|
|
@@ -711,10 +713,10 @@ selection washes).
|
|
|
711
713
|
(rose vs orange) stays on `Badge`s.
|
|
712
714
|
|
|
713
715
|
Links use `Link`/`TextLink` (blue-600), never `solid("blue")` — and blue-600 means NAVIGATION
|
|
714
|
-
specifically, not "interactive" generally
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
716
|
+
specifically, not "interactive" generally. Underlined text is the NAVIGATION affordance and nothing
|
|
717
|
+
else (see the button ladder above): an act carries a control surface. `TextLink` takes the blue only
|
|
718
|
+
when it has an `href`; as a passive marker on a value it stays neutral, because a press that opens a
|
|
719
|
+
PEEK is not a trip.
|
|
718
720
|
|
|
719
721
|
## `Badge` is for STATUS only — everything else is text
|
|
720
722
|
|
package/docs/templates.md
CHANGED
|
@@ -42,8 +42,7 @@ the package index is [../AGENTS.md](../AGENTS.md).
|
|
|
42
42
|
| A guided sequence of physical tasks (scan, confirm, next) | `tpl_pick` |
|
|
43
43
|
| Splitting one source amount across many targets | `tpl_allocate` |
|
|
44
44
|
| A record's create/edit surface — also the settings shape | `tpl_record` |
|
|
45
|
-
| A
|
|
46
|
-
| A team task board (inline-managed grouped table) | `tpl_task_board` |
|
|
45
|
+
| A surface whose SUBJECT is tasks (the canonical task-list grammar) | `tpl_task_board` |
|
|
47
46
|
| One record HANDED between desks — stages that each own their controls | `tpl_record` (its Progress section) |
|
|
48
47
|
| Financial statements | `tpl_statements` |
|
|
49
48
|
| A scoped lookup report with export | `tpl_report` |
|
|
@@ -336,25 +335,36 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
336
335
|
`DrawerFooter`. "Add fee" rides the section heading's right edge — the one place it does not
|
|
337
336
|
move as rows arrive — and is create-then-refine: a blank fee opens straight in the drawer. The
|
|
338
337
|
empty state carries no button of its own, because that one is already on screen.
|
|
339
|
-
- **Billing —
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
338
|
+
- **Billing — INLINE bands, closed by a STATEMENT.** Each invoice is a `Subsection` costing ONE
|
|
339
|
+
line of chrome (its name, its total, its issued ref) over its charge rows in a `DetailTable`;
|
|
340
|
+
the charge editors carry ghost list prices with a one-tap "Standard …" fill and a "How paid…"
|
|
341
|
+
select. Three shapes were tried. Inline bands with a heading rung, a band callout and an
|
|
342
|
+
action row each made the CHROME three times the content; a `Table` + right-docked `Drawer`
|
|
343
|
+
then hid four charge lines behind three expansions, which trades a real fault for a worse one
|
|
344
|
+
— you can no longer see what you are paying for. So the lines all stay and the chrome
|
|
345
|
+
shrank, and a per-invoice problem rides the CHARGE ROW that has it (`warning` on the line
|
|
346
|
+
missing its method), which is smaller than a callout and more precise: it names the offender
|
|
347
|
+
instead of counting offenders.
|
|
348
|
+
The section then CLOSES with a `Ledger` — the money grammar's worked example, and the answer
|
|
349
|
+
the bands cannot give: three invoice totals down the page are three facts the reader has to
|
|
350
|
+
add up, and "is anything still owed" appeared nowhere. THREE sides — `Invoiced`, a one-row
|
|
351
|
+
`Adjustments` credit, and `Received` with its rows negative and `success`-toned — so the total
|
|
352
|
+
states arithmetic rather than a difference, over a `LedgerTotal` carrying `zeroLabel="Paid in
|
|
353
|
+
full"`. Every row state is on screen: a `peek` door on the multi-line invoice (its lookup link
|
|
354
|
+
INSIDE the popover, since a button cannot hold a button), a flat `reference` on the single-line
|
|
355
|
+
one, `meta` qualifying how money came back. `Received` is DERIVED from the charges that carry a
|
|
356
|
+
payment method — not a second flag to keep in step — and the refundable deposit stays OUT,
|
|
357
|
+
because a ledger earns trust by summing exactly what its label claims. The fixture is
|
|
358
|
+
deliberately uneven (an unpriced charge, one issued invoice, a one-row group) because a tidy
|
|
359
|
+
one verifies nothing.
|
|
348
360
|
THE ACTION-GATING LAW is still the worked example: a not-ready Issue is DISABLED and the
|
|
349
|
-
reason is a co-located `Callout` at the gate's SCOPE, once — an invoice's own inconsistency
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
Issuing gates on record premises only — never silently on stage.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
total is the one that will be billed even when the press was held for a charge cell's write
|
|
357
|
-
(data_entry.md § Inline edit).
|
|
361
|
+
reason is a co-located `Callout` at the gate's SCOPE, once — an invoice's own inconsistency (a
|
|
362
|
+
charged line with no method) on that line, a broken record premise (no customer / invalid tax
|
|
363
|
+
ID) at the section top; self-evident empties stay silent, and NEVER prose beside a CTA.
|
|
364
|
+
Issuing gates on record premises only — never silently on stage. It is also the worked example
|
|
365
|
+
of **an irreversible action taking an ID, not a captured object** — the confirm re-resolves the
|
|
366
|
+
invoice from its key, so the quoted total is the one that will be billed even when the press
|
|
367
|
+
was held for a charge cell's write (data_entry.md § Inline edit).
|
|
358
368
|
- **Document set — the OUTPUT desk, the last WORK section** (the composition rules' output
|
|
359
369
|
law worked on the record surface: the top is intake, the bottom produces on demand).
|
|
360
370
|
Forms group PER PARTY on the `SubsectionStack` beat — each party a `SubsectionHeading` +
|
|
@@ -159,7 +159,7 @@ const ASSIGNEES = [...new Set(HO_SO.map((r) => r.phuTrach))].map((n) => ({ label
|
|
|
159
159
|
// Columns defined ONCE — Table renders the header from these and each
|
|
160
160
|
// TableCell takes its width/align by position; the ⋯ is the trailing gutter.
|
|
161
161
|
// `priority` = the MOBILE contract: when the container can't fit every column
|
|
162
|
-
// the highest numbers drop first (Received →
|
|
162
|
+
// the highest numbers drop first (Received → Status), the identity +
|
|
163
163
|
// Customer + Fee survive longest, and at the register floor rows STACK
|
|
164
164
|
// (label-over-value) so nothing is lost — the drawer carries full detail
|
|
165
165
|
// either way.
|
|
@@ -715,8 +715,8 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
715
715
|
<View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
|
|
716
716
|
<Text size="xs" color="muted">
|
|
717
717
|
{variant === "export"
|
|
718
|
-
? "The export case starts in Processing — details
|
|
719
|
-
: "The import case starts in Processing — details
|
|
718
|
+
? "The export case starts in Processing — details and payment live on the record itself."
|
|
719
|
+
: "The import case starts in Processing — details and payment live on the record itself."}
|
|
720
720
|
</Text>
|
|
721
721
|
<FormField label="Customer">
|
|
722
722
|
{khach === "" ? (
|
|
@@ -779,9 +779,8 @@ export function TplItemList() {
|
|
|
779
779
|
const [search, setSearch] = useState("");
|
|
780
780
|
const [page, setPage] = useState(0);
|
|
781
781
|
const [openMa, setOpenMa] = useState<string | null>(null);
|
|
782
|
-
//
|
|
783
|
-
// drawer
|
|
784
|
-
// settling the ledger in the drawer flips the row's Unpaid/Print/menu live.
|
|
782
|
+
// Paid state is LIFTED, not owned by the drawer: settling the ledger inside the
|
|
783
|
+
// drawer flips the row's Unpaid/Print/menu live, because both read one source.
|
|
785
784
|
const [paidMap, setPaidMap] = useState<Record<string, boolean>>({});
|
|
786
785
|
const daThuOf = (r: HoSo) => paidMap[r.ma] ?? r.daThu;
|
|
787
786
|
// The register rows are state: the create dialog PREPENDS a real row and
|
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";
|
|
@@ -278,9 +278,12 @@ interface Invoice {
|
|
|
278
278
|
|
|
279
279
|
const BILLING_INITIAL: Invoice[] = [
|
|
280
280
|
{
|
|
281
|
+
// Issued AND multi-line, which is the row that exercises the precedence: `peek` wins,
|
|
282
|
+
// so its lookup link renders INSIDE the popover and the flat `reference` is ignored.
|
|
283
|
+
// Storage is the mirror case (one charge, issued) and takes the flat link instead.
|
|
281
284
|
key: "delivery",
|
|
282
285
|
title: "Delivery",
|
|
283
|
-
ref: "",
|
|
286
|
+
ref: "INV-0029",
|
|
284
287
|
charges: [
|
|
285
288
|
{ key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
|
|
286
289
|
{ key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
|
|
@@ -293,13 +296,19 @@ const BILLING_INITIAL: Invoice[] = [
|
|
|
293
296
|
charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
|
|
294
297
|
},
|
|
295
298
|
{
|
|
299
|
+
// Seeded ISSUED, so the section renders both states at rest: the Re-issue path, the
|
|
300
|
+
// band's lookup link, and the ledger's trailing `reference`. A fixture where nothing
|
|
301
|
+
// has happened yet only ever exercises the first half of a flow.
|
|
296
302
|
key: "storage",
|
|
297
303
|
title: "Storage",
|
|
298
|
-
ref: "",
|
|
304
|
+
ref: "INV-0031",
|
|
299
305
|
charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
|
|
300
306
|
},
|
|
301
307
|
];
|
|
302
308
|
|
|
309
|
+
/** A settled credit against the record — the statement's third side, and its only line. */
|
|
310
|
+
const CREDIT = { key: "cn-0031", label: "Credit note CN-0031", amount: 120_000, meta: "Storage waived, 3 days" };
|
|
311
|
+
|
|
303
312
|
const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
|
|
304
313
|
const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
|
|
305
314
|
|
|
@@ -640,6 +649,38 @@ interface Stop {
|
|
|
640
649
|
* The kit draws the rail, the reorder/remove controls and the spacing; the
|
|
641
650
|
* template supplies what a stop CONTAINS and what its position MEANS.
|
|
642
651
|
*/
|
|
652
|
+
/**
|
|
653
|
+
* What sits behind ONE invoice figure in the closing statement — the charges that add up
|
|
654
|
+
* to it, and the lookup link once it is issued.
|
|
655
|
+
*
|
|
656
|
+
* The link belongs in HERE rather than beside the row: a `peek` row is already a button,
|
|
657
|
+
* and a button inside a button is invalid markup. That is also why the flat `reference`
|
|
658
|
+
* link and `peek` are alternatives, never companions — the component renders `reference`
|
|
659
|
+
* only on a row that is not a door.
|
|
660
|
+
*
|
|
661
|
+
* Unpriced charges are listed too, as "—" rather than 0: a charge nobody has priced is
|
|
662
|
+
* not a charge of nothing, and this is the popover that explains why the figure above is
|
|
663
|
+
* smaller than the invoice's line count suggests.
|
|
664
|
+
*/
|
|
665
|
+
function InvoiceParticulars({ inv }: { inv: Invoice }) {
|
|
666
|
+
return (
|
|
667
|
+
<View style={{ gap: 8, padding: 12, minWidth: 240 }}>
|
|
668
|
+
<Text size="xs" color="muted">{inv.title}</Text>
|
|
669
|
+
{inv.charges.map((c) => (
|
|
670
|
+
<View key={c.key} style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
|
|
671
|
+
<Text size="sm" style={{ flexGrow: 1, flexShrink: 1 }}>{c.label}</Text>
|
|
672
|
+
<Text size="sm" tabular color={c.amount > 0 ? undefined : "muted"}>
|
|
673
|
+
{c.amount > 0 ? formatMoney(c.amount) : "—"}
|
|
674
|
+
</Text>
|
|
675
|
+
</View>
|
|
676
|
+
))}
|
|
677
|
+
{inv.ref ? (
|
|
678
|
+
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
679
|
+
) : null}
|
|
680
|
+
</View>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
643
684
|
function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[]) => void }) {
|
|
644
685
|
const patch = (id: string, next: Partial<Stop>) =>
|
|
645
686
|
onChange(stops.map((s) => (s.id === id ? { ...s, ...next } : s)));
|
|
@@ -691,7 +732,9 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
|
|
|
691
732
|
<View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
|
|
692
733
|
{/* A stop is added BEFORE the destination — a new leg is always in the
|
|
693
734
|
middle of a journey; nobody adds one past the end. */}
|
|
694
|
-
<
|
|
735
|
+
<Button
|
|
736
|
+
title="Add stop"
|
|
737
|
+
color="secondary"
|
|
695
738
|
onPress={() =>
|
|
696
739
|
onChange([
|
|
697
740
|
...stops.slice(0, -1),
|
|
@@ -699,9 +742,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
|
|
|
699
742
|
stops[stops.length - 1],
|
|
700
743
|
])
|
|
701
744
|
}
|
|
702
|
-
|
|
703
|
-
Add stop
|
|
704
|
-
</TextButton>
|
|
745
|
+
/>
|
|
705
746
|
</View>
|
|
706
747
|
</View>
|
|
707
748
|
);
|
|
@@ -1282,6 +1323,22 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1282
1323
|
// total that will actually be billed, including a charge that landed after the press.
|
|
1283
1324
|
const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
|
|
1284
1325
|
const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
|
|
1326
|
+
// A charge carrying a METHOD has been paid — the statement below is derived from
|
|
1327
|
+
// that, not from a second "paid" flag somebody has to keep in step with it. The
|
|
1328
|
+
// record already knows; asking again is how the two answers start disagreeing.
|
|
1329
|
+
//
|
|
1330
|
+
// ONE list, and both the rows and the subtotal read it. Deriving them from two
|
|
1331
|
+
// separate predicates is how a ledger comes to disagree with itself: filter the
|
|
1332
|
+
// rows on `method && amount > 0` while summing on `method` alone and any charge
|
|
1333
|
+
// that fails only the second test lands in the total with no line to explain it.
|
|
1334
|
+
// The component cannot catch that — it renders the `total` it is handed.
|
|
1335
|
+
const receipts = invoices.flatMap((inv) =>
|
|
1336
|
+
inv.charges.filter((c) => c.method !== "" && c.amount > 0).map((c) => ({ inv, charge: c })),
|
|
1337
|
+
);
|
|
1338
|
+
const received = receipts.reduce((sum, r) => sum + r.charge.amount, 0);
|
|
1339
|
+
// Three sides now, so the total states the arithmetic rather than a difference of two:
|
|
1340
|
+
// what was billed, less what was credited, less what arrived.
|
|
1341
|
+
const outstanding = grandTotal - CREDIT.amount - received;
|
|
1285
1342
|
const allMissing = invoices.flatMap(missingMethods);
|
|
1286
1343
|
|
|
1287
1344
|
const issue = (inv: Invoice) => {
|
|
@@ -2441,12 +2498,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2441
2498
|
convention, which is the kit's. It replaces the
|
|
2442
2499
|
DrawerFooter that used to pin it.
|
|
2443
2500
|
|
|
2444
|
-
`
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2501
|
+
Solid `danger`, which is the destructive convention
|
|
2502
|
+
page-wide (composition rule 9) — not a text-weight act.
|
|
2503
|
+
A quiet destructive verb had been tried here and the
|
|
2504
|
+
surface-less rung is gone: an act now always carries a
|
|
2505
|
+
control, and only NAVIGATION is underlined text. The trash
|
|
2506
|
+
glyph stays off — "Remove fee" already names the object,
|
|
2507
|
+
which is the safeguard the icon was only decorating.
|
|
2450
2508
|
|
|
2451
2509
|
NO Divider. Every hairline a `Table` draws is
|
|
2452
2510
|
full-bleed (the row `Divider`s carry no padding, the
|
|
@@ -2457,7 +2515,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2457
2515
|
wrapper is required, not decoration: a `Button` alone
|
|
2458
2516
|
in a column View stretches to full width. */}
|
|
2459
2517
|
<View style={{ flexDirection: "row" }}>
|
|
2460
|
-
<
|
|
2518
|
+
<Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
|
|
2461
2519
|
</View>
|
|
2462
2520
|
</View>
|
|
2463
2521
|
}
|
|
@@ -2591,6 +2649,88 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2591
2649
|
})}
|
|
2592
2650
|
</SubsectionStack>
|
|
2593
2651
|
|
|
2652
|
+
{/* THE CLOSING STATEMENT — `Ledger`, the money grammar, and the answer to
|
|
2653
|
+
the one question the bands above cannot give: what does this record come
|
|
2654
|
+
to, and how much of it has arrived. Each invoice states its own total on
|
|
2655
|
+
its lead line, but three totals down the page are three facts the reader
|
|
2656
|
+
has to add up, and "is anything still owed" was nowhere on the section.
|
|
2657
|
+
|
|
2658
|
+
It is a STATEMENT, not another band: no `Subsection`, no heading rung. It
|
|
2659
|
+
closes the invoices, so it sits directly under them and above the record's
|
|
2660
|
+
own fields — a conclusion goes after what it concludes.
|
|
2661
|
+
|
|
2662
|
+
THREE sides, which is what makes the total state arithmetic rather than a
|
|
2663
|
+
difference: billed, less credited, less received. Money coming back is
|
|
2664
|
+
NEGATIVE (the component renders "− ") and `success`-toned, because a
|
|
2665
|
+
receipt and a credit are both the good outcome; `zeroLabel` gives a settled
|
|
2666
|
+
record "Paid in full" instead of a proud zero.
|
|
2667
|
+
|
|
2668
|
+
The DEPOSIT is deliberately absent. It is refundable — never part of the
|
|
2669
|
+
total to collect — so folding it in would make the closing figure answer a
|
|
2670
|
+
different question than the one it is labelled with. A ledger earns trust
|
|
2671
|
+
by summing exactly what its label claims.
|
|
2672
|
+
|
|
2673
|
+
A row opens where there is something to open, and the two ways are not
|
|
2674
|
+
interchangeable. `peek` floats an invoice's PARTICULARS — the charges behind
|
|
2675
|
+
its one figure — and the lookup link lives INSIDE that popover, never beside
|
|
2676
|
+
the trigger, because a button inside a button is invalid. `reference` is the
|
|
2677
|
+
flat alternative for a row with nothing to expand: one charge IS its own
|
|
2678
|
+
particulars, so Storage carries a trailing link instead of a door.
|
|
2679
|
+
|
|
2680
|
+
The `Adjustments` group holds ONE row and therefore no `total`: a sum over a
|
|
2681
|
+
single line states the same fact twice, at two weights. That is the honest
|
|
2682
|
+
fixture rather than a tidy one — real groups are built from data, so some
|
|
2683
|
+
arrive with one row — and it puts the grammar's open question on screen: an
|
|
2684
|
+
unsummed group captions from ABOVE while a summed one closes from below, so
|
|
2685
|
+
this statement names its groups in two places. */}
|
|
2686
|
+
<Ledger formatValue={money}>
|
|
2687
|
+
<LedgerGroup label="Invoiced" total={grandTotal}>
|
|
2688
|
+
{invoices.map((inv) => {
|
|
2689
|
+
/* NO meta on this side. It briefly carried "awaiting payment method",
|
|
2690
|
+
and that was `meta` doing two jobs: on the Received rows it holds a
|
|
2691
|
+
neutral QUALIFIER (how the money arrived), here it held a PROBLEM.
|
|
2692
|
+
Two unlike things in one treatment is what makes a caption read as
|
|
2693
|
+
inconsistent — the size was never the fault.
|
|
2694
|
+
The problem also had a home already: the charge row above carries
|
|
2695
|
+
`warning="Choose how this was paid"`, on the line with the editor that
|
|
2696
|
+
fixes it. A statement's job is the arithmetic, and the arithmetic
|
|
2697
|
+
already says it — `Outstanding` IS the unpaid money. Saying it again in
|
|
2698
|
+
weaker words adds a second voice for one fact. */
|
|
2699
|
+
/* A door where there are PARTICULARS, and the count that decides it is the
|
|
2700
|
+
charges, not the priced ones. An invoice whose second line is unpriced is
|
|
2701
|
+
exactly the row whose figure looks too small for it, which is the question
|
|
2702
|
+
the popover answers — gating on `amount > 0` would hide the door precisely
|
|
2703
|
+
when it is most useful. */
|
|
2704
|
+
return (
|
|
2705
|
+
<LedgerRow
|
|
2706
|
+
key={inv.key}
|
|
2707
|
+
label={inv.title}
|
|
2708
|
+
value={invoiceTotal(inv)}
|
|
2709
|
+
peek={inv.charges.length > 1 ? <InvoiceParticulars inv={inv} /> : undefined}
|
|
2710
|
+
reference={inv.ref ? { label: inv.ref, onPress: () => {} } : undefined}
|
|
2711
|
+
/>
|
|
2712
|
+
);
|
|
2713
|
+
})}
|
|
2714
|
+
</LedgerGroup>
|
|
2715
|
+
<LedgerGroup label="Adjustments">
|
|
2716
|
+
<LedgerRow label={CREDIT.label} meta={CREDIT.meta} value={-CREDIT.amount} tone="success" />
|
|
2717
|
+
</LedgerGroup>
|
|
2718
|
+
{received > 0 ? (
|
|
2719
|
+
<LedgerGroup label="Received" total={-received}>
|
|
2720
|
+
{receipts.map(({ inv, charge }) => (
|
|
2721
|
+
<LedgerRow
|
|
2722
|
+
key={`${inv.key}-${charge.key}`}
|
|
2723
|
+
label={charge.label}
|
|
2724
|
+
meta={METHODS.find((m) => m.value === charge.method)?.label}
|
|
2725
|
+
value={-charge.amount}
|
|
2726
|
+
tone="success"
|
|
2727
|
+
/>
|
|
2728
|
+
))}
|
|
2729
|
+
</LedgerGroup>
|
|
2730
|
+
) : null}
|
|
2731
|
+
<LedgerTotal label="Outstanding" value={outstanding} tone={outstanding > 0 ? "danger" : "default"} zeroLabel="Paid in full" />
|
|
2732
|
+
</Ledger>
|
|
2733
|
+
|
|
2594
2734
|
{/* ONE action row for the section, on the control column. The deposit
|
|
2595
2735
|
is a field of the record, not a band of its own — its receipt is a
|
|
2596
2736
|
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.6.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,
|
package/src/select.tsx
CHANGED
|
@@ -226,12 +226,27 @@ function SelectTrigger<T extends string>({
|
|
|
226
226
|
<FocusRingPressable
|
|
227
227
|
ref={ref}
|
|
228
228
|
testID={testID}
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
229
|
+
// `combobox`, NOT `button`, and the reason is structural rather than semantic.
|
|
230
|
+
// A role is what react-native-web picks the ELEMENT from, and `button` gave a real
|
|
231
|
+
// <button> — whose content model forbids interactive descendants. The sanctioned
|
|
232
|
+
// chip-box (`renderSelected` → `<Chip onDismiss>`, the reason there is no separate
|
|
233
|
+
// TagInput) puts a remove button inside the trigger, so the trigger rendered a
|
|
234
|
+
// <button> inside a <button>: invalid HTML, a React error on every mount, and a
|
|
235
|
+
// removal control of doubtful reachability. `combobox` renders a <div>, which may
|
|
236
|
+
// legally contain buttons, and it is the role the APG pattern this control already
|
|
237
|
+
// follows actually calls for (docs/accessibility.md: a popup opens on Enter/Space,
|
|
238
|
+
// never on Tab arrival). Verified on the chip demo: tab-focusable (tabindex 0),
|
|
239
|
+
// Enter opens, pointer opens, Escape closes, and the ✕ removes without opening.
|
|
240
|
+
//
|
|
241
|
+
// Without any role this Pressable would render an unfocusable <div> — out of the
|
|
242
|
+
// tab order, no keyboard activation. `aria-expanded` announces open/closed. The
|
|
243
|
+
// W3C props, NOT `accessibilityState` — this build drops the latter silently.
|
|
244
|
+
//
|
|
245
|
+
// When `searchable`, OptionList's filter input is ALSO a combobox (it owns
|
|
246
|
+
// aria-controls + aria-activedescendant, the editable half of the pattern). Two
|
|
247
|
+
// combobox-shaped controls across two focus events is redundant, not wrong — and
|
|
248
|
+
// strictly better than the invalid nesting it replaced.
|
|
249
|
+
accessibilityRole="combobox"
|
|
235
250
|
accessibilityLabel={accessibilityLabel}
|
|
236
251
|
aria-expanded={open}
|
|
237
252
|
aria-disabled={disabled}
|
package/src/summary_line.tsx
CHANGED
|
@@ -32,6 +32,20 @@ export interface SummaryLineProps {
|
|
|
32
32
|
* dashboard stat band: that's `KPIStrip` (a boxed metric grid for dashboard
|
|
33
33
|
* pages, left untouched). Here there's no card, no big numbers, no column rules
|
|
34
34
|
* — a small `Metric` value + a muted label, items wrapping, separated by space.
|
|
35
|
+
*
|
|
36
|
+
* **It summarizes a SET, and it goes with the set it summarizes.** Every item must be
|
|
37
|
+
* an aggregate over the rows in view — a count, a sum, a fill — so the strip earns its
|
|
38
|
+
* place by answering something no single row can. Nesting counts as a set: a record
|
|
39
|
+
* that gathers children (a consol of house shipments, an order of lines) may carry one
|
|
40
|
+
* above THAT list.
|
|
41
|
+
*
|
|
42
|
+
* **Never on a record's identity band, reporting that record's own state.** Derived
|
|
43
|
+
* verdicts about one record — "documents incomplete", "2 fields need checking", "no
|
|
44
|
+
* next milestone" — are a checklist, and a record does not carry a checklist
|
|
45
|
+
* (`docs/catalog.md`); work state belongs to `Pipeline`, a field's problem to a
|
|
46
|
+
* `Callout` on the field that can fix it. Such a strip also restates at a distance
|
|
47
|
+
* what the sections below state in place, so it goes stale against its own record and
|
|
48
|
+
* gives the reader two versions of one truth.
|
|
35
49
|
*/
|
|
36
50
|
export function SummaryLine(props: SummaryLineProps) {
|
|
37
51
|
const { items } = props;
|
package/src/text_link.tsx
CHANGED
|
@@ -20,11 +20,15 @@ export interface TextLinkProps extends TextProps {
|
|
|
20
20
|
* "this opens somewhere else" signal.
|
|
21
21
|
*
|
|
22
22
|
* It does NOT act. An `onPress` here used to resolve to `role="button"`, so one
|
|
23
|
-
* export was three components — a link, a button, and a passive marker — behind
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* export was three components — a link, a button, and a passive marker — behind one
|
|
24
|
+
* name, inside `OptionList`, `FilterChip` and others.
|
|
25
|
+
*
|
|
26
|
+
* The act mode briefly became its own surface-less component and is now gone
|
|
27
|
+
* entirely: it made underline mean two things separable only by ink, and it competed
|
|
28
|
+
* with the `Button` colour ladder for the job `muted` already does. **Underlined text
|
|
29
|
+
* is the NAVIGATION affordance; anything that acts carries a control surface** —
|
|
30
|
+
* `Button` in chrome, `InlineButton` on a field. A quiet verb that cannot sit against
|
|
31
|
+
* a text column belongs in chrome, not in a rung of its own.
|
|
28
32
|
*/
|
|
29
33
|
export function TextLink(props: TextLinkProps) {
|
|
30
34
|
const { icon, href, children, color, style, ...textProps } = props;
|
package/src/text_button.tsx
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
2
|
-
import { StyleSheet } from "react-native";
|
|
3
|
-
import { PressableHighlight } from "./pressable_highlight";
|
|
4
|
-
import { Text } from "./text";
|
|
5
|
-
|
|
6
|
-
/** Two only. Deliberately NOT the whole `TextColor` palette: `muted` would mute the
|
|
7
|
-
* one thing carrying the act's weight, and the rest (warning/success/inverted) name
|
|
8
|
-
* states, not verbs. Reach for `Button color="muted"` when an act must be quieter. */
|
|
9
|
-
export type TextButtonColor = "default" | "danger";
|
|
10
|
-
|
|
11
|
-
export interface TextButtonProps {
|
|
12
|
-
/** The verb, as words. */
|
|
13
|
-
children: ReactNode;
|
|
14
|
-
/** The act. REQUIRED — a text button with nothing to do is just `Text`. */
|
|
15
|
-
onPress: () => void;
|
|
16
|
-
/** `default` = neutral ink; `danger` = destructive. */
|
|
17
|
-
color?: TextButtonColor;
|
|
18
|
-
/**
|
|
19
|
-
* Nothing to act on. Pass it UNCONDITIONALLY and disable it rather than
|
|
20
|
-
* rendering the verb only once its target exists — an action that appears and
|
|
21
|
-
* disappears reflows the line it sits in, which is the one thing a control in
|
|
22
|
-
* a row of text must never do.
|
|
23
|
-
*/
|
|
24
|
-
disabled?: boolean;
|
|
25
|
-
/** Announced name, when the words alone are ambiguous ("Open" → "Open customer"). */
|
|
26
|
-
accessibilityLabel?: string;
|
|
27
|
-
/** Truncate at this many lines (a label in a narrow row). */
|
|
28
|
-
numberOfLines?: number;
|
|
29
|
-
tooltip?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* A low-chrome ACTION rendered at text weight — "Select all", "Clear",
|
|
34
|
-
* "Add stop". The third member of the action family, by CHROME:
|
|
35
|
-
* `Button` (a 40px control with a surface) → `InlineButton` (28px, filled, living
|
|
36
|
-
* on a field's own surface) → this (no surface AT REST, sitting in a line of text).
|
|
37
|
-
*
|
|
38
|
-
* UNDERLINED, in NEUTRAL ink — what Airbnb ships for a text action, and the shape
|
|
39
|
-
* that keeps this kit's own colour rule intact:
|
|
40
|
-
*
|
|
41
|
-
* underline = INTERACTIVE blue = NAVIGATION
|
|
42
|
-
* `Link`/`TextLink` blue + underline → navigates
|
|
43
|
-
* `TextButton` zinc + underline → acts
|
|
44
|
-
*
|
|
45
|
-
* The underline is the affordance and it is not optional. With no surface and no
|
|
46
|
-
* tint, nothing else says at rest that the words can be pressed: POSITION is a
|
|
47
|
-
* learned convention rather than an affordance (invisible to a first-time reader),
|
|
48
|
-
* and hover is not one either — absent on touch, and revealed only once you are
|
|
49
|
-
* already there. A tint is the other way to say it, the one Material and HIG take,
|
|
50
|
-
* but blue is spoken for here and a second accent for "actionable" would collide
|
|
51
|
-
* with the one-accent-per-purpose rule.
|
|
52
|
-
*
|
|
53
|
-
* This is also why it holds INSIDE a run of prose, where colour alone could not
|
|
54
|
-
* distinguish an embedded control at all (WCAG 1.4.1 / F73).
|
|
55
|
-
*
|
|
56
|
-
* Built on `PressableHighlight` rather than a pressable `Text`, because a `Text`
|
|
57
|
-
* with `onPress` gets NEITHER hover nor a focus ring: it renders a real
|
|
58
|
-
* `<button>` with `tabIndex=0`, `outline: none` and no box-shadow, so a keyboard
|
|
59
|
-
* user lands on it with no indication they have. A focusable control with no focus
|
|
60
|
-
* treatment is a bug, and hand-rolling hover + focus onto `Text` would duplicate
|
|
61
|
-
* machinery this already owns. The inherited hover wash is Material's state layer
|
|
62
|
-
* by another name, and it costs nothing at rest, so the chrome ladder above holds.
|
|
63
|
-
*
|
|
64
|
-
* The padding/negative-margin bleed is `Peek`'s: the wash and the focus ring need
|
|
65
|
-
* room off the glyphs, and the margins give it back so the line's layout never
|
|
66
|
-
* shifts — `OptionList` depends on that, insetting 8px so its select-all verbs
|
|
67
|
-
* line up under the option labels above.
|
|
68
|
-
*
|
|
69
|
-
* ONE SIZE (`Text`'s own `sm`) and no icon: it sits in a line of text and matches
|
|
70
|
-
* it. A verb needing an icon or a real hit target is a `Button`.
|
|
71
|
-
*
|
|
72
|
-
* It is TEXT-height, not control-height, and that is deliberate: `marginVertical`
|
|
73
|
-
* absorbs the touch box so the height IN FLOW stays 24 and dropping one into a
|
|
74
|
-
* dense band (`OptionList`'s select-all, `FilterChip`'s Clear) cannot grow that
|
|
75
|
-
* band. Do NOT match `Button`'s 40 to make a mixed row line up — that inflates
|
|
76
|
-
* every inline use and collapses the chrome ladder above, turning this into a
|
|
77
|
-
* Button without a fill. A row that mixes rungs aligns them the way such a row
|
|
78
|
-
* should anyway: `alignItems: "center"`, which lands a centred 32px box's text on
|
|
79
|
-
* the same line as a centred 40px one (measured: 0px apart in a peek footer).
|
|
80
|
-
*
|
|
81
|
-
* It sets NO `alignSelf`, so it obeys its parent — which is what a row wants, and
|
|
82
|
-
* a baked `flex-start` top-aligned it against a taller sibling and silently
|
|
83
|
-
* overrode a footer's `alignItems: center`. In a COLUMN container a `Pressable`
|
|
84
|
-
* stretches, so the hover wash would run the full width for a two-word verb: wrap
|
|
85
|
-
* it in a `flexDirection: "row"` View there, the same thing a `Button` needs.
|
|
86
|
-
*/
|
|
87
|
-
export function TextButton(props: TextButtonProps) {
|
|
88
|
-
const { children, onPress, color = "default", disabled, accessibilityLabel, numberOfLines, tooltip } = props;
|
|
89
|
-
// `TextColor` tokens throughout, no raw palette access: neutral ink IS `default`,
|
|
90
|
-
// and `zinc-400` is `Button`'s own disabled ink — an inert control should read the
|
|
91
|
-
// same whatever its chrome, where muting to zinc-600 left it looking merely quiet.
|
|
92
|
-
const ink = disabled === true ? "zinc-400" : color === "danger" ? "danger" : "default";
|
|
93
|
-
return (
|
|
94
|
-
<PressableHighlight
|
|
95
|
-
focusRing
|
|
96
|
-
accessibilityRole="button"
|
|
97
|
-
accessibilityLabel={accessibilityLabel}
|
|
98
|
-
aria-disabled={disabled === true ? true : undefined}
|
|
99
|
-
disabled={disabled}
|
|
100
|
-
tooltip={tooltip}
|
|
101
|
-
// 32px box + 4px slop = the 40px target `Peek` keeps for the same shape. A
|
|
102
|
-
// text action measured 24px on its own, which clears WCAG 2.5.8's 24×24
|
|
103
|
-
// floor and nothing else — and it sits in dense rows, exactly where a thumb
|
|
104
|
-
// needs the margin most.
|
|
105
|
-
hitSlop={4}
|
|
106
|
-
onPress={onPress}
|
|
107
|
-
// After the inherited wash, so a disabled verb does not light up under the
|
|
108
|
-
// pointer — `hovered` still fires on a disabled Pressable.
|
|
109
|
-
style={[styles.trigger, disabled === true ? styles.dead : null]}
|
|
110
|
-
>
|
|
111
|
-
<Text decoration="underline" weight="medium" color={ink} numberOfLines={numberOfLines}>
|
|
112
|
-
{children}
|
|
113
|
-
</Text>
|
|
114
|
-
</PressableHighlight>
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const styles = StyleSheet.create({
|
|
119
|
-
trigger: {
|
|
120
|
-
flexDirection: "row",
|
|
121
|
-
alignItems: "center",
|
|
122
|
-
// NO `alignSelf` here — see the prop. A baked `flex-start` is a CROSS-axis
|
|
123
|
-
// instruction, so it means "hug the words" only in a column; in a row it means
|
|
124
|
-
// top-align, and it silently overrode a footer's `alignItems: center`.
|
|
125
|
-
borderRadius: 6,
|
|
126
|
-
// Room for the wash + focus ring, given straight back as margin so adding a
|
|
127
|
-
// TextButton to a line cannot move anything around it (`Peek`'s idiom). The
|
|
128
|
-
// 32px box carries the touch target; `marginVertical` absorbs 8 of it, so the
|
|
129
|
-
// height IN FLOW stays 24 and a row's rhythm is unchanged.
|
|
130
|
-
minHeight: 32,
|
|
131
|
-
marginVertical: -4,
|
|
132
|
-
// 6 rather than `Peek`'s 8: these come in GROUPS (OptionList's select-all /
|
|
133
|
-
// deselect-all sit 16 apart), and an 8px bleed each side would leave adjacent
|
|
134
|
-
// washes touching.
|
|
135
|
-
paddingHorizontal: 6,
|
|
136
|
-
marginHorizontal: -6,
|
|
137
|
-
},
|
|
138
|
-
dead: { backgroundColor: "transparent" },
|
|
139
|
-
});
|