@lotics/ui 47.0.0 → 47.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/catalog.md +34 -4
- package/docs/composition.md +42 -6
- package/docs/reviewing.md +37 -3
- package/examples/tpl_item_list.tsx +7 -2
- package/package.json +1 -1
- package/src/accordion.tsx +11 -5
- package/src/bar_chart.tsx +3 -1
- package/src/breakdown.tsx +3 -1
- package/src/charge_lines.tsx +24 -5
- package/src/finding.tsx +1 -1
- package/src/form_text_input.tsx +9 -2
- package/src/inline_slot.tsx +16 -2
- package/src/inline_static.tsx +18 -4
- package/src/member_chip.tsx +18 -1
- package/src/menu_button.tsx +12 -2
- package/src/progress_bar.tsx +14 -1
- package/src/record_summary.tsx +5 -0
- package/src/stacked_bar_chart.tsx +4 -1
- package/src/table_fit.ts +18 -1
- package/src/thumbnail_stack.tsx +18 -3
package/docs/catalog.md
CHANGED
|
@@ -86,6 +86,13 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
|
|
|
86
86
|
your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
|
|
87
87
|
renders these per option.)
|
|
88
88
|
|
|
89
|
+
**`showAvatar={false}` where the member is ONE FIELD AMONG COLUMNS** — an owner, an assignee,
|
|
90
|
+
a last-edited-by. Keep the face where the member IS the subject (a roster, a picker option, a
|
|
91
|
+
profile). In a supporting column it fails twice: it competes with the mark the row already
|
|
92
|
+
carries for its own subject, and on a real book a few people own hundreds of rows, so the
|
|
93
|
+
column draws one repeated disc — which `composition.md` §Identity marks calls a mark that
|
|
94
|
+
"carries nothing and is the largest, brightest thing there".
|
|
95
|
+
|
|
89
96
|
**To answer "who IS this?" — `MemberPeek`**, the same chip made a door onto a
|
|
90
97
|
`MemberProfileCard`: a 72px avatar, what they sign in as, their role, their teams and when they
|
|
91
98
|
joined. The trigger takes `Peek`'s `variant="control"` there, so the chip seats in the 40px
|
|
@@ -130,7 +137,11 @@ that a whole office shares.
|
|
|
130
137
|
`ThumbnailStack` is `AvatarGroup`'s sibling over a different subject: a capped, overlapping set
|
|
131
138
|
of PICTURES answering "an order of what?", "which parts?", "what arrived?" in one register cell.
|
|
132
139
|
Pass `{ id, label, image }` per thing; `max` (default 3) caps it and the rest become the `+N`
|
|
133
|
-
mark at the end of the stack.
|
|
140
|
+
mark at the end of the stack. Each mark ANNOUNCES its label by default — a standalone stack is
|
|
141
|
+
unreadable otherwise — so pass **`decorative`** where the row already names the thing in text, or
|
|
142
|
+
the pictures read it a second time on every row. (The default is the opposite of
|
|
143
|
+
`Avatar.announce`, and for the same reason: an avatar almost always sits beside its name, a stack
|
|
144
|
+
often does not. The default follows where the component actually sits.) Both components read their separating edge from `mark_stack.ts`,
|
|
134
145
|
so a row carrying people in one column and goods in the next reads as one device used twice
|
|
135
146
|
rather than two — but they **tuck by different fractions, and that is deliberate**: a face is
|
|
136
147
|
edge-anchored and survives being covered two thirds of the way, while a product shot centres its
|
|
@@ -279,7 +290,12 @@ by size.)
|
|
|
279
290
|
search, you don't group. Worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx).
|
|
280
291
|
Never an HTML `<table>` or a `.map` of rows. The register adapts to its container on its
|
|
281
292
|
own — hides columns by `priority`, stacks rows below the two-column floor (see the `table`
|
|
282
|
-
inventory entry) — so a narrow screen or panel needs NO branching
|
|
293
|
+
inventory entry) — so a narrow screen or panel needs NO branching. **A flexible column that
|
|
294
|
+
carries fixed chrome inside its cell declares it as `lead`** (a leading mark plus its gap): the
|
|
295
|
+
fit guarantees a READ floor for the text, and without `lead` the mark eats part of that floor,
|
|
296
|
+
so a register reports "fitted" while its subject clips. `composition.md` requires a leading mark
|
|
297
|
+
on every register row and `tpl_item_list` puts it inside the subject cell, so this is the
|
|
298
|
+
ordinary case, not an exotic one; compose a hand-designed
|
|
283
299
|
card pile (`PressableRow` + your own hierarchy) over `useScreenSize` only when a screen
|
|
284
300
|
deserves a better mobile shape than the automatic stack.
|
|
285
301
|
- **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view)
|
|
@@ -1351,7 +1367,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1351
1367
|
EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
|
|
1352
1368
|
total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
|
|
1353
1369
|
non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number
|
|
1354
|
-
column, `weight="medium"` to emphasise a total among plain rows, `
|
|
1370
|
+
column, `weight="medium"` to emphasise a total among plain rows, **`actions` for a verb about
|
|
1371
|
+
the value** (look this id up, copy it, request the gated change) — a static value is still a
|
|
1372
|
+
field, and its verb belongs on the field's surface, never on the row's ground where an
|
|
1373
|
+
`InlineButton` has nothing under it; `multiline` to WRAP rather
|
|
1355
1374
|
than truncate (single-line is the default, because a field column reads as a column only if
|
|
1356
1375
|
its rows are one height — pass it where the counterpart editor is itself `multiline`, or a
|
|
1357
1376
|
truncated value beside a wrapping input reintroduces the misalignment on the other axis).
|
|
@@ -1376,7 +1395,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1376
1395
|
- **`inline_button`** — `InlineButton`: a verb that sits INSIDE an inline field's surface
|
|
1377
1396
|
(the `Open` on a reference, a `Copy` on a value worth copying) rather than in the row's
|
|
1378
1397
|
trailing column — put it inside when the act is ABOUT THE VALUE, so it travels with what it
|
|
1379
|
-
acts on and the trailing column stays free for row-level verbs.
|
|
1398
|
+
acts on and the trailing column stays free for row-level verbs.
|
|
1399
|
+
|
|
1400
|
+
**NEVER use it outside an inline field.** Not in a section header, not in a toolbar, not as a
|
|
1401
|
+
row action, not as a card's own verb — it is an *inner* control of a field, and the whole of its
|
|
1402
|
+
design says so: it is FILLED against the field's white ground (a relationship to a surface it no
|
|
1403
|
+
longer has when you move it), it is 28px against a 40px band, it carries no border because the
|
|
1404
|
+
field already draws one, and **it stops propagation** because the field beneath it is pressable.
|
|
1405
|
+
Outside a field every one of those becomes wrong: it reads as a heavier `Button secondary` that
|
|
1406
|
+
is mysteriously too small, it sits on a ground it was not contrasted against, and its swallowed
|
|
1407
|
+
press is now swallowing nothing. If the verb is about the ROW use the row's trailing action; if
|
|
1408
|
+
it is about the SECTION use its heading row; if it is about the SCREEN it is a `Button`. The
|
|
1409
|
+
test is one question — **is there a field surface under it?** If not, it is the wrong control. FILLED (`Button secondary`'s own
|
|
1380
1410
|
zinc-100) on the field's white surface — the contrast is a RELATIONSHIP, not a colour: invert it
|
|
1381
1411
|
if the resting field surface ever changes, or the verb dissolves into the value. 28px, radius 8,
|
|
1382
1412
|
no border (the field already has one) and no shadow (depth belongs to the primary action alone). **It stops propagation** — that is the contract: the
|
package/docs/composition.md
CHANGED
|
@@ -556,9 +556,25 @@ have decided the thing itself (which one they want).
|
|
|
556
556
|
The toggle earns its place when the two states differ in more than a number — different rows,
|
|
557
557
|
different units, a different shape of answer. Swapping `1.700.000` for `2.000.000` is not that.
|
|
558
558
|
|
|
559
|
+
**A container's own gap must exceed the largest gap INSIDE its children — not just the gap to its
|
|
560
|
+
siblings.** The comparison authors are told to make is within-group against between-group, which
|
|
561
|
+
reads as "compare the two numbers *you* wrote". The failure that actually ships compares a number
|
|
562
|
+
you wrote against one the KIT wrote inside a component you dropped in: a section body at 12 or 14
|
|
563
|
+
around a field table that spaces its own rows at 16. Both are conventional, neither looks wrong
|
|
564
|
+
alone, and the boundary BETWEEN two objects ends up tighter than the boundary inside one. Measured
|
|
565
|
+
form: per container, collect its own `row-gap` and the **max `row-gap` of its visible children**;
|
|
566
|
+
`own <= child-max` is the finding.
|
|
567
|
+
|
|
559
568
|
## Numbers, money, dates
|
|
560
569
|
|
|
561
570
|
- Free-standing numerals: `<Text tabular>` — always.
|
|
571
|
+
- **A kit component that RENDERS a number takes the app's formatter — it must never `String()` a
|
|
572
|
+
decimal.** In a locale that writes `28,9`, the one figure the kit prints for itself becomes the
|
|
573
|
+
one figure on the screen that is wrong, sitting beside numbers the app formatted correctly — and
|
|
574
|
+
in a locale where `.` is the *thousands* separator that is not a cosmetic difference, it is a
|
|
575
|
+
different quantity. `ChargeLines` takes `formatMoney` and `formatQuantity` for this reason, both
|
|
576
|
+
at the BAND so two lines cannot disagree, and threads them to the locked value and the inline
|
|
577
|
+
editor alike. Any new component that displays a caller's number owes the same hook.
|
|
562
578
|
- **A column states its unit ONCE — which is only possible if it holds ONE unit.** State it in the
|
|
563
579
|
header band and leave the cells bare. The corollary is the one that bites: a label/value list
|
|
564
580
|
whose rows are a date range, a sum of money and a COUNT has no single unit to state, so each
|
|
@@ -1665,6 +1681,19 @@ information.
|
|
|
1665
1681
|
identity a reader actually scans for — a deal takes its account's mark, since "whose deal is
|
|
1666
1682
|
this" is the question being asked of the list. Only rows with genuinely no identity are exempt:
|
|
1667
1683
|
a list of amounts, a log of events.
|
|
1684
|
+
- **A member as one FIELD among columns is TEXT, not a face** (`MemberChip showAvatar={false}`).
|
|
1685
|
+
Keep the face where the member IS the subject — a roster, a picker option, a profile. In an
|
|
1686
|
+
owner / assignee / last-edited-by column it fails twice over: it competes with the mark the row
|
|
1687
|
+
already carries for its own subject, and on a real book a handful of people own hundreds of rows,
|
|
1688
|
+
so the column draws the same disc over and over. Shipped example: an owner column rendering four
|
|
1689
|
+
identical discs for two people, both initial "T", both hashed to a purple.
|
|
1690
|
+
- **A mark derived from POSITION is not identity — the list already draws position.** An ordinal
|
|
1691
|
+
disc, a rank badge, an index chip *does* vary per row, so it passes "the mark must not look the
|
|
1692
|
+
same on every row" and every probe built on that test. It fails for a different reason: it
|
|
1693
|
+
restates what top-to-bottom order says, and it spends the one slot that could have carried the
|
|
1694
|
+
subject. Keep the ordinal where order is the thing being EDITED, and then as text in the
|
|
1695
|
+
supporting line — never as the mark. A surface that needs to state position has its own readout
|
|
1696
|
+
(a drawer's `3/8`, a stepper's rail).
|
|
1668
1697
|
- **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
|
|
1669
1698
|
where it is one field among columns (see `avatar_size`).
|
|
1670
1699
|
- **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
|
|
@@ -1692,12 +1721,19 @@ Every rule above removes a defect. None of them ADDS anything, so a screen can p
|
|
|
1692
1721
|
and still be reported as plain, boring, or unfinished — which is a different complaint with a
|
|
1693
1722
|
different fix.
|
|
1694
1723
|
|
|
1695
|
-
- **What is inherently visual in THIS data?**
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1724
|
+
- **What is inherently visual in THIS data?** Mostly the data itself: identity marks (above),
|
|
1725
|
+
status colour, file-type badges, sparklines, proportion bars, tabular figures aligning down a
|
|
1726
|
+
column. Every one of these is information first and decoration never — which is why they are the
|
|
1727
|
+
only kind of visual interest that survives contact with a real dataset. Reaching instead for a
|
|
1728
|
+
gradient, an illustration or a hero band adds weight without adding a single fact.
|
|
1729
|
+
- **But some entities DO have a likeness, and then it is the strongest mark available.** A product,
|
|
1730
|
+
a property, a vehicle, a dish, a person — a reader recognises the thing itself faster than any
|
|
1731
|
+
derived initial, and the picture is doing the identifying rather than decorating. Where the entity
|
|
1732
|
+
has one, an empty image field is a **DATA gap, not a design constraint**: the fix is to put the
|
|
1733
|
+
image on the record, not to restyle the row around its absence. A catalogue of sixty rows opening
|
|
1734
|
+
with text reads as a fixture no treatment pass will rescue. The test is unchanged — it must make
|
|
1735
|
+
the screen clearer AND better-looking — which is exactly what a thumbnail that is also the
|
|
1736
|
+
identifier does, and exactly what a stock photo bolted onto a task list does not.
|
|
1701
1737
|
- **What does the screen DECIDE?** A register listing every row at equal weight has no point of
|
|
1702
1738
|
view: it hands the reader the same undifferentiated wall the query returned. A screen with a
|
|
1703
1739
|
point of view puts what needs attention first (`TableGroup`), spends its one accent on where the
|
package/docs/reviewing.md
CHANGED
|
@@ -543,15 +543,23 @@ this converges on *distinctive*.
|
|
|
543
543
|
largest, brightest thing there.
|
|
544
544
|
- **What does the screen DECIDE?** A register listing every row at equal weight has no point of
|
|
545
545
|
view. Usually the real answer when treatment fixes keep not landing.
|
|
546
|
-
- **What is inherently visual in THIS data?**
|
|
547
|
-
|
|
548
|
-
|
|
546
|
+
- **What is inherently visual in THIS data?** Usually the data itself: identity marks, status
|
|
547
|
+
colour, file-type badges, sparklines, proportion bars, tabular figures. **But check whether the
|
|
548
|
+
entity has a LIKENESS** — a product, a property, a vehicle, a person. If it does and the rows
|
|
549
|
+
open without one, query the image field's fill rate before writing a treatment finding: an empty
|
|
550
|
+
field makes this a DATA defect, and no amount of restyling fixes it.
|
|
549
551
|
- **What makes this belong to THIS product?** Default typeface, default greys, default accent,
|
|
550
552
|
default control shapes is a well-built generic screen, and no polish changes that. The fix is a
|
|
551
553
|
theme, not another treatment pass.
|
|
552
554
|
|
|
553
555
|
→ [composition.md](./composition.md) §"Identity marks", §"Character comes from the DATA", §"Where the accent goes".
|
|
554
556
|
|
|
557
|
+
**A driver's `.focus()` does not set `:focus-visible`** — so a focus-ring check driven
|
|
558
|
+
programmatically reports *no ring* on a control whose ring is fine, and the audit spends its time
|
|
559
|
+
on a defect that does not exist. Walk the surface with real `Tab` presses and read the computed
|
|
560
|
+
`box-shadow` at each stop; `.focus()` is only good for asking what CAN take focus, never for how it
|
|
561
|
+
looks when it does.
|
|
562
|
+
|
|
555
563
|
### 10. Microcopy
|
|
556
564
|
- **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
|
|
557
565
|
refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
|
|
@@ -637,6 +645,32 @@ variant that walks ELEMENTS rather than text nodes. Those tags hold text nodes t
|
|
|
637
645
|
sees, and counting them produces confident nonsense — a page "with eight nodes in Times New Roman"
|
|
638
646
|
that has none, or a clean tally on a screen with an untracked component still on it.
|
|
639
647
|
|
|
648
|
+
### Identity marks — N identical is a finding, and only a per-COLUMN count sees it
|
|
649
|
+
Probe 9 says "count them. Zero is the finding, and so is N identical" — and every other snippet
|
|
650
|
+
here reads type, rules, gaps, states or reachability, so nothing fired on a column of identical
|
|
651
|
+
discs. Group by the x band (i.e. by column), because that is what separates the row's own subject
|
|
652
|
+
mark from a supporting column that borrowed identity weight.
|
|
653
|
+
|
|
654
|
+
```js
|
|
655
|
+
// → [{ x, count, distinct, sample }] — flag any band with count > 1 && distinct === 1
|
|
656
|
+
Object.values(
|
|
657
|
+
[...document.querySelectorAll('[data-avatar], [class*="avatar"], img')]
|
|
658
|
+
.filter((n) => n.getBoundingClientRect().width > 0)
|
|
659
|
+
.reduce((acc, n) => {
|
|
660
|
+
const band = Math.round(n.getBoundingClientRect().x / 24) * 24; // column, not pixel
|
|
661
|
+
const cs = getComputedStyle(n);
|
|
662
|
+
const key = [cs.backgroundColor, n.textContent?.trim(), n.getAttribute("src") ?? ""].join("|");
|
|
663
|
+
acc[band] ??= { x: band, keys: new Set(), count: 0 };
|
|
664
|
+
acc[band].keys.add(key);
|
|
665
|
+
acc[band].count++;
|
|
666
|
+
return acc;
|
|
667
|
+
}, {}),
|
|
668
|
+
).map((b) => ({ x: b.x, count: b.count, distinct: b.keys.size, sample: [...b.keys][0] }));
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
A band with `count > 1 && distinct === 1` is the reported defect. A band whose `distinct` is well
|
|
672
|
+
under `count` is the softer version — a palette collapsing on real names — and worth reading by eye.
|
|
673
|
+
|
|
640
674
|
### Separator beats — is the biggest boundary the widest?
|
|
641
675
|
|
|
642
676
|
**A rule is a painted LINE, and it is drawn two ways.** Selecting "thin and wide" alone gets both
|
|
@@ -1244,8 +1244,13 @@ export function TplItemList() {
|
|
|
1244
1244
|
<Text size="xxl" weight="semibold">Records</Text>
|
|
1245
1245
|
|
|
1246
1246
|
{/* toolbar — search + consolidated filters LEFT, New case RIGHT, one row.
|
|
1247
|
-
A search is present, so status is a dropdown (Select), not pills.
|
|
1248
|
-
|
|
1247
|
+
A search is present, so status is a dropdown (Select), not pills.
|
|
1248
|
+
|
|
1249
|
+
`flex-start`, not `center`: the filter block wraps to two or three
|
|
1250
|
+
lines on a phone, and centring floats the CTA against the whole
|
|
1251
|
+
wrapped block — measured landing level with the SECOND row of chips,
|
|
1252
|
+
anchored to nothing. The band's top edge is what it belongs to. */}
|
|
1253
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
1249
1254
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center", flex: 1 }}>
|
|
1250
1255
|
<View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
|
|
1251
1256
|
<SearchInput
|
package/package.json
CHANGED
package/src/accordion.tsx
CHANGED
|
@@ -175,9 +175,14 @@ export interface AccordionContentProps {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
/** The collapsible body, revealed in place under the header. Plain by
|
|
178
|
-
* design — flush with the header's left edge
|
|
179
|
-
*
|
|
180
|
-
*
|
|
178
|
+
* design — flush with the header BOX's left edge, no tinted well; the chevron
|
|
179
|
+
* + position already say "inside". Compose rows/dividers/actions directly.
|
|
180
|
+
*
|
|
181
|
+
* It does NOT align with the title text: `AccordionTitle` always reserves a
|
|
182
|
+
* 16px icon slot plus a 12px gap, so the title starts 28px in while the content
|
|
183
|
+
* starts at 0. That is deliberate — the body belongs to the section, not to the
|
|
184
|
+
* heading — but it means a header with no icon shows the offset with nothing
|
|
185
|
+
* explaining it, so pass an icon when the gap reads as a mistake. */
|
|
181
186
|
export function AccordionContent(props: AccordionContentProps) {
|
|
182
187
|
const { expanded } = useAccordionContext();
|
|
183
188
|
if (!expanded) return null;
|
|
@@ -210,8 +215,9 @@ const styles = StyleSheet.create({
|
|
|
210
215
|
meta: {
|
|
211
216
|
flexShrink: 1,
|
|
212
217
|
},
|
|
213
|
-
// Flush with the header's left edge
|
|
214
|
-
//
|
|
218
|
+
// Flush with the header BOX's left edge, like a Section body — NOT with the
|
|
219
|
+
// title text, which the icon slot pushes 28px in. The chevron + position
|
|
220
|
+
// already say "inside"; no indent needed.
|
|
215
221
|
content: {
|
|
216
222
|
paddingBottom: 10,
|
|
217
223
|
},
|
package/src/bar_chart.tsx
CHANGED
|
@@ -300,7 +300,9 @@ export function BarChart(props: BarChartProps) {
|
|
|
300
300
|
<Text numberOfLines={1} weight="medium">
|
|
301
301
|
{item.label}
|
|
302
302
|
</Text>
|
|
303
|
-
<Text color="muted">
|
|
303
|
+
<Text numberOfLines={1} color="muted">
|
|
304
|
+
{formatNumber(item.value)}
|
|
305
|
+
</Text>
|
|
304
306
|
</View>
|
|
305
307
|
) : null}
|
|
306
308
|
</View>
|
package/src/breakdown.tsx
CHANGED
|
@@ -85,7 +85,9 @@ export function Breakdown(props: BreakdownProps) {
|
|
|
85
85
|
<Text size="sm" color={dimmed ? "muted" : "default"} numberOfLines={1} style={styles.label}>
|
|
86
86
|
{item.label}
|
|
87
87
|
</Text>
|
|
88
|
-
|
|
88
|
+
{/* medium, matching `StackedBarChart`'s figure — the value is what the
|
|
89
|
+
row is read for. `semibold` stays reserved for selection. */}
|
|
90
|
+
<Text size="sm" weight={selected ? "semibold" : "medium"} color={dimmed ? "muted" : "default"} tabular>
|
|
89
91
|
{formatValue(item.value)}
|
|
90
92
|
</Text>
|
|
91
93
|
<Text size="xs" color="muted" tabular align="right" style={styles.share}>
|
package/src/charge_lines.tsx
CHANGED
|
@@ -120,6 +120,11 @@ export interface ChargeLinesProps {
|
|
|
120
120
|
* not the sum of something visible is the one figure a reader cannot check. */
|
|
121
121
|
total: number;
|
|
122
122
|
formatMoney: (n: number) => string;
|
|
123
|
+
/** Format a QUANTITY. Band-level for the same reason `formatMoney` is: a
|
|
124
|
+
* locale that writes `28,9` must not have the one figure the kit renders
|
|
125
|
+
* itself print `28.9` while every number the app formats beside it reads
|
|
126
|
+
* correctly. Defaults to the platform's own `String`. */
|
|
127
|
+
formatQuantity?: (n: number) => string;
|
|
123
128
|
/** Sits in the closing row beside the total — the issue/collect action. */
|
|
124
129
|
action?: ReactNode;
|
|
125
130
|
/** Shown in place of the lines when there are none. */
|
|
@@ -129,7 +134,12 @@ export interface ChargeLinesProps {
|
|
|
129
134
|
const QTY_W = 76;
|
|
130
135
|
const OP_W = 14;
|
|
131
136
|
const PRICE_W = 132;
|
|
132
|
-
|
|
137
|
+
// 48 fitted an integer and clipped `28,9` — 33px of text in the 30 left after the
|
|
138
|
+
// inset and border, on 4 of 7 lines of a real record. A quantity with one decimal
|
|
139
|
+
// is the ordinary case wherever goods are sold by weight, and `formatQuantity`
|
|
140
|
+
// now makes the separator a comma in the locales that use one, which is no wider
|
|
141
|
+
// but no narrower either. 60 seats four glyphs and a unit's worth of slack.
|
|
142
|
+
const QTY_W_NARROW = 60;
|
|
133
143
|
const PRICE_W_NARROW = 92;
|
|
134
144
|
const AMOUNT_W = 108;
|
|
135
145
|
const ACTION_W = 32;
|
|
@@ -138,7 +148,7 @@ const ACTION_W = 32;
|
|
|
138
148
|
const FORK_AT = 520;
|
|
139
149
|
|
|
140
150
|
export function ChargeLines(props: ChargeLinesProps) {
|
|
141
|
-
const { children, totalLabel, total, formatMoney, action, empty } = props;
|
|
151
|
+
const { children, totalLabel, total, formatMoney, formatQuantity, action, empty } = props;
|
|
142
152
|
const [width, setWidth] = useState(0);
|
|
143
153
|
const narrow = width > 0 && width < FORK_AT;
|
|
144
154
|
const onLayout = useCallback((e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width), []);
|
|
@@ -146,7 +156,7 @@ export function ChargeLines(props: ChargeLinesProps) {
|
|
|
146
156
|
const isEmpty = Array.isArray(rows) ? rows.length === 0 : !rows;
|
|
147
157
|
|
|
148
158
|
return (
|
|
149
|
-
<ChargeLinesContext.Provider value={{ narrow, formatMoney }}>
|
|
159
|
+
<ChargeLinesContext.Provider value={{ narrow, formatMoney, formatQuantity }}>
|
|
150
160
|
<View onLayout={onLayout} style={styles.band}>
|
|
151
161
|
{isEmpty ? (
|
|
152
162
|
<View style={styles.empty}>{empty}</View>
|
|
@@ -188,6 +198,10 @@ export function ChargeLine(props: ChargeLineProps) {
|
|
|
188
198
|
const band = useChargeLines();
|
|
189
199
|
const words = useLoticsLocale().chargeLines;
|
|
190
200
|
const money = props.formatMoney ?? band.formatMoney;
|
|
201
|
+
const qty = band.formatQuantity ?? ((n: number) => String(n));
|
|
202
|
+
const fmtQty = band.formatQuantity
|
|
203
|
+
? (v: number | null) => (v == null ? "" : qty(v))
|
|
204
|
+
: undefined;
|
|
191
205
|
const priced = quantity !== undefined || unitPrice !== undefined;
|
|
192
206
|
const amount = priced ? (quantity ?? 0) * (unitPrice ?? 0) : (props.amount ?? 0);
|
|
193
207
|
/** The row's money verb — the unit price's on a priced line, the amount's on a
|
|
@@ -212,12 +226,13 @@ export function ChargeLine(props: ChargeLineProps) {
|
|
|
212
226
|
<View style={{ width: band.narrow ? QTY_W_NARROW : QTY_W }}>
|
|
213
227
|
{locked || !onQuantityChange ? (
|
|
214
228
|
<Text size="sm" tabular style={styles.inset}>
|
|
215
|
-
{quantity
|
|
229
|
+
{quantity == null ? "—" : qty(quantity)}
|
|
216
230
|
</Text>
|
|
217
231
|
) : (
|
|
218
232
|
<InlineNumberInput
|
|
219
233
|
value={quantity ?? null}
|
|
220
234
|
onSave={onQuantityChange}
|
|
235
|
+
format={fmtQty}
|
|
221
236
|
min={0}
|
|
222
237
|
placeholder="1"
|
|
223
238
|
align="right"
|
|
@@ -340,7 +355,11 @@ export function ChargeLine(props: ChargeLineProps) {
|
|
|
340
355
|
);
|
|
341
356
|
}
|
|
342
357
|
|
|
343
|
-
const ChargeLinesContext = createContext<{
|
|
358
|
+
const ChargeLinesContext = createContext<{
|
|
359
|
+
narrow: boolean;
|
|
360
|
+
formatMoney: (n: number) => string;
|
|
361
|
+
formatQuantity?: (n: number) => string;
|
|
362
|
+
}>({
|
|
344
363
|
narrow: false,
|
|
345
364
|
formatMoney: (n) => String(n),
|
|
346
365
|
});
|
package/src/finding.tsx
CHANGED
|
@@ -220,7 +220,7 @@ const styles = StyleSheet.create({
|
|
|
220
220
|
// being read aloud, the same defect `CommentsButton` was fixed for. It also
|
|
221
221
|
// wrapped: at phone width a line could BEGIN with the separator. Stacking pairs
|
|
222
222
|
// each source with its own value at every width and needs no separator at all.
|
|
223
|
-
readings: { flexDirection: "column", alignItems: "
|
|
223
|
+
readings: { flexDirection: "column", alignItems: "stretch", gap: 4 },
|
|
224
224
|
reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
|
|
225
225
|
// The same split as `title`/`delta` one row up, for the same reason: the SOURCE
|
|
226
226
|
// is a label and gives way, the VALUE is the figure and never does. Both halves
|
package/src/form_text_input.tsx
CHANGED
|
@@ -7,10 +7,17 @@ export interface FormTextInputProps extends Omit<FormFieldProps, "style">, TextI
|
|
|
7
7
|
/** `FormField` (label / description / warning / error / optional) wrapping a `TextInputField` — one
|
|
8
8
|
* labeled text field. For a 2-col grid cell, prefer a bare `FormField style={half}` around the input. */
|
|
9
9
|
export function FormTextInput(props: FormTextInputProps) {
|
|
10
|
-
const { label, description, warning, optional, error, ...textInputProps } = props;
|
|
10
|
+
const { label, description, warning, optional, optionalLabel, error, ...textInputProps } = props;
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<FormField
|
|
13
|
+
<FormField
|
|
14
|
+
label={label}
|
|
15
|
+
description={description}
|
|
16
|
+
warning={warning}
|
|
17
|
+
error={error}
|
|
18
|
+
optional={optional}
|
|
19
|
+
optionalLabel={optionalLabel}
|
|
20
|
+
>
|
|
14
21
|
<TextInputField {...textInputProps} />
|
|
15
22
|
</FormField>
|
|
16
23
|
);
|
package/src/inline_slot.tsx
CHANGED
|
@@ -6,6 +6,10 @@ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
|
6
6
|
export interface InlineSlotProps {
|
|
7
7
|
/** Whatever sits in the value column — a `DiffValue`, a chip, a pair. */
|
|
8
8
|
children: ReactNode;
|
|
9
|
+
/** Verbs about the value, pinned right ON the slot's surface — the same place
|
|
10
|
+
* an editable `Inline*` puts its `actions`, so a static row and an editable
|
|
11
|
+
* one carry their verbs on one edge. */
|
|
12
|
+
actions?: ReactNode;
|
|
9
13
|
style?: StyleProp<ViewStyle>;
|
|
10
14
|
}
|
|
11
15
|
|
|
@@ -32,11 +36,21 @@ export interface InlineSlotProps {
|
|
|
32
36
|
* NOT for a table cell: a `Table` sets its own row rhythm, and a 40px control
|
|
33
37
|
* band inside one only makes the rows taller.
|
|
34
38
|
*/
|
|
35
|
-
export function InlineSlot({ children, style }: InlineSlotProps) {
|
|
36
|
-
return <View style={[styles.box, style]}>{children}</View>;
|
|
39
|
+
export function InlineSlot({ children, actions, style }: InlineSlotProps) {
|
|
40
|
+
if (!actions) return <View style={[styles.box, style]}>{children}</View>;
|
|
41
|
+
return (
|
|
42
|
+
<View style={[styles.box, styles.withActions, style]}>
|
|
43
|
+
<View style={styles.grow}>{children}</View>
|
|
44
|
+
{actions}
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
const styles = StyleSheet.create({
|
|
50
|
+
// The value takes the slack and the verbs pin right, so a column of rows —
|
|
51
|
+
// some with a verb, some without — keeps one right edge.
|
|
52
|
+
withActions: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
53
|
+
grow: { flex: 1, minWidth: 0 },
|
|
40
54
|
// Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
|
|
41
55
|
// transparent border) so the content's baseline aligns with the editors —
|
|
42
56
|
// minus the FocusRingPressable, so it never reads as an interactive control.
|
package/src/inline_static.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import { StyleSheet } from "react-native";
|
|
2
3
|
import { Text } from "./text";
|
|
3
4
|
import { colors } from "./colors";
|
|
@@ -28,6 +29,18 @@ export interface InlineStaticProps {
|
|
|
28
29
|
* misalignment this component exists to prevent, just on the other axis.
|
|
29
30
|
*/
|
|
30
31
|
multiline?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Verbs about THIS value, on the value's own surface — an `InlineButton`, the
|
|
34
|
+
* same slot every editable `Inline*` exposes.
|
|
35
|
+
*
|
|
36
|
+
* A read-only value often still has an act attached to it: look this id up,
|
|
37
|
+
* copy it, request the change that is gated behind an approval. Without this
|
|
38
|
+
* slot that verb has nowhere to sit but the row's ground, and an `InlineButton`
|
|
39
|
+
* outside a field surface is the wrong control (see `catalog.md`
|
|
40
|
+
* §`inline_button`). A static value is still a field; it just is not editable
|
|
41
|
+
* in place.
|
|
42
|
+
*/
|
|
43
|
+
actions?: ReactNode;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
/**
|
|
@@ -37,15 +50,16 @@ export interface InlineStaticProps {
|
|
|
37
50
|
* locked value — sits flush in the same column as the editable rows. It is
|
|
38
51
|
* NON-interactive (no hover, no pointer, no focus ring): it reads as a plain
|
|
39
52
|
* value, NOT a disabled input. Pair with `DetailRow` exactly like the `Inline*`
|
|
40
|
-
* editors
|
|
41
|
-
* slot,
|
|
53
|
+
* editors. A verb about the value goes in `actions`, on the value's own surface —
|
|
54
|
+
* `DetailRow` has no trailing slot, and a verb on the row's ground is an
|
|
55
|
+
* `InlineButton` without a field under it.
|
|
42
56
|
*/
|
|
43
57
|
export function InlineStatic(props: InlineStaticProps) {
|
|
44
|
-
const { value, placeholder, muted, tabular, align = "left", weight, multiline } = props;
|
|
58
|
+
const { value, placeholder, muted, tabular, align = "left", weight, multiline, actions } = props;
|
|
45
59
|
const isEmpty = value.length === 0;
|
|
46
60
|
const display = isEmpty ? (placeholder ?? "—") : value;
|
|
47
61
|
return (
|
|
48
|
-
<InlineSlot>
|
|
62
|
+
<InlineSlot actions={actions}>
|
|
49
63
|
<Text
|
|
50
64
|
numberOfLines={multiline ? undefined : 1}
|
|
51
65
|
tabular={tabular}
|
package/src/member_chip.tsx
CHANGED
|
@@ -29,6 +29,20 @@ interface MemberChipProps {
|
|
|
29
29
|
/** A rung on the shared avatar scale. Default `md`, which is sized to seat in a
|
|
30
30
|
* 40px control band; drop to `sm` for a dense cell. See `AVATAR_PX`. */
|
|
31
31
|
size?: AvatarSize;
|
|
32
|
+
/**
|
|
33
|
+
* Drop the face and render the person as TEXT.
|
|
34
|
+
*
|
|
35
|
+
* Pass it wherever the member is ONE FIELD AMONG COLUMNS rather than the row's
|
|
36
|
+
* subject — an owner column, an assignee cell, a "last edited by". Two reasons,
|
|
37
|
+
* both from `composition.md` §Identity marks: a second mark competes with the
|
|
38
|
+
* mark the row already carries for its own subject, and a handful of people
|
|
39
|
+
* owning hundreds of rows draws the same disc over and over, which "carries
|
|
40
|
+
* nothing and is the largest, brightest thing there".
|
|
41
|
+
*
|
|
42
|
+
* Keep the face where the member IS the subject — a roster, a picker option, a
|
|
43
|
+
* profile, a directory row.
|
|
44
|
+
*/
|
|
45
|
+
showAvatar?: boolean;
|
|
32
46
|
/** This person is no longer active — they left, their account was closed.
|
|
33
47
|
* Mutes the name so a record that still names them reads as history rather
|
|
34
48
|
* than as a current assignment. The chip keeps its height, because its usual
|
|
@@ -58,6 +72,7 @@ export function MemberChip({
|
|
|
58
72
|
image,
|
|
59
73
|
secondary,
|
|
60
74
|
size = "md",
|
|
75
|
+
showAvatar = true,
|
|
61
76
|
inactive,
|
|
62
77
|
style,
|
|
63
78
|
}: MemberChipProps) {
|
|
@@ -101,7 +116,9 @@ export function MemberChip({
|
|
|
101
116
|
secondary && AVATAR_PX[size] < AVATAR_PX.lg ? "lg" : size;
|
|
102
117
|
return (
|
|
103
118
|
<View style={[styles.row, style]}>
|
|
104
|
-
|
|
119
|
+
{showAvatar ? (
|
|
120
|
+
<Avatar size={markSize} name={displayName} source={image ? { uri: image } : undefined} />
|
|
121
|
+
) : null}
|
|
105
122
|
<View style={styles.text}>
|
|
106
123
|
<Text
|
|
107
124
|
userSelect="none"
|
package/src/menu_button.tsx
CHANGED
|
@@ -91,7 +91,11 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
91
91
|
|
|
92
92
|
const resolvedIcon =
|
|
93
93
|
typeof icon === "string" ? (
|
|
94
|
-
<Icon
|
|
94
|
+
<Icon
|
|
95
|
+
size={20}
|
|
96
|
+
name={icon as IconName}
|
|
97
|
+
color={disabled ? colors.zinc["400"] : danger ? colors.red["900"] : undefined}
|
|
98
|
+
/>
|
|
95
99
|
) : (
|
|
96
100
|
icon
|
|
97
101
|
);
|
|
@@ -102,7 +106,7 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
102
106
|
weight="medium"
|
|
103
107
|
numberOfLines={1}
|
|
104
108
|
userSelect="none"
|
|
105
|
-
color={danger ? "danger" : undefined}
|
|
109
|
+
color={disabled ? "muted" : danger ? "danger" : undefined}
|
|
106
110
|
>
|
|
107
111
|
{title}
|
|
108
112
|
</Text>
|
|
@@ -125,6 +129,7 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
125
129
|
styles.container,
|
|
126
130
|
highlighted && styles.highlighted,
|
|
127
131
|
focused && !highlighted && styles.focused,
|
|
132
|
+
disabled && styles.disabled,
|
|
128
133
|
style,
|
|
129
134
|
];
|
|
130
135
|
|
|
@@ -182,6 +187,11 @@ const styles = StyleSheet.create({
|
|
|
182
187
|
highlighted: {
|
|
183
188
|
backgroundColor: colors.zinc["100"],
|
|
184
189
|
},
|
|
190
|
+
// A blocked row keeps its geometry and loses its weight — the label and icon
|
|
191
|
+
// go muted above, and the row itself stops inviting the press.
|
|
192
|
+
disabled: {
|
|
193
|
+
opacity: 0.6,
|
|
194
|
+
},
|
|
185
195
|
focused: {
|
|
186
196
|
backgroundColor: colors.zinc["50"],
|
|
187
197
|
},
|
package/src/progress_bar.tsx
CHANGED
|
@@ -92,8 +92,15 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
92
92
|
<View style={[styles.track, styles.compactTrack]}>
|
|
93
93
|
<View style={[styles.fill, { width: `${percentage}%`, backgroundColor: isComplete ? completeColor : color }]} />
|
|
94
94
|
</View>
|
|
95
|
+
{/* The caption is MUTED at every ratio, matching the non-compact anatomy.
|
|
96
|
+
A meter can mean "done" (a task, an upload) or "used up" (a credit
|
|
97
|
+
limit, a quota), and the same 100% is good in one and bad in the
|
|
98
|
+
other — so the component cannot colour the number without asserting
|
|
99
|
+
something it does not know. It used to draw a green caption beside a
|
|
100
|
+
red bar on every cap meter. The verdict stays where the caller states
|
|
101
|
+
it: the FILL, via `completeColor`. */}
|
|
95
102
|
{format === "none" ? null : (
|
|
96
|
-
<Text size="sm" tabular color=
|
|
103
|
+
<Text size="sm" tabular color="muted">
|
|
97
104
|
{label}
|
|
98
105
|
</Text>
|
|
99
106
|
)}
|
|
@@ -151,6 +158,12 @@ const styles = StyleSheet.create({
|
|
|
151
158
|
},
|
|
152
159
|
compactTrack: {
|
|
153
160
|
flex: 1,
|
|
161
|
+
// A shrink-to-fit parent — a stacked `Table` cell, a `DetailRow` with no
|
|
162
|
+
// `DetailTable` above it — gives `flex: 1` nothing to divide, so the track
|
|
163
|
+
// measures 0 and the meter renders as its caption alone. It passes every
|
|
164
|
+
// treatment probe because there is nothing left to measure. The floor is
|
|
165
|
+
// what makes it a bar rather than a number.
|
|
166
|
+
minWidth: 64,
|
|
154
167
|
},
|
|
155
168
|
header: {
|
|
156
169
|
flexDirection: "row",
|
package/src/record_summary.tsx
CHANGED
|
@@ -102,6 +102,11 @@ const styles = StyleSheet.create({
|
|
|
102
102
|
flexDirection: "row",
|
|
103
103
|
alignItems: "flex-start",
|
|
104
104
|
gap: 16,
|
|
105
|
+
// The metric sizes to its content while `identity` is `flex: 1`, so without
|
|
106
|
+
// this the title is the only thing that can give — and it gave down to three
|
|
107
|
+
// wrapped lines in a 358px column while the metric's note kept its full width.
|
|
108
|
+
// A record's identity is the last thing on the surface that should yield.
|
|
109
|
+
flexWrap: "wrap",
|
|
105
110
|
},
|
|
106
111
|
identity: {
|
|
107
112
|
flex: 1,
|
|
@@ -129,7 +129,10 @@ export function StackedBarChart(props: StackedBarChartProps) {
|
|
|
129
129
|
<View style={styles.leading}>{row.leading}</View>
|
|
130
130
|
) : null}
|
|
131
131
|
<View style={styles.headText}>
|
|
132
|
-
|
|
132
|
+
{/* regular, matching `Breakdown`'s label: the two components draw
|
|
133
|
+
the same row and a card may sit them side by side. The figure
|
|
134
|
+
carries the weight; the series name does not. */}
|
|
135
|
+
<Text size="sm" numberOfLines={2} leading="tight">
|
|
133
136
|
{row.label}
|
|
134
137
|
</Text>
|
|
135
138
|
{row.meta ? (
|
package/src/table_fit.ts
CHANGED
|
@@ -26,6 +26,23 @@ export interface TableFitColumn {
|
|
|
26
26
|
key: string;
|
|
27
27
|
/** Fixed width in px; omit for a flexible column. */
|
|
28
28
|
width?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Fixed CHROME inside this flexible cell, in px — a leading mark, a badge, a
|
|
31
|
+
* swatch, plus its gap. Added to the read floor so the cell keeps
|
|
32
|
+
* {@link FLEX_READ_WIDTH} for the TEXT rather than spending part of it on
|
|
33
|
+
* furniture.
|
|
34
|
+
*
|
|
35
|
+
* `composition.md` requires every register row to carry a leading mark and
|
|
36
|
+
* `tpl_item_list` puts it INSIDE the subject cell (the `leading` slot holds
|
|
37
|
+
* the checkbox), so obeying the kit's own rule silently adds ~50px the fit
|
|
38
|
+
* could not see: a register reports "fitted" while its subject clips. Measured
|
|
39
|
+
* in the wild — a subject handed 154px for strings needing 283, with every
|
|
40
|
+
* other signal green.
|
|
41
|
+
*
|
|
42
|
+
* Ignored on a fixed-`width` column, which already states its total. Default
|
|
43
|
+
* 0, so a caller that passes nothing gets exactly the previous behaviour.
|
|
44
|
+
*/
|
|
45
|
+
lead?: number;
|
|
29
46
|
/** This column's claim on space, **`1` = highest**, the way P1/P2/P3 rank a
|
|
30
47
|
* bug: the column you least want to lose gets the SMALLEST number. Said the
|
|
31
48
|
* other way round it is the same rule — the larger the number, the sooner the
|
|
@@ -133,7 +150,7 @@ export function computeTableFit(
|
|
|
133
150
|
const requiredWidth = (cols: TableFitColumn[]): number => {
|
|
134
151
|
const slots = cols.length + (leading > 0 ? 1 : 0) + (trailing > 0 ? 1 : 0);
|
|
135
152
|
const gaps = Math.max(0, slots - 1) * COLUMN_GAP;
|
|
136
|
-
const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_READ_WIDTH), 0);
|
|
153
|
+
const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_READ_WIDTH + (c.lead ?? 0)), 0);
|
|
137
154
|
return ROW_H_PADDING + (leading > 0 ? leading : 0) + (trailing > 0 ? trailing : 0) + colsWidth + gaps;
|
|
138
155
|
};
|
|
139
156
|
|
package/src/thumbnail_stack.tsx
CHANGED
|
@@ -62,6 +62,19 @@ export interface ThumbnailStackProps {
|
|
|
62
62
|
items: readonly ThumbnailStackItem[];
|
|
63
63
|
/** Pictures shown before the remainder becomes `+N`. Default 3. */
|
|
64
64
|
max?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Silence the marks for assistive tech — they become decoration.
|
|
67
|
+
*
|
|
68
|
+
* The DEFAULT is to announce each `label`, and that is deliberate: a stack is
|
|
69
|
+
* often the only statement of what is there, and a run of unlabelled pictures
|
|
70
|
+
* is unreadable. Pass this where the row ALREADY names the thing in text, so
|
|
71
|
+
* the pictures do not read it a second time on every row.
|
|
72
|
+
*
|
|
73
|
+
* Note the default is the opposite of `Avatar.announce`, and for the same
|
|
74
|
+
* reason: an avatar almost always sits beside its name, a stack often does
|
|
75
|
+
* not. The default follows where the component actually sits.
|
|
76
|
+
*/
|
|
77
|
+
decorative?: boolean;
|
|
65
78
|
/**
|
|
66
79
|
* A rung on the shared avatar scale — the same scale the marks in the
|
|
67
80
|
* neighbouring columns take, so a row of goods and a row of people sit on one
|
|
@@ -73,7 +86,7 @@ export interface ThumbnailStackProps {
|
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
export function ThumbnailStack(props: ThumbnailStackProps) {
|
|
76
|
-
const { items, max = DEFAULT_MAX, size = "md", style, testID } = props;
|
|
89
|
+
const { items, max = DEFAULT_MAX, size = "md", decorative, style, testID } = props;
|
|
77
90
|
const locale = useLoticsLocale();
|
|
78
91
|
|
|
79
92
|
if (items.length === 0) return null;
|
|
@@ -92,8 +105,10 @@ export function ThumbnailStack(props: ThumbnailStackProps) {
|
|
|
92
105
|
{shown.map((item, index) => (
|
|
93
106
|
<View
|
|
94
107
|
key={item.id}
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
// Announced by default — a standalone stack is unreadable otherwise.
|
|
109
|
+
// `decorative` silences it where the row already names the thing.
|
|
110
|
+
accessible={decorative ? undefined : true}
|
|
111
|
+
accessibilityLabel={decorative ? undefined : item.label}
|
|
97
112
|
style={{
|
|
98
113
|
width: px,
|
|
99
114
|
height: px,
|