@lotics/ui 34.0.0 → 36.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/MIGRATION.md +83 -0
- package/docs/catalog.md +16 -4
- package/docs/composition.md +52 -0
- package/examples/tpl_item_list.tsx +17 -3
- package/examples/tpl_record.tsx +7 -2
- package/package.json +1 -1
- package/src/accordion.tsx +2 -1
- package/src/agent_progress.tsx +2 -1
- package/src/allocation_row.tsx +5 -3
- package/src/breakdown.tsx +4 -2
- package/src/checklist.tsx +6 -2
- package/src/chip_group.tsx +2 -1
- package/src/column_filter.tsx +4 -2
- package/src/comments_button.tsx +17 -3
- package/src/counter.tsx +6 -3
- package/src/file_row.tsx +3 -1
- package/src/filter_chip.tsx +36 -22
- package/src/kpi_card.tsx +5 -1
- package/src/locale.tsx +69 -8
- package/src/peek.tsx +2 -1
- package/src/pressable_highlight.tsx +17 -6
- package/src/scroll_to_bottom.tsx +2 -1
- package/src/sources.tsx +4 -2
- package/src/status_grid.tsx +2 -1
- package/src/summary_line.tsx +3 -1
- package/src/switch_button.tsx +2 -1
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
18
|
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows, master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
21
21
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
|
|
22
22
|
|
|
23
23
|
## Iron rules
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,89 @@ 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
|
+
## 36.0.0 — seven components stopped announcing English; their strings moved to the pack
|
|
8
|
+
|
|
9
|
+
**Nothing to change if you pass a shipped pack** (`en` / `vi`) — both carry every
|
|
10
|
+
new entry, and no component API changed. A HAND-BUILT pack must add them:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
commentsButton: { comment: "bình luận", comments: "bình luận",
|
|
14
|
+
withSubject: (counted, subject) => `${counted} về ${subject}` },
|
|
15
|
+
infoPopover: { more: "Thông tin thêm", about: (label) => `Thông tin về ${label}` },
|
|
16
|
+
fileRow: { open: (name) => `Mở ${name}` },
|
|
17
|
+
sources: { open: (label) => `Mở ${label}`, heading: "Nguồn" },
|
|
18
|
+
counter: { decrease: (label) => `Giảm ${label}`, increase: (label) => `Tăng ${label}` },
|
|
19
|
+
allocationRow: { allocateTo: (label) => `Phân bổ vào ${label}`,
|
|
20
|
+
due: (amount) => `còn ${amount}`, clear: "Xoá", full: "Toàn bộ" },
|
|
21
|
+
columnFilter: { min: (label) => `${label} tối thiểu`, max: (label) => `${label} tối đa` },
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Why.** These components built user-facing strings by concatenating an English
|
|
25
|
+
word onto caller data — `Open ${name}`, `About ${label}`, `${label} min`,
|
|
26
|
+
`${amount} due` — with no locale lookup at all. Most were accessible names, so a
|
|
27
|
+
Vietnamese app announced "Open hop_dong.pdf" to the one user who cannot see the
|
|
28
|
+
screen and has only the announcement; `Sources`' eyebrow and `AllocationRow`'s
|
|
29
|
+
caption and buttons were visibly English too.
|
|
30
|
+
|
|
31
|
+
`CommentsButton` is the same defect one step further along: it composed
|
|
32
|
+
`3 comments · Northwind`, and a middot is the one part of a string a screen reader
|
|
33
|
+
drops, so the mark carrying the relation never survived being read aloud.
|
|
34
|
+
|
|
35
|
+
**Every interpolated entry is a FUNCTION, not a word plus concatenation**, because
|
|
36
|
+
the added word sits in a different PLACE in different languages — `5,000,000 due`
|
|
37
|
+
against `còn 5.000.000`. A pack supplying only vocabulary would still be stuck with
|
|
38
|
+
English order. This is how every other interpolated string in a pack already works
|
|
39
|
+
(`sortHeader.sortBy`, `ledger.rowDetails`, `pagination.rangeWithTotal`).
|
|
40
|
+
|
|
41
|
+
Required rather than optional on purpose: both shipped packs are compile-forced
|
|
42
|
+
complete, and that is the mechanism that catches an untranslated string before it
|
|
43
|
+
ships.
|
|
44
|
+
|
|
45
|
+
`Checklist`'s phase toggle also lost a middot and needs nothing from you — its name
|
|
46
|
+
is now `Show Picking` rather than `Show · Picking`, verb then object.
|
|
47
|
+
|
|
48
|
+
### `PressableHighlight` now sets `accessibilityRole="button"` itself
|
|
49
|
+
|
|
50
|
+
Nothing to pass; an explicit `accessibilityRole` still wins. Surfaces built on it
|
|
51
|
+
that never declared a role now announce as buttons and take a tab stop, which is
|
|
52
|
+
what the component always claimed to be — the docblock says "it IS a button", and
|
|
53
|
+
that is the whole reason its children must be non-interactive while `PressableRow`
|
|
54
|
+
stays role-less. In this package 18 of 35 call sites passed the role by hand and
|
|
55
|
+
every one passed `"button"`; the other 17 rendered a surface a screen reader could
|
|
56
|
+
name but not identify as pressable.
|
|
57
|
+
|
|
58
|
+
If a surface of yours is genuinely not a button, say so (`accessibilityRole="link"`,
|
|
59
|
+
…) — or reach for `PressableRow`, which carries no role by design.
|
|
60
|
+
|
|
61
|
+
## 35.0.0 — `FilterChip` has no Clear footer; the × is the clear
|
|
62
|
+
|
|
63
|
+
`FilterChip` no longer renders a Clear button in its popover footer. **Nothing to
|
|
64
|
+
change at a call site** — `onClear` and `clearLabel` are unchanged, and the × on
|
|
65
|
+
the pill still clears, still carries `clearLabel` as its tooltip, and still
|
|
66
|
+
replaces the chevron whenever the chip is active.
|
|
67
|
+
|
|
68
|
+
**Why.** The pill's × and the footer button did the same thing, and the footer
|
|
69
|
+
cost a whole bordered band to say it twice. The editors this pill exists to wrap
|
|
70
|
+
bring their own bottom actions — an `OptionList` with `enableSelectAll` renders
|
|
71
|
+
"Select all / Deselect all" in a bordered band of its own — so a multi-select
|
|
72
|
+
filter ended with TWO stacked rules, the lower one holding a single button that
|
|
73
|
+
"Deselect all" directly above it already performed. Measured on a live filter:
|
|
74
|
+
bands of 49px and 47px, each with its own 1px top border.
|
|
75
|
+
|
|
76
|
+
The × is also the more reachable of the two: it is on the pill, which is on
|
|
77
|
+
screen whether or not the popover is open, while the footer button needed the
|
|
78
|
+
panel opened first.
|
|
79
|
+
|
|
80
|
+
**One behaviour change worth knowing:** passing a custom `footer` no longer
|
|
81
|
+
suppresses the ×. `showClear` used to gate both, so a Save-footer pill silently
|
|
82
|
+
lost its clear — if you were relying on that to hide the ×, pass no `onClear`
|
|
83
|
+
instead, which is what "not clearable" has always meant.
|
|
84
|
+
|
|
85
|
+
**If your filter had no other way to empty itself**, it still does: the ×. And if
|
|
86
|
+
your editor genuinely needs a clear INSIDE the panel (a range slider, a counter —
|
|
87
|
+
controls with no per-option deselect), put one in the `footer` slot, which is
|
|
88
|
+
still there and is now the only thing that draws a footer band.
|
|
89
|
+
|
|
7
90
|
## 34.0.0 — one avatar rung fits a control band
|
|
8
91
|
|
|
9
92
|
**`AVATAR_PX.md` is 28, not 36.** Nothing to change at a call site — `md` is still
|
package/docs/catalog.md
CHANGED
|
@@ -555,8 +555,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
555
555
|
aims at, and splitting them leaves half the affordance dead under the pointer. FULL INK, unlike
|
|
556
556
|
the muted values around it — everything else on a row is a fact ABOUT the record, this is people
|
|
557
557
|
talking about it and the one thing there that can be UNREAD. `subject` reaches the accessible
|
|
558
|
-
NAME only ("3 comments
|
|
559
|
-
is not) — never the visible label, which would spend row width restating whose record it is.
|
|
558
|
+
NAME only ("3 comments on Northwind Packaging" is a destination; "3 comments" on the fortieth row
|
|
559
|
+
is not) — never the visible label, which would spend row width restating whose record it is. The
|
|
560
|
+
pack's `commentsButton.withSubject` builds that phrase, so the word ORDER is the pack's to choose
|
|
561
|
+
and the join is a WORD: this string exists to be read aloud, and a screen reader drops
|
|
562
|
+
punctuation along with the relation it was carrying.
|
|
560
563
|
**Render it only when `count > 0`** — a zero on every quiet row is a column of noise that trains
|
|
561
564
|
the eye to skip exactly where the signal will appear. Pair it with a jump that LANDS on the
|
|
562
565
|
thread (`tpl_item_list` opens the record's drawer on its comments section), because a count the
|
|
@@ -634,7 +637,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
634
637
|
designing a pressable surface it doesn't cover.
|
|
635
638
|
- **`pressable_highlight`** — `PressableHighlight`: the hover-wash + keyboard-focus-ring
|
|
636
639
|
`Pressable` under `MenuButton`/`Switcher`/custom pressable surfaces; its style-fn/children
|
|
637
|
-
receive `hovered` + `focusVisible`.
|
|
640
|
+
receive `hovered` + `focusVisible`. It announces as `role="button"` by DEFAULT (pass
|
|
641
|
+
`accessibilityRole` to override), and that role is the line between it and
|
|
642
|
+
**`PressableRow`**, which stays role-less and out of the tab order: a button may not
|
|
643
|
+
contain interactive descendants, so a row carrying its own CTA / ⋯ / checkbox is a
|
|
644
|
+
`PressableRow` with the body as its door, never this. Give it an `accessibilityLabel` —
|
|
645
|
+
a button whose whole content is an icon or a chip announces as nothing otherwise.
|
|
638
646
|
- **`focus_ring_pressable`** — `FocusRingPressable`: a Pressable that rings on keyboard
|
|
639
647
|
focus; the raw-control default.
|
|
640
648
|
- **`card_select_item`** — `CardSelectItem`: a bordered, card-shaped button; `selected` =
|
|
@@ -1117,7 +1125,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1117
1125
|
`isColumnFilterActive` + `columnFilterSummary`: the typed per-column filter pill; for a
|
|
1118
1126
|
register filtering on several columns.
|
|
1119
1127
|
- **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
|
|
1120
|
-
facet (options, a `Slider range`, a `Counter`).
|
|
1128
|
+
facet (options, a `Slider range`, a `Counter`). It draws NO bottom band of its own — the ×
|
|
1129
|
+
on the pill is the clear, and the editor inside brings whatever actions it has, so a
|
|
1130
|
+
multi-select's "select all / deselect all" is the only rule in the panel. `footer` is for an
|
|
1131
|
+
editor that must COMMIT (Cancel / Save); reach for it only then, since it adds a second band
|
|
1132
|
+
under an editor that already has one.
|
|
1121
1133
|
- **`summary_line`** — `SummaryLine`: the light inline summary of a register/list's FILTERED
|
|
1122
1134
|
view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band. Every item is an
|
|
1123
1135
|
AGGREGATE over the rows in view (a count, a sum, a fill), and the strip goes with the set it
|
package/docs/composition.md
CHANGED
|
@@ -397,6 +397,58 @@ so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), n
|
|
|
397
397
|
registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
|
|
398
398
|
an empty overlay with no content to derive a name from.
|
|
399
399
|
|
|
400
|
+
**A CELL THAT DOES NOT SET THE ROW'S HEIGHT MUST NOT VARY WITH THE DATA.** A row centres its
|
|
401
|
+
cells, so such a cell's first line lands at `(rowH − cellH) / 2`: one line on some rows and two
|
|
402
|
+
on others puts that first line at two different offsets, and the column stops having a baseline.
|
|
403
|
+
Uniform ROW heights hide it completely — the rows measure identical, the content inside them does
|
|
404
|
+
not — and the drift is against the row's own centred chrome (ordinal, checkbox) as much as
|
|
405
|
+
against the neighbouring columns.
|
|
406
|
+
|
|
407
|
+
**It takes a height FLOOR to bite**, which is the part worth understanding rather than
|
|
408
|
+
memorising. A `TableRow` with `minHeight` above its tallest cell holds slack that centring then
|
|
409
|
+
distributes; without the floor the tallest cell sets the row, every cell starts at the top of its
|
|
410
|
+
own row, and nothing drifts. So the cell that IS tallest may vary freely — it just makes the rows
|
|
411
|
+
ragged instead, which is a different and far more visible problem. It is the cells living inside
|
|
412
|
+
another cell's slack that have to hold still.
|
|
413
|
+
|
|
414
|
+
**"Fixed" means the same every row, NOT one line.** A two-line cell is fine if it is two lines on
|
|
415
|
+
every row. And the cost is real: bounding a cell usually means `numberOfLines={1}` and
|
|
416
|
+
truncation, so you are buying a baseline with clipped text. Worth it for an identifier beside a
|
|
417
|
+
contact; think harder for a cell whose job is prose, where the honest answer may be to move that
|
|
418
|
+
content off the register entirely.
|
|
419
|
+
|
|
420
|
+
Two corollaries, both counter-intuitive enough to state:
|
|
421
|
+
|
|
422
|
+
- **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
|
|
423
|
+
cell's own range, not of the row's height: a 20/42 cell spreads 11px in a 52px row and 11px in
|
|
424
|
+
a 68px one. Only bounding the cell that varies removes it.
|
|
425
|
+
- **The fix is never a cross-axis knob.** `Table` has no `align` (see MIGRATION 32.0.0): topping
|
|
426
|
+
the cells leaves the chrome centred, so the row reads as scatter instead.
|
|
427
|
+
|
|
428
|
+
Bound a cell by giving every row the same content, not by hoping the data is uniform. A line that
|
|
429
|
+
appears only when its field is set is the defect; RESERVING the line is the fix — **but only when
|
|
430
|
+
the field is nearly always present.** A box reserved for a value that half the rows lack reads as
|
|
431
|
+
a column that forgot something, which is its own defect: prefer a field the rows actually carry
|
|
432
|
+
(an identifier, a contact) over one that is merely interesting. A cell may hold more than one line
|
|
433
|
+
freely when something FIXED occupies its top — a meter, a thumbnail — because the primary text
|
|
434
|
+
then still lands near the row's centre, where the chrome already is. `tpl_item_list`'s identity
|
|
435
|
+
cell is the worked example: name on top, the supporting values UNCONDITIONALLY beneath.
|
|
436
|
+
|
|
437
|
+
**A pressable CELL wears `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS`, never a hand-picked grey.**
|
|
438
|
+
Reaching a cell means crossing its row, so both wash at once — a cell painting the row's own
|
|
439
|
+
`zinc-100` vanishes under the pointer, and anything LIGHTER reads as a hole punched in the row.
|
|
440
|
+
The tokens are one step darker for exactly that reason, and they are what `IconButton`,
|
|
441
|
+
`CopyButton` and `CommentsButton` already paint, so a register whose cells use them speaks one
|
|
442
|
+
hover language. Bleed the wash outward (`marginHorizontal: -8` against its own padding) so the
|
|
443
|
+
cell's content stays on the column's edge — see `ROW_WASH_BLEED`.
|
|
444
|
+
|
|
445
|
+
**Size a flexible column by what it CARRIES, measured.** A `flex` share is a claim about content;
|
|
446
|
+
when the content shrinks — a value moves to the record, a stacked pair becomes a count — the
|
|
447
|
+
share left behind is white space, and the columns beside it pay for it. Measure the widest row's
|
|
448
|
+
content per column and set `flex` from that. A column whose cell holds a PROPORTION (a meter, a
|
|
449
|
+
bar) is the exception in one direction only: cap it, because a gauge stops reading as an
|
|
450
|
+
instrument once its track is long enough to be a rule across the row.
|
|
451
|
+
|
|
400
452
|
Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
|
|
401
453
|
`CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
|
|
402
454
|
rows `marked`, paired with a `FloatingActionBar` (its "Clear" escape is locale-resolved — pass
|
|
@@ -237,7 +237,16 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
|
|
|
237
237
|
because it is the one value copied often enough to earn one (a desk
|
|
238
238
|
pastes it into a message, a rep dials it). The verb rides the value
|
|
239
239
|
inside its own cell: the trailing gutter belongs to the ⋯, and with
|
|
240
|
-
two values in this cell it would have nothing to name.
|
|
240
|
+
two values in this cell it would have nothing to name.
|
|
241
|
+
|
|
242
|
+
UNCONDITIONAL, and that is the load-bearing part of this shape. A
|
|
243
|
+
row centres its cells, so a supporting line that appears only when
|
|
244
|
+
its field is set makes this cell two heights, and the NAME above it
|
|
245
|
+
then sits at two different offsets down the column while every row
|
|
246
|
+
still measures the same. That is why the values here are ones every
|
|
247
|
+
record carries — a key, a contact — rather than whichever field is
|
|
248
|
+
most interesting: the line has to be honest on every row to be
|
|
249
|
+
reservable at all. */}
|
|
241
250
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
|
|
242
251
|
{/* The KEY, muted, on the supporting line — at most this, never a
|
|
243
252
|
column and never the row's name.
|
|
@@ -250,8 +259,13 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
|
|
|
250
259
|
gives way, which is also the right order of loss: the phone has a
|
|
251
260
|
Copy control beside it that hands over the full value regardless. */}
|
|
252
261
|
<Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
|
|
253
|
-
|
|
254
|
-
|
|
262
|
+
{/* A GAP, not a middot. Two facts of different kinds sat either side
|
|
263
|
+
of a ` · ` here, which is the separator this kit bans outright —
|
|
264
|
+
punctuation claiming a relation it refuses to name, and dropped
|
|
265
|
+
entirely by a screen reader. Spacing separates them without
|
|
266
|
+
asserting anything, and the two are already told apart by shape:
|
|
267
|
+
one is a key, one is a number with a Copy control on it. */}
|
|
268
|
+
<Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
|
|
255
269
|
<CopyButton value={hs.dienThoai} label="Copy phone number" />
|
|
256
270
|
</View>
|
|
257
271
|
</View>
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -2131,10 +2131,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2131
2131
|
"the Harbor Freight order", never "RC-2026-0418" — the code is how
|
|
2132
2132
|
the SYSTEM refers to the record, which makes it a supporting value.
|
|
2133
2133
|
The register's identity cell makes the same trade, so the two
|
|
2134
|
-
surfaces name a record the same way.
|
|
2134
|
+
surfaces name a record the same way.
|
|
2135
|
+
|
|
2136
|
+
A COMMA joins them, never a middot — two identifying facts read as
|
|
2137
|
+
a list, and the kit bans the glyph outright (composition.md
|
|
2138
|
+
§Microcopy): it claims a relation while refusing to name it, and a
|
|
2139
|
+
screen reader drops it, taking the only thing that joined them. */}
|
|
2135
2140
|
<RecordSummary
|
|
2136
2141
|
title={customer ? customer.name : "No customer"}
|
|
2137
|
-
subtitle={[code, customer?.city].filter(Boolean).join("
|
|
2142
|
+
subtitle={[code, customer?.city].filter(Boolean).join(", ")}
|
|
2138
2143
|
/>
|
|
2139
2144
|
</View>
|
|
2140
2145
|
</View>
|
package/package.json
CHANGED
package/src/accordion.tsx
CHANGED
|
@@ -101,7 +101,8 @@ export function AccordionHeader(props: AccordionHeaderProps) {
|
|
|
101
101
|
|
|
102
102
|
return (
|
|
103
103
|
<PressableHighlight
|
|
104
|
-
focusRing
|
|
104
|
+
focusRing
|
|
105
|
+
onPress={toggle}
|
|
105
106
|
accessibilityRole="button"
|
|
106
107
|
// The W3C prop, NOT `accessibilityState` — this react-native-web build drops the
|
|
107
108
|
// latter silently, so a disclosure shipped a `role="button"` that never announced
|
package/src/agent_progress.tsx
CHANGED
|
@@ -68,7 +68,8 @@ export function AgentProgress(props: AgentProgressProps) {
|
|
|
68
68
|
) : null}
|
|
69
69
|
|
|
70
70
|
<PressableHighlight
|
|
71
|
-
focusRing
|
|
71
|
+
focusRing
|
|
72
|
+
onPress={() => setExpanded((e) => !e)}
|
|
72
73
|
style={styles.pill}
|
|
73
74
|
accessibilityLabel={expanded ? "Hide the agent's steps" : "Show the agent's steps"}
|
|
74
75
|
>
|
package/src/allocation_row.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { View } from "react-native";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Button } from "./button";
|
|
5
5
|
import { NumberInput } from "./number_input";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
export interface AllocationRowProps {
|
|
8
9
|
/** The target's primary line — an invoice no., an order, a cost centre. */
|
|
@@ -28,6 +29,7 @@ export interface AllocationRowProps {
|
|
|
28
29
|
*/
|
|
29
30
|
export function AllocationRow(props: AllocationRowProps) {
|
|
30
31
|
const { label, sublabel, cap, value, onValueChange, format = (n) => n.toLocaleString(), trailing } = props;
|
|
32
|
+
const locale = useLoticsLocale();
|
|
31
33
|
const full = value >= cap;
|
|
32
34
|
return (
|
|
33
35
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, minHeight: 60 }}>
|
|
@@ -38,17 +40,17 @@ export function AllocationRow(props: AllocationRowProps) {
|
|
|
38
40
|
</View>
|
|
39
41
|
{sublabel ? <Text size="xs" color="muted">{sublabel}</Text> : null}
|
|
40
42
|
</View>
|
|
41
|
-
<Text size="sm" color="muted" tabular style={{ width: 128, textAlign: "right" }}>{
|
|
43
|
+
<Text size="sm" color="muted" tabular style={{ width: 128, textAlign: "right" }}>{locale.allocationRow.due(format(cap))}</Text>
|
|
42
44
|
<View style={{ width: 150 }}>
|
|
43
45
|
<NumberInput
|
|
44
46
|
value={value || null}
|
|
45
47
|
onValueChange={(n) => onValueChange(Math.max(0, Math.min(cap, n ?? 0)))}
|
|
46
48
|
min={0}
|
|
47
49
|
max={cap}
|
|
48
|
-
accessibilityLabel={
|
|
50
|
+
accessibilityLabel={locale.allocationRow.allocateTo(label)}
|
|
49
51
|
/>
|
|
50
52
|
</View>
|
|
51
|
-
<Button title={full ?
|
|
53
|
+
<Button title={full ? locale.allocationRow.clear : locale.allocationRow.full} color="muted" onPress={() => onValueChange(full ? 0 : cap)} />
|
|
52
54
|
</View>
|
|
53
55
|
);
|
|
54
56
|
}
|
package/src/breakdown.tsx
CHANGED
|
@@ -95,7 +95,8 @@ export function Breakdown(props: BreakdownProps) {
|
|
|
95
95
|
);
|
|
96
96
|
return onSelect ? (
|
|
97
97
|
<PressableHighlight
|
|
98
|
-
focusRing
|
|
98
|
+
focusRing
|
|
99
|
+
key={item.key}
|
|
99
100
|
accessibilityRole="button"
|
|
100
101
|
// The W3C prop — `accessibilityState` is dropped by this react-native-web
|
|
101
102
|
// build, and `selected` has no meaning on a `button` role regardless.
|
|
@@ -114,7 +115,8 @@ export function Breakdown(props: BreakdownProps) {
|
|
|
114
115
|
})}
|
|
115
116
|
{collapsible ? (
|
|
116
117
|
<PressableHighlight
|
|
117
|
-
|
|
118
|
+
focusRing
|
|
119
|
+
accessibilityRole="button"
|
|
118
120
|
// The W3C prop — `accessibilityState` is dropped by this react-native-web build.
|
|
119
121
|
aria-expanded={expanded}
|
|
120
122
|
accessibilityLabel={expanded ? labels.less : labels.more(hidden)}
|
package/src/checklist.tsx
CHANGED
|
@@ -165,9 +165,13 @@ export function ChecklistGroup({ title, open, onToggleOpen, labels, ...positiona
|
|
|
165
165
|
{/* A TEXT link, not a chevron: the phase is a heading rather than a row, so
|
|
166
166
|
a disclosure triangle would give it the affordance of something that
|
|
167
167
|
opens INTO a place. This only shows and hides rows already in the run,
|
|
168
|
-
and it says which in words.
|
|
168
|
+
and it says which in words.
|
|
169
|
+
|
|
170
|
+
The NAME is verb then object with nothing between them — "Show Picking"
|
|
171
|
+
is already a phrase, and the middot that stood here bought no clarity
|
|
172
|
+
while being dropped by every screen reader that read it aloud. */}
|
|
169
173
|
{onToggleOpen != null ? (
|
|
170
|
-
<Pressable onPress={onToggleOpen} accessibilityRole="button" accessibilityLabel={`${word}
|
|
174
|
+
<Pressable onPress={onToggleOpen} accessibilityRole="button" accessibilityLabel={`${word} ${title}`}>
|
|
171
175
|
<TextLink size="xs">{word}</TextLink>
|
|
172
176
|
</Pressable>
|
|
173
177
|
) : null}
|
package/src/chip_group.tsx
CHANGED
|
@@ -46,7 +46,8 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
|
|
|
46
46
|
const active = option.value === value;
|
|
47
47
|
return (
|
|
48
48
|
<PressableHighlight
|
|
49
|
-
focusRing
|
|
49
|
+
focusRing
|
|
50
|
+
key={option.value}
|
|
50
51
|
testID={option.testID}
|
|
51
52
|
onPress={() => onValueChange(option.value)}
|
|
52
53
|
accessibilityRole="button"
|
package/src/column_filter.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { NumberInput } from "./number_input";
|
|
|
5
5
|
import { OptionList } from "./option_list";
|
|
6
6
|
import { FilterChip } from "./filter_chip";
|
|
7
7
|
import type { PickerOption } from "./picker";
|
|
8
|
+
import { useLoticsLocale } from "./locale";
|
|
8
9
|
|
|
9
10
|
/** A column the picker can filter on. `type` selects the control + operators. */
|
|
10
11
|
export interface FilterableColumn {
|
|
@@ -107,6 +108,7 @@ export interface ColumnFilterProps {
|
|
|
107
108
|
*/
|
|
108
109
|
export function ColumnFilter(props: ColumnFilterProps) {
|
|
109
110
|
const { column, value, onChange, clearLabel } = props;
|
|
111
|
+
const locale = useLoticsLocale();
|
|
110
112
|
const active = isColumnFilterActive(value);
|
|
111
113
|
|
|
112
114
|
return (
|
|
@@ -129,7 +131,7 @@ export function ColumnFilter(props: ColumnFilterProps) {
|
|
|
129
131
|
) : column.type === "number" ? (
|
|
130
132
|
<View style={styles.range}>
|
|
131
133
|
<NumberInput
|
|
132
|
-
accessibilityLabel={
|
|
134
|
+
accessibilityLabel={locale.columnFilter.min(column.label)}
|
|
133
135
|
value={value?.kind === "number" ? value.min : null}
|
|
134
136
|
onValueChange={(min) =>
|
|
135
137
|
onChange({ kind: "number", min, max: value?.kind === "number" ? value.max : null })
|
|
@@ -139,7 +141,7 @@ export function ColumnFilter(props: ColumnFilterProps) {
|
|
|
139
141
|
–
|
|
140
142
|
</Text>
|
|
141
143
|
<NumberInput
|
|
142
|
-
accessibilityLabel={
|
|
144
|
+
accessibilityLabel={locale.columnFilter.max(column.label)}
|
|
143
145
|
value={value?.kind === "number" ? value.max : null}
|
|
144
146
|
onValueChange={(max) =>
|
|
145
147
|
onChange({ kind: "number", min: value?.kind === "number" ? value.min : null, max })
|
package/src/comments_button.tsx
CHANGED
|
@@ -20,8 +20,14 @@ export interface CommentsButtonProps {
|
|
|
20
20
|
/** `sm` (the register row's) or `md`. Matches `CopyButton`'s scale so the two
|
|
21
21
|
* affordances a row carries sit at one size. */
|
|
22
22
|
size?: "sm" | "md";
|
|
23
|
-
/** Override the wording; otherwise the locale's.
|
|
24
|
-
|
|
23
|
+
/** Override the wording; otherwise the locale's. `withSubject` builds the whole
|
|
24
|
+
* phrase from the counted noun and the subject, so an override changes the word
|
|
25
|
+
* ORDER too, not just the words. */
|
|
26
|
+
labels?: {
|
|
27
|
+
comments?: string;
|
|
28
|
+
comment?: string;
|
|
29
|
+
withSubject?: (counted: string, subject: string) => string;
|
|
30
|
+
};
|
|
25
31
|
disabled?: boolean;
|
|
26
32
|
testID?: string;
|
|
27
33
|
}
|
|
@@ -57,7 +63,15 @@ export function CommentsButton(props: CommentsButtonProps) {
|
|
|
57
63
|
// The subject rides the NAME, never the visible label: a row already says whose
|
|
58
64
|
// record it is, and repeating it beside the count spends the row's width on
|
|
59
65
|
// something the eye has just read.
|
|
60
|
-
|
|
66
|
+
//
|
|
67
|
+
// The PACK composes it, exactly as `sortBy` and `rowDetails` do. Two reasons,
|
|
68
|
+
// and the second is the one that outlives this component: a middot is dropped by
|
|
69
|
+
// every screen reader, so the one mark carrying "these comments are about that
|
|
70
|
+
// record" is the one part that never survives being read aloud — and the word
|
|
71
|
+
// that replaces it sits in a different PLACE in different languages, which a
|
|
72
|
+
// component concatenating in English order cannot express at all.
|
|
73
|
+
const counted = `${count} ${noun}`;
|
|
74
|
+
const name = subject ? (labels?.withSubject ?? locale.commentsButton.withSubject)(counted, subject) : counted;
|
|
61
75
|
|
|
62
76
|
return (
|
|
63
77
|
<PressableHighlight
|
package/src/counter.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { StyleSheet, View } from "react-native";
|
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
5
6
|
|
|
6
7
|
export interface CounterProps {
|
|
7
8
|
value: number;
|
|
@@ -9,7 +10,8 @@ export interface CounterProps {
|
|
|
9
10
|
min?: number;
|
|
10
11
|
max?: number;
|
|
11
12
|
step?: number;
|
|
12
|
-
/** Announced name — the
|
|
13
|
+
/** Announced name — the pack wraps it into each button's own phrase
|
|
14
|
+
* ("Decrease {label}" in English, "Giảm {label}" in Vietnamese). */
|
|
13
15
|
accessibilityLabel: string;
|
|
14
16
|
/** Render the value with units ("3 nights", "2 guests"). Default: the number. */
|
|
15
17
|
format?: (value: number) => string;
|
|
@@ -24,6 +26,7 @@ export interface CounterProps {
|
|
|
24
26
|
*/
|
|
25
27
|
export function Counter(props: CounterProps) {
|
|
26
28
|
const { value, onValueChange, min = 0, max = Infinity, step = 1, accessibilityLabel, format } = props;
|
|
29
|
+
const locale = useLoticsLocale();
|
|
27
30
|
const clamp = (n: number) => Math.min(max, Math.max(min, n));
|
|
28
31
|
|
|
29
32
|
return (
|
|
@@ -32,7 +35,7 @@ export function Counter(props: CounterProps) {
|
|
|
32
35
|
icon="minus"
|
|
33
36
|
size="md"
|
|
34
37
|
style={styles.btn}
|
|
35
|
-
accessibilityLabel={
|
|
38
|
+
accessibilityLabel={locale.counter.decrease(accessibilityLabel)}
|
|
36
39
|
disabled={value <= min}
|
|
37
40
|
onPress={() => onValueChange(clamp(value - step))}
|
|
38
41
|
/>
|
|
@@ -43,7 +46,7 @@ export function Counter(props: CounterProps) {
|
|
|
43
46
|
icon="plus"
|
|
44
47
|
size="md"
|
|
45
48
|
style={styles.btn}
|
|
46
|
-
accessibilityLabel={
|
|
49
|
+
accessibilityLabel={locale.counter.increase(accessibilityLabel)}
|
|
47
50
|
disabled={value >= max}
|
|
48
51
|
onPress={() => onValueChange(clamp(value + step))}
|
|
49
52
|
/>
|
package/src/file_row.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { colors } from "./colors";
|
|
|
5
5
|
import { FileBadge } from "./file_badge";
|
|
6
6
|
import { FOCUS_RING } from "./control_surface";
|
|
7
7
|
import { useFocusRing } from "./use_focus_ring";
|
|
8
|
+
import { useLoticsLocale } from "./locale";
|
|
8
9
|
|
|
9
10
|
export interface FileRowProps {
|
|
10
11
|
/** The file / document name — the primary line. */
|
|
@@ -76,6 +77,7 @@ export function FileRow({
|
|
|
76
77
|
leading,
|
|
77
78
|
size = "sm",
|
|
78
79
|
}: FileRowProps) {
|
|
80
|
+
const locale = useLoticsLocale();
|
|
79
81
|
const md = size === "md";
|
|
80
82
|
const [hovered, setHovered] = useState(false);
|
|
81
83
|
const [pressed, setPressed] = useState(false);
|
|
@@ -134,7 +136,7 @@ export function FileRow({
|
|
|
134
136
|
onPressIn={() => setPressed(true)}
|
|
135
137
|
onPressOut={() => setPressed(false)}
|
|
136
138
|
accessibilityRole="button"
|
|
137
|
-
accessibilityLabel={
|
|
139
|
+
accessibilityLabel={locale.fileRow.open(name)}
|
|
138
140
|
style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
139
141
|
>
|
|
140
142
|
{content}
|
package/src/filter_chip.tsx
CHANGED
|
@@ -7,7 +7,6 @@ import { Chip } from "./chip";
|
|
|
7
7
|
import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
|
|
8
8
|
import type { PopoverSide, PopoverAlign } from "./popover";
|
|
9
9
|
import { useLoticsLocale } from "./locale";
|
|
10
|
-
import { Button } from "./button";
|
|
11
10
|
|
|
12
11
|
export interface FilterChipProps {
|
|
13
12
|
/** The dimension name — shown alone when inactive ("Owner"), prefixed when
|
|
@@ -18,10 +17,12 @@ export interface FilterChipProps {
|
|
|
18
17
|
* selection RICHLY in the trigger (member avatars, colour dots). Empty /
|
|
19
18
|
* undefined renders the inactive pill (label + chevron, no clear). */
|
|
20
19
|
summary?: ReactNode;
|
|
21
|
-
/** Clears the dimension — renders the × on the pill
|
|
22
|
-
*
|
|
20
|
+
/** Clears the dimension — renders the × on the pill whenever a summary is
|
|
21
|
+
* present. The × is the ONLY clear: the editor inside brings its own bottom
|
|
22
|
+
* actions, and a second clear in the popover footer stacked a redundant band
|
|
23
|
+
* under them. */
|
|
23
24
|
onClear?: () => void;
|
|
24
|
-
/**
|
|
25
|
+
/** Tooltip on the pill's × (pass a translated string). */
|
|
25
26
|
clearLabel?: string;
|
|
26
27
|
/** The editor revealed on press — a premium primitive (`Slider range`,
|
|
27
28
|
* `Counter`, `OptionList` multi) or any composed control. `FilterChip` owns
|
|
@@ -35,8 +36,11 @@ export interface FilterChipProps {
|
|
|
35
36
|
/** Optional controlled popover state — for an editor that closes on Save. */
|
|
36
37
|
open?: boolean;
|
|
37
38
|
onOpenChange?: (open: boolean) => void;
|
|
38
|
-
/** A custom popover footer (e.g. Cancel / Save) —
|
|
39
|
-
* Lets a non-filter VALUE pill (a setting gate)
|
|
39
|
+
/** A custom popover footer (e.g. Cancel / Save) — for an editor that commits
|
|
40
|
+
* rather than applying live. Lets a non-filter VALUE pill (a setting gate)
|
|
41
|
+
* reuse the same shell. Reach for it only when the editor NEEDS a commit: an
|
|
42
|
+
* editor that already carries bottom actions of its own gets a second
|
|
43
|
+
* bordered band from this. */
|
|
40
44
|
footer?: ReactNode;
|
|
41
45
|
}
|
|
42
46
|
|
|
@@ -61,9 +65,11 @@ export function selectSummary(
|
|
|
61
65
|
* filter dimension, the sibling of `ChipGroup` (which lays ONE hot dimension's
|
|
62
66
|
* options out inline). Many dimensions stay scannable because each collapses to
|
|
63
67
|
* a single pill: inactive reads "Label ⌄", active reads "Label: summary" with an
|
|
64
|
-
* × to clear. `FilterChip` bakes the consistent chrome — the preview pill
|
|
65
|
-
* padded popover body for the composed editor
|
|
66
|
-
*
|
|
68
|
+
* × to clear. `FilterChip` bakes the consistent chrome — the preview pill and a
|
|
69
|
+
* padded popover body for the composed editor — so every filter looks and
|
|
70
|
+
* behaves the same. It adds NO bottom band of its own: the editor inside brings
|
|
71
|
+
* whatever actions it has, and a shell that also contributed one gave every
|
|
72
|
+
* multi-select filter two stacked rules. Drop a premium primitive inside
|
|
67
73
|
* (`Slider range`, `Counter`, `OptionList` multi). With `footer` (+ controlled
|
|
68
74
|
* `open`/`onOpenChange`) the same shell wraps a non-filter VALUE pill — a
|
|
69
75
|
* setting gate with Cancel/Save — keeping it on the one pill surface. The table
|
|
@@ -73,9 +79,23 @@ export function FilterChip(props: FilterChipProps) {
|
|
|
73
79
|
const { label, summary, onClear, children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
|
|
74
80
|
const clearLabel = props.clearLabel ?? useLoticsLocale().filterChip.clear;
|
|
75
81
|
const active = summary != null && (typeof summary !== "string" || summary.length > 0);
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
82
|
+
// THE × IS THE CLEAR, and it is the only one. A valued, non-clearable pill
|
|
83
|
+
// ("Target: 20") keeps its chevron.
|
|
84
|
+
//
|
|
85
|
+
// There used to be a baked Clear in the popover footer as well, and it was a
|
|
86
|
+
// duplicate that cost a whole extra band. The editors this pill is built for
|
|
87
|
+
// bring their own bottom actions — an `OptionList` with `enableSelectAll`
|
|
88
|
+
// renders "Select all / Deselect all" in a bordered band of its own — so the
|
|
89
|
+
// popover ended with TWO stacked rules whose lower one held a single button
|
|
90
|
+
// that "Deselect all" directly above it already performed. Measured on a live
|
|
91
|
+
// filter: bands at 49px and 47px, each with its own 1px top border.
|
|
92
|
+
//
|
|
93
|
+
// The × is also the more discoverable of the two: it sits on the pill, which
|
|
94
|
+
// is on screen whether or not the popover is open, and it carries `clearLabel`
|
|
95
|
+
// as its tooltip. The footer button was reachable only by opening the panel
|
|
96
|
+
// first. A custom `footer` (Cancel / Save) no longer suppresses it either —
|
|
97
|
+
// that coupling meant a Save-footer pill silently lost its clear.
|
|
98
|
+
const showClear = active && !!onClear;
|
|
79
99
|
|
|
80
100
|
// Own the open state so the editor can close itself via the render-prop
|
|
81
101
|
// `close`, while still honoring a controlled `open`/`onOpenChange` from the
|
|
@@ -112,13 +132,7 @@ export function FilterChip(props: FilterChipProps) {
|
|
|
112
132
|
</PopoverTrigger>
|
|
113
133
|
<PopoverContent style={styles.body} disableBodyScroll>
|
|
114
134
|
{typeof children === "function" ? children({ close: () => setOpen(false) }) : children}
|
|
115
|
-
{footer ?
|
|
116
|
-
<PopoverFooter>{footer}</PopoverFooter>
|
|
117
|
-
) : showClear ? (
|
|
118
|
-
<PopoverFooter align="start">
|
|
119
|
-
<Button title={clearLabel} color="muted" onPress={onClear} />
|
|
120
|
-
</PopoverFooter>
|
|
121
|
-
) : null}
|
|
135
|
+
{footer ? <PopoverFooter>{footer}</PopoverFooter> : null}
|
|
122
136
|
</PopoverContent>
|
|
123
137
|
</Popover>
|
|
124
138
|
);
|
|
@@ -126,9 +140,9 @@ export function FilterChip(props: FilterChipProps) {
|
|
|
126
140
|
|
|
127
141
|
const styles = StyleSheet.create({
|
|
128
142
|
// The popover hugs its content — a wide control (e.g. Slider) sets its
|
|
129
|
-
// OWN fixed width; the shell never forces one. No extra padding: the editor
|
|
130
|
-
//
|
|
131
|
-
//
|
|
143
|
+
// OWN fixed width; the shell never forces one. No extra padding: the editor
|
|
144
|
+
// then sits on the popover's own 8px inset, so a multi-select's options and
|
|
145
|
+
// its select-all band line up on one left edge.
|
|
132
146
|
body: {
|
|
133
147
|
gap: 8,
|
|
134
148
|
},
|
package/src/kpi_card.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Text } from "./text";
|
|
|
3
3
|
import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
|
|
4
4
|
import { TrendChip } from "./trend_chip";
|
|
5
5
|
import { InfoPopover } from "./info_popover";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
import { SPACE } from "./spacing";
|
|
7
8
|
|
|
8
9
|
interface KPICardProps {
|
|
@@ -50,13 +51,16 @@ interface KPICardProps {
|
|
|
50
51
|
*/
|
|
51
52
|
export function KPICard(props: KPICardProps) {
|
|
52
53
|
const { label, value, format, currency, locale, emptyLabel, compact, size = "lg", tone, trend, caption, info, style } = props;
|
|
54
|
+
// `pack`, not `locale` — that name is taken by the BCP-47 tag this card passes
|
|
55
|
+
// to `Metric` for number formatting, which is a different axis entirely.
|
|
56
|
+
const pack = useLoticsLocale();
|
|
53
57
|
return (
|
|
54
58
|
<View style={[styles.container, style]}>
|
|
55
59
|
<View style={styles.labelRow}>
|
|
56
60
|
<Text size="xs" color="muted" transform="uppercase">
|
|
57
61
|
{label}
|
|
58
62
|
</Text>
|
|
59
|
-
{info ? <InfoPopover text={info} accessibilityLabel={
|
|
63
|
+
{info ? <InfoPopover text={info} accessibilityLabel={pack.infoPopover.about(label)} /> : null}
|
|
60
64
|
</View>
|
|
61
65
|
<View style={styles.valueRow}>
|
|
62
66
|
<Metric
|
package/src/locale.tsx
CHANGED
|
@@ -164,8 +164,27 @@ export interface LoticsLocale {
|
|
|
164
164
|
/** `ImageGallery`: the empty state, the inline rotate controls, and the
|
|
165
165
|
* press-to-zoom a11y name. */
|
|
166
166
|
imageGallery: { empty: string; rotateLeft: string; rotateRight: string; zoom: string };
|
|
167
|
-
/** `InfoPopover`: the ⓘ trigger's a11y name.
|
|
168
|
-
|
|
167
|
+
/** `InfoPopover`: the ⓘ trigger's a11y name. `more` is the bare default;
|
|
168
|
+
* `about` names the SUBJECT, which is what a screen a11y reader needs when a
|
|
169
|
+
* page carries several ⓘ buttons and "More information" is said four times. */
|
|
170
|
+
infoPopover: { more: string; about: (label: string) => string };
|
|
171
|
+
/** `FileRow` / `Sources`: the door onto one file or one cited source. Two
|
|
172
|
+
* slices rather than one shared verb, because a pack may open a FILE and open
|
|
173
|
+
* a SOURCE with different words. */
|
|
174
|
+
fileRow: { open: (name: string) => string };
|
|
175
|
+
sources: { open: (label: string) => string; heading: string };
|
|
176
|
+
/** `Counter`: the two steppers, named for the value they move. */
|
|
177
|
+
counter: { decrease: (label: string) => string; increase: (label: string) => string };
|
|
178
|
+
/** `AllocationRow`: the a11y name of the amount input (naming its destination),
|
|
179
|
+
* the outstanding-amount caption, and the two fill/empty verbs. */
|
|
180
|
+
allocationRow: {
|
|
181
|
+
allocateTo: (label: string) => string;
|
|
182
|
+
due: (amount: string) => string;
|
|
183
|
+
clear: string;
|
|
184
|
+
full: string;
|
|
185
|
+
};
|
|
186
|
+
/** `ColumnFilter`: the two bounds of a numeric range, named for their column. */
|
|
187
|
+
columnFilter: { min: (label: string) => string; max: (label: string) => string };
|
|
169
188
|
/** `Matrix`: the total column/row header and the legend's less→more ends. */
|
|
170
189
|
matrix: { total: string; less: string; more: string };
|
|
171
190
|
/** `ScrollToBottom`: the jump-to-latest tooltip. */
|
|
@@ -209,8 +228,17 @@ export interface LoticsLocale {
|
|
|
209
228
|
* value only the call site knows, the same split `referenceField.open` draws. */
|
|
210
229
|
copyButton: { copy: string; copied: string };
|
|
211
230
|
/** `CommentsButton`'s accessible name — the kit owns the noun so one surface
|
|
212
|
-
* does not say "comments" while the next says "notes" for the same thread.
|
|
213
|
-
|
|
231
|
+
* does not say "comments" while the next says "notes" for the same thread.
|
|
232
|
+
* `withSubject` builds the WHOLE phrase, so a pack chooses the word ORDER and
|
|
233
|
+
* not merely the vocabulary: the subject leads in some languages, and a
|
|
234
|
+
* component that concatenates in English order cannot be translated into one.
|
|
235
|
+
* It joins with a WORD because this string exists to be read aloud, and a
|
|
236
|
+
* screen reader drops punctuation, taking the relation with it. */
|
|
237
|
+
commentsButton: {
|
|
238
|
+
comment: string;
|
|
239
|
+
comments: string;
|
|
240
|
+
withSubject: (counted: string, subject: string) => string;
|
|
241
|
+
};
|
|
214
242
|
}
|
|
215
243
|
|
|
216
244
|
/** The platform default — English. Every component's hardcoded default lives
|
|
@@ -329,7 +357,17 @@ export const en: LoticsLocale = {
|
|
|
329
357
|
retryAll: "Retry all",
|
|
330
358
|
},
|
|
331
359
|
imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
|
|
332
|
-
infoPopover: { more: "More information" },
|
|
360
|
+
infoPopover: { more: "More information", about: (label) => `About ${label}` },
|
|
361
|
+
fileRow: { open: (name) => `Open ${name}` },
|
|
362
|
+
sources: { open: (label) => `Open ${label}`, heading: "Sources" },
|
|
363
|
+
counter: { decrease: (label) => `Decrease ${label}`, increase: (label) => `Increase ${label}` },
|
|
364
|
+
allocationRow: {
|
|
365
|
+
allocateTo: (label) => `Allocate to ${label}`,
|
|
366
|
+
due: (amount) => `${amount} due`,
|
|
367
|
+
clear: "Clear",
|
|
368
|
+
full: "Full",
|
|
369
|
+
},
|
|
370
|
+
columnFilter: { min: (label) => `${label} min`, max: (label) => `${label} max` },
|
|
333
371
|
matrix: { total: "Total", less: "Less", more: "More" },
|
|
334
372
|
scrollToBottom: { tooltip: "Scroll to bottom" },
|
|
335
373
|
textInputField: { clear: "Clear" },
|
|
@@ -366,7 +404,11 @@ export const en: LoticsLocale = {
|
|
|
366
404
|
approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
|
|
367
405
|
messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
|
|
368
406
|
copyButton: { copy: "Copy", copied: "Copied" },
|
|
369
|
-
commentsButton: {
|
|
407
|
+
commentsButton: {
|
|
408
|
+
comment: "comment",
|
|
409
|
+
comments: "comments",
|
|
410
|
+
withSubject: (counted, subject) => `${counted} on ${subject}`,
|
|
411
|
+
},
|
|
370
412
|
};
|
|
371
413
|
|
|
372
414
|
/** Vietnamese. Maintained once here so every app (and the frontend) shares one
|
|
@@ -482,7 +524,22 @@ export const vi: LoticsLocale = {
|
|
|
482
524
|
retryAll: "Thử lại tất cả",
|
|
483
525
|
},
|
|
484
526
|
imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
|
|
485
|
-
infoPopover: { more: "Thông tin thêm" },
|
|
527
|
+
infoPopover: { more: "Thông tin thêm", about: (label) => `Thông tin về ${label}` },
|
|
528
|
+
fileRow: { open: (name) => `Mở ${name}` },
|
|
529
|
+
sources: { open: (label) => `Mở ${label}`, heading: "Nguồn" },
|
|
530
|
+
counter: { decrease: (label) => `Giảm ${label}`, increase: (label) => `Tăng ${label}` },
|
|
531
|
+
allocationRow: {
|
|
532
|
+
allocateTo: (label) => `Phân bổ vào ${label}`,
|
|
533
|
+
// The word LEADS in Vietnamese and trails in English — "còn 5.000.000 ₫"
|
|
534
|
+
// against "5,000,000 ₫ due". A pack-owned function is the only shape that
|
|
535
|
+
// expresses both.
|
|
536
|
+
due: (amount) => `còn ${amount}`,
|
|
537
|
+
clear: "Xoá",
|
|
538
|
+
full: "Toàn bộ",
|
|
539
|
+
},
|
|
540
|
+
// The qualifier FOLLOWS the noun in Vietnamese — "Doanh thu tối thiểu", never
|
|
541
|
+
// "tối thiểu Doanh thu". Exactly what a pack-owned function is for.
|
|
542
|
+
columnFilter: { min: (label) => `${label} tối thiểu`, max: (label) => `${label} tối đa` },
|
|
486
543
|
matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
|
|
487
544
|
scrollToBottom: { tooltip: "Cuộn xuống cuối" },
|
|
488
545
|
textInputField: { clear: "Xóa" },
|
|
@@ -519,7 +576,11 @@ export const vi: LoticsLocale = {
|
|
|
519
576
|
approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
|
|
520
577
|
messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
|
|
521
578
|
copyButton: { copy: "Sao chép", copied: "Đã sao chép" },
|
|
522
|
-
commentsButton: {
|
|
579
|
+
commentsButton: {
|
|
580
|
+
comment: "bình luận",
|
|
581
|
+
comments: "bình luận",
|
|
582
|
+
withSubject: (counted, subject) => `${counted} về ${subject}`,
|
|
583
|
+
},
|
|
523
584
|
};
|
|
524
585
|
|
|
525
586
|
const LoticsLocaleContext = createContext<LoticsLocale>(en);
|
package/src/peek.tsx
CHANGED
|
@@ -33,7 +33,8 @@ export function Peek(props: PeekProps) {
|
|
|
33
33
|
<Popover side={side} align={align}>
|
|
34
34
|
<PopoverTrigger>
|
|
35
35
|
<PressableHighlight
|
|
36
|
-
focusRing
|
|
36
|
+
focusRing
|
|
37
|
+
accessibilityRole="button"
|
|
37
38
|
accessibilityLabel={accessibilityLabel}
|
|
38
39
|
style={styles.trigger}
|
|
39
40
|
// 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
|
|
@@ -71,12 +71,14 @@ export interface PressableHighlightProps extends PressableProps {
|
|
|
71
71
|
* A pressable component that highlights when hovered and pressed.
|
|
72
72
|
* It will darken the `backgroundColor` prop when hovered and pressed.
|
|
73
73
|
*
|
|
74
|
-
* It IS a button
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* the
|
|
79
|
-
*
|
|
74
|
+
* It IS a button — it says so itself, with `accessibilityRole="button"` by default —
|
|
75
|
+
* and it WRAPS its children, so its content must be non-interactive by construction.
|
|
76
|
+
* A row whose content carries its own controls (a CTA, a ⋯ menu, a checkbox, a
|
|
77
|
+
* `Link`) must not be built on it: a button cannot contain interactive descendants.
|
|
78
|
+
* Compose the role-less `PressableRow` + a `PressDoor` sibling instead — the surface
|
|
79
|
+
* takes the mouse, the door takes the keyboard, the controls stay their own tab stops
|
|
80
|
+
* (`TableRow` is the reference). That role split IS the difference between the two
|
|
81
|
+
* components, which is why neither leaves it to the call site.
|
|
80
82
|
*/
|
|
81
83
|
export function PressableHighlight(props: PressableHighlightProps) {
|
|
82
84
|
const {
|
|
@@ -122,6 +124,15 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
122
124
|
] as StyleProp<ViewStyle>;
|
|
123
125
|
}}
|
|
124
126
|
onPress={handlePress}
|
|
127
|
+
// THE ROLE IS THE CONTRACT, so it is set here rather than asked for at every
|
|
128
|
+
// call site. This component IS a button — that is what separates it from the
|
|
129
|
+
// role-less `PressableRow`, and it is why its children must be
|
|
130
|
+
// non-interactive. Left to the call sites the split existed only in prose:
|
|
131
|
+
// 18 of 35 passed the role, all 18 passed "button", and the other 17
|
|
132
|
+
// rendered a surface a screen reader could name but not identify as
|
|
133
|
+
// pressable. Before the spread, so a caller that genuinely means something
|
|
134
|
+
// else still wins.
|
|
135
|
+
accessibilityRole="button"
|
|
125
136
|
{...restPressableProps}
|
|
126
137
|
// `onFocus` / `onBlur` are part of `PressableProps`, so a trailing
|
|
127
138
|
// `{...tooltipProps}` spread would silently overwrite caller-provided
|
package/src/scroll_to_bottom.tsx
CHANGED
|
@@ -17,7 +17,8 @@ export function ScrollToBottom(props: ScrollToBottomProps) {
|
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
19
|
<PressableHighlight
|
|
20
|
-
focusRing
|
|
20
|
+
focusRing
|
|
21
|
+
testID="scroll-to-bottom-button"
|
|
21
22
|
tooltip={tooltip}
|
|
22
23
|
onPress={onPress}
|
|
23
24
|
style={styles.button}
|
package/src/sources.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { colors, solid, tint, type ColorName } from "./colors";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon, type IconName } from "./icon";
|
|
5
5
|
import { PressableHighlight } from "./pressable_highlight";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
export type SourceKind = "record" | "document" | "table" | "web" | "knowledge";
|
|
8
9
|
|
|
@@ -43,18 +44,19 @@ const KIND: Record<SourceKind, { icon: IconName; color: ColorName }> = {
|
|
|
43
44
|
* Renders nothing for an empty list.
|
|
44
45
|
*/
|
|
45
46
|
export function Sources(props: SourcesProps) {
|
|
47
|
+
const locale = useLoticsLocale();
|
|
46
48
|
if (props.sources.length === 0) return null;
|
|
47
49
|
return (
|
|
48
50
|
<View style={{ gap: 8 }}>
|
|
49
51
|
{props.label === null ? null : (
|
|
50
52
|
<Text size="xs" color="muted" weight="medium">
|
|
51
|
-
{props.label ??
|
|
53
|
+
{props.label ?? locale.sources.heading}
|
|
52
54
|
</Text>
|
|
53
55
|
)}
|
|
54
56
|
<View style={styles.row}>
|
|
55
57
|
{props.sources.map((s) =>
|
|
56
58
|
props.onOpen ? (
|
|
57
|
-
<PressableHighlight focusRing key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={
|
|
59
|
+
<PressableHighlight focusRing key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={locale.sources.open(s.label)} style={styles.chip}>
|
|
58
60
|
<SourceChip source={s} openable />
|
|
59
61
|
</PressableHighlight>
|
|
60
62
|
) : (
|
package/src/status_grid.tsx
CHANGED
|
@@ -131,7 +131,8 @@ export function StatusLegend(props: StatusLegendProps) {
|
|
|
131
131
|
);
|
|
132
132
|
return onSelect ? (
|
|
133
133
|
<PressableHighlight
|
|
134
|
-
focusRing
|
|
134
|
+
focusRing
|
|
135
|
+
key={state.key}
|
|
135
136
|
accessibilityRole="button"
|
|
136
137
|
// The W3C prop — `accessibilityState` is dropped by this react-native-web
|
|
137
138
|
// build, and `selected` has no meaning on a `button` role regardless.
|
package/src/summary_line.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Text } from "./text";
|
|
|
3
3
|
import { Metric, type MetricFormat, type MetricTone } from "./metric";
|
|
4
4
|
import { TrendChip } from "./trend_chip";
|
|
5
5
|
import { InfoPopover } from "./info_popover";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
export interface SummaryLineItem {
|
|
8
9
|
/** Label that reads AFTER the value ("below minimum", "stock value"). */
|
|
@@ -49,6 +50,7 @@ export interface SummaryLineProps {
|
|
|
49
50
|
*/
|
|
50
51
|
export function SummaryLine(props: SummaryLineProps) {
|
|
51
52
|
const { items } = props;
|
|
53
|
+
const locale = useLoticsLocale();
|
|
52
54
|
return (
|
|
53
55
|
<View style={styles.row}>
|
|
54
56
|
{items.map((item) => (
|
|
@@ -67,7 +69,7 @@ export function SummaryLine(props: SummaryLineProps) {
|
|
|
67
69
|
{item.label}
|
|
68
70
|
</Text>
|
|
69
71
|
{item.trend != null ? <TrendChip value={item.trend} /> : null}
|
|
70
|
-
{item.info ? <InfoPopover text={item.info} accessibilityLabel={
|
|
72
|
+
{item.info ? <InfoPopover text={item.info} accessibilityLabel={locale.infoPopover.about(item.label)} /> : null}
|
|
71
73
|
</View>
|
|
72
74
|
))}
|
|
73
75
|
</View>
|