@lotics/ui 46.12.0 → 46.14.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 +2 -2
- package/docs/catalog.md +35 -0
- package/docs/composition.md +19 -0
- package/docs/reviewing.md +54 -1
- package/package.json +3 -1
- package/src/avatar_group.tsx +2 -29
- package/src/mark_stack.ts +54 -0
- package/src/popover.tsx +22 -7
- package/src/popover_layers.ts +70 -0
- package/src/thumbnail_stack.tsx +151 -0
package/AGENTS.md
CHANGED
|
@@ -17,8 +17,8 @@ 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, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
|
|
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; findings, provenance, confidence; **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), and where a commit sits. |
|
|
21
|
-
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), and where a commit sits. |
|
|
21
|
+
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [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) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
|
24
24
|
|
package/docs/catalog.md
CHANGED
|
@@ -125,6 +125,28 @@ someone wants `md`. Initials are the first and **last** word of the name — "V
|
|
|
125
125
|
Watson" → MW — because a Vietnamese name's first two words are the family and middle names
|
|
126
126
|
that a whole office shares.
|
|
127
127
|
|
|
128
|
+
### Several THINGS in one cell — `ThumbnailStack`
|
|
129
|
+
|
|
130
|
+
`ThumbnailStack` is `AvatarGroup`'s sibling over a different subject: a capped, overlapping set
|
|
131
|
+
of PICTURES answering "an order of what?", "which parts?", "what arrived?" in one register cell.
|
|
132
|
+
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. Both components read their separating edge from `mark_stack.ts`,
|
|
134
|
+
so a row carrying people in one column and goods in the next reads as one device used twice
|
|
135
|
+
rather than two — but they **tuck by different fractions, and that is deliberate**: a face is
|
|
136
|
+
edge-anchored and survives being covered two thirds of the way, while a product shot centres its
|
|
137
|
+
subject in studio margin, so the facepile's ratio lands the remainder on top of the last
|
|
138
|
+
photograph. Both ratios live in `mark_stack.ts` beside each other, with the reason.
|
|
139
|
+
|
|
140
|
+
What differs from a facepile follows from a thing not being a person: the marks are ROUNDED
|
|
141
|
+
SQUARES (round is the shape the kit spends on a face), and there is **no initials fallback** —
|
|
142
|
+
"CA" is not a stand-in for a bag of coffee, so a thing with no picture renders a neutral
|
|
143
|
+
placeholder and its `label` carries the meaning to a screen reader. Inert, like the facepile:
|
|
144
|
+
its home is a pressable row and it must not hold a second destination.
|
|
145
|
+
|
|
146
|
+
**Reach for it wherever a row states a COUNT of things it could show instead.** A register whose
|
|
147
|
+
rows carry a bare number reads as a ledger of nothing in particular; the pictures are the row's
|
|
148
|
+
own data, and they say what the business is before a word is read.
|
|
149
|
+
|
|
128
150
|
### A select-field value
|
|
129
151
|
|
|
130
152
|
`OptionBadge` (a stored `select` value as its CONFIGURED colored badge) — never hand-map
|
|
@@ -691,6 +713,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
691
713
|
- **`group_avatar`** — `GroupAvatar`: a name's initials in a zinc rounded square (`size` — the
|
|
692
714
|
same rung scale AND the same initials rule as `Avatar`, default `md`); the avatar for
|
|
693
715
|
image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
|
|
716
|
+
- **`thumbnail_stack`** — `ThumbnailStack`: the same capped stack over THINGS rather than
|
|
717
|
+
parties — a set of product, document or unit pictures answering "an order of what?" in one
|
|
718
|
+
register cell. Takes `{ id, label, image? }`, shows `max` (default 3) and makes the rest the
|
|
719
|
+
`+N` mark at the END of the stack. Rounded squares, not discs, and **no initials fallback**: a
|
|
720
|
+
name is a fair stand-in for a face and a useless one for a bag of coffee, so a picture-less
|
|
721
|
+
thing renders a neutral placeholder while its `label` carries the meaning to a screen reader.
|
|
722
|
+
Marks `announce` (the stack shows no text) and it is INERT, for the same reason the facepile
|
|
723
|
+
is. Reach for it wherever a row states a COUNT of things it could show instead.
|
|
724
|
+
- **`mark_stack`** — the constants a capped stack of marks is built from: `MARK_RING` (the
|
|
725
|
+
translucent separating edge, shared, so the two stacks read as one device) plus a tuck ratio
|
|
726
|
+
EACH — `MARK_OVERLAP_RATIO` for faces and the shallower `THUMBNAIL_OVERLAP_RATIO` for things,
|
|
727
|
+
because a face survives being covered two thirds of the way and a centred product shot does
|
|
728
|
+
not. They sit together so the difference reads as a decision rather than a typo.
|
|
694
729
|
- **`avatar_group`** — `AvatarGroup`: a SET of parties as overlapping marks — "who is on this" in
|
|
695
730
|
one cell. Takes `{ id, name, kind: "person" | "group", image? }`, shows `max` of them (default
|
|
696
731
|
3) and makes the rest a `+N` that is the LAST MARK IN THE STACK, not loose text beside it.
|
package/docs/composition.md
CHANGED
|
@@ -1389,6 +1389,25 @@ reader can see, sort and filter — the column is the fact, the grouping is one
|
|
|
1389
1389
|
Ship the column first, offer grouping as a control, and let `TableGroup` render whatever the reader
|
|
1390
1390
|
picked. Everything below governs how the bands look and behave once they are on.
|
|
1391
1391
|
|
|
1392
|
+
**"The column is the floor" asks for the fact to be VISIBLE, not for a badge.** A status column
|
|
1393
|
+
earns its slot when it carries something the rest of the row cannot produce — a REASON, a name, a
|
|
1394
|
+
next step. When the state is already derivable from a neighbour, the badge is a second copy of one
|
|
1395
|
+
fact, free to disagree with the first, and it costs a column on every screen to say what the
|
|
1396
|
+
reader already read.
|
|
1397
|
+
|
|
1398
|
+
The clean discriminator is what the badge would say that the row does not:
|
|
1399
|
+
|
|
1400
|
+
- A gate register whose status reads *"paperwork not closed"* keeps its column. No other cell
|
|
1401
|
+
yields that; it is the sentence the operator repeats to the person in front of them.
|
|
1402
|
+
- A ticket register pairing two timestamps in one cell (`in → out`) does not. A second timestamp
|
|
1403
|
+
present means closed, absent means open — the state IS the shape of the cell, and an added
|
|
1404
|
+
*Open / Closed* badge restates it in a column's worth of width.
|
|
1405
|
+
|
|
1406
|
+
Two things usually carry the state better than a badge, and both are already on the row: the
|
|
1407
|
+
INTERVAL cell, where a missing second half is the open case, and the per-row ACTION, which exists
|
|
1408
|
+
only in one state and is the more useful signal because it can be pressed. Where the reader needs
|
|
1409
|
+
to isolate a state rather than spot it, that is the facet's job, not a column's.
|
|
1410
|
+
|
|
1392
1411
|
**A value's filter renders the value the same way its cell does.** If the status cell is a
|
|
1393
1412
|
`Badge variant="dot"`, the status filter's options are dot badges too — same vocabulary, same
|
|
1394
1413
|
colours, same shapes. Re-rendering it as plain text makes the reader translate between two
|
package/docs/reviewing.md
CHANGED
|
@@ -453,7 +453,60 @@ band has only a `SearchInput`.
|
|
|
453
453
|
→ [composition.md](./composition.md) §"Choice, filter & navigation controls"; the rule itself is in
|
|
454
454
|
`src/summary_line.tsx`'s contract.
|
|
455
455
|
|
|
456
|
-
### 8e.
|
|
456
|
+
### 8e. Derivable columns — every column another cell already answers
|
|
457
|
+
|
|
458
|
+
**Measure.** Per column, ask what it would say that its neighbours do not, and try to produce its
|
|
459
|
+
value from them. A column you can compute from the row is not a column; it is a restatement that
|
|
460
|
+
can drift from its source.
|
|
461
|
+
|
|
462
|
+
The frequent one is a STATUS badge beside an interval: `start → end` already distinguishes open
|
|
463
|
+
from closed, so *Open / Closed* spends a column repeating the shape of the cell next to it. Same
|
|
464
|
+
test for a total beside its parts, a duration beside two timestamps, a "has attachments" tick
|
|
465
|
+
beside a file count.
|
|
466
|
+
|
|
467
|
+
**Keep it when the badge carries a REASON rather than a state** — "blocked: papers expired" is not
|
|
468
|
+
derivable from any other cell, and it is the sentence the operator reads aloud.
|
|
469
|
+
|
|
470
|
+
**Signature in the numbers:** a column whose distinct-value count across the register is 2 while a
|
|
471
|
+
neighbouring cell varies with it one-for-one.
|
|
472
|
+
|
|
473
|
+
→ [composition.md](./composition.md) §"A register that TRIAGES".
|
|
474
|
+
|
|
475
|
+
### 8f. Does the summary describe WHAT'S SHOWN — press a facet, then re-read it
|
|
476
|
+
|
|
477
|
+
**Measure.** Record the summary strip's numbers. Press one filter value. Record them again, and
|
|
478
|
+
record how many rows the register now holds.
|
|
479
|
+
|
|
480
|
+
- The row count changed and **the numbers did not** → the strip is summarising a set the reader
|
|
481
|
+
cannot see. It is aggregating the raw query rather than the view.
|
|
482
|
+
- Also compare the `y` of the filter controls against the `y` of the strip. Controls must come
|
|
483
|
+
FIRST: the reader filters, then reads what the filter left.
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
const y = (sel) => { const e = document.querySelector(sel); return e ? Math.round(e.getBoundingClientRect().top) : null }
|
|
487
|
+
// facet chips vs the summary strip — chips must be the smaller number
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
**Why it survives every other check.** Both halves render perfectly, the numbers are real, and
|
|
491
|
+
nothing overflows or errors — the strip is simply answering a question nobody asked. It reads as
|
|
492
|
+
authoritative precisely because it is stated in the same place as the honest version.
|
|
493
|
+
|
|
494
|
+
**Two shapes it takes, and the second is the one that hides.** A strip computed off the raw query
|
|
495
|
+
never moves. A strip computed off a PARTIALLY filtered set moves for the search box and not for
|
|
496
|
+
the facet, so it looks correct in the first test you run.
|
|
497
|
+
|
|
498
|
+
**A count the facet already states is a different defect on the same strip** — the chips carry
|
|
499
|
+
counts, so repeating them as summary items is one number in two places, free to disagree. Keep
|
|
500
|
+
the aggregates no control states: the money, the extreme, the ratio.
|
|
501
|
+
|
|
502
|
+
**Source-side signature:** an aggregate reading the query result (`q.rows`, `data`, the fetch
|
|
503
|
+
hook's array) where the register renders a filtered derivative of it — and the two sitting far
|
|
504
|
+
apart in the file, which is what lets them drift.
|
|
505
|
+
|
|
506
|
+
→ [composition.md](./composition.md) §"Register / list" — the rule this measures is already
|
|
507
|
+
written there; this probe exists because reading it is not the same as obeying it.
|
|
508
|
+
|
|
509
|
+
### 8g. Truncation — every string the layout cut, at the width a phone actually is
|
|
457
510
|
|
|
458
511
|
**Measure.** At 375 (and again at 320), walk every leaf node and compare `scrollWidth` to
|
|
459
512
|
`clientWidth`. Anything wider is being cut, and the page reports no overflow while it happens:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "46.
|
|
3
|
+
"version": "46.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./accordion": "./src/accordion.tsx",
|
|
@@ -171,6 +171,7 @@
|
|
|
171
171
|
"./list_item": "./src/list_item.tsx",
|
|
172
172
|
"./loading": "./src/loading.tsx",
|
|
173
173
|
"./locale": "./src/locale.tsx",
|
|
174
|
+
"./mark_stack": "./src/mark_stack.ts",
|
|
174
175
|
"./markdown": {
|
|
175
176
|
"react-native": "./src/markdown.tsx",
|
|
176
177
|
"default": "./src/markdown.web.tsx"
|
|
@@ -279,6 +280,7 @@
|
|
|
279
280
|
"react-native": "./src/theme.ts",
|
|
280
281
|
"default": "./src/theme.web.ts"
|
|
281
282
|
},
|
|
283
|
+
"./thumbnail_stack": "./src/thumbnail_stack.tsx",
|
|
282
284
|
"./time_columns": "./src/time_columns.tsx",
|
|
283
285
|
"./time_options": "./src/time_options.ts",
|
|
284
286
|
"./time_picker": "./src/time_picker.tsx",
|
package/src/avatar_group.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Text } from "./text";
|
|
|
5
5
|
import { AVATAR_PX, AVATAR_TEXT, type AvatarSize } from "./avatar_size";
|
|
6
6
|
import { colors } from "./colors";
|
|
7
7
|
import { useLoticsLocale } from "./locale";
|
|
8
|
+
import { MARK_OVERLAP_RATIO, MARK_RING } from "./mark_stack";
|
|
8
9
|
|
|
9
10
|
/** One party in the set. A PERSON takes the disc; a group or an organization
|
|
10
11
|
* takes the rounded square, which is `Avatar`'s own shape axis and the one
|
|
@@ -25,34 +26,6 @@ export interface AvatarGroupPrincipal {
|
|
|
25
26
|
* separable at a glance and a number is the more honest summary. */
|
|
26
27
|
const DEFAULT_MAX = 3;
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
-
* How far each mark tucks under the one before it, as a fraction of the rung.
|
|
30
|
-
*
|
|
31
|
-
* A FRACTION, not a pixel constant, so the stack is the same object at every
|
|
32
|
-
* rung — a fixed inset barely stacks at `lg` and eats the marks at `sm`. Just
|
|
33
|
-
* under a third leaves each mark's whole leading edge, which is as much of an
|
|
34
|
-
* image or a pair of initials as the reader needs to tell the parties apart.
|
|
35
|
-
*/
|
|
36
|
-
const OVERLAP_RATIO = 0.3;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The hairline separating each mark from the one it covers.
|
|
40
|
-
*
|
|
41
|
-
* TRANSLUCENT INK, never the surface colour — and that is what lets this
|
|
42
|
-
* component overlap at all. The conventional facepile rings each mark in the
|
|
43
|
-
* page background so the discs read as separate, which binds the ring to a
|
|
44
|
-
* colour the row does not keep: a register row washes to `zinc[50]` on hover,
|
|
45
|
-
* `zinc[100]` as the open record and `zinc[200]` while pressed, so a white
|
|
46
|
-
* ring is a halo in three of the four states — and those are the states a
|
|
47
|
-
* reader is in when they are actually looking at the row. A translucent dark
|
|
48
|
-
* edge DARKENS whatever is behind it instead of matching it, so it holds on
|
|
49
|
-
* every ground. `FileBadge` separates itself from its own surface the same way.
|
|
50
|
-
*
|
|
51
|
-
* A `boxShadow` rather than a border, because a border grows the box: a stacked
|
|
52
|
-
* mark would render 2px wider than the identical mark in the next column.
|
|
53
|
-
*/
|
|
54
|
-
const MARK_RING = "0 0 0 1px rgba(0,0,0,0.12)";
|
|
55
|
-
|
|
56
29
|
/**
|
|
57
30
|
* A SET of parties as marks — the answer to "who is on this" in one cell.
|
|
58
31
|
*
|
|
@@ -116,7 +89,7 @@ export function AvatarGroup(props: AvatarGroupProps) {
|
|
|
116
89
|
const shown = principals.slice(0, max);
|
|
117
90
|
const hidden = principals.length - shown.length;
|
|
118
91
|
const px = AVATAR_PX[size];
|
|
119
|
-
const overlap = Math.round(px *
|
|
92
|
+
const overlap = Math.round(px * MARK_OVERLAP_RATIO);
|
|
120
93
|
// Descending, so the FIRST mark ends up on top. The remainder is one more
|
|
121
94
|
// thing to lay under, hence its own slot in the depth.
|
|
122
95
|
const depth = shown.length + (hidden > 0 ? 1 : 0);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two constants a capped stack of marks is built from.
|
|
3
|
+
*
|
|
4
|
+
* They live here, side by side, because there are two stacks — a set of PARTIES
|
|
5
|
+
* (`avatar_group`) and a set of THINGS (`thumbnail_stack`) — and the decision
|
|
6
|
+
* each constant encodes is only legible NEXT TO the other one. The edge is
|
|
7
|
+
* shared: a stack that separated itself differently would read as a second
|
|
8
|
+
* device rather than as the same one over a different subject. The tuck is NOT,
|
|
9
|
+
* and the two ratios sitting together is what stops the difference looking like
|
|
10
|
+
* a typo in one of them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How far one FACE tucks under the one before it, as a fraction of the rung.
|
|
15
|
+
*
|
|
16
|
+
* A FRACTION, not a pixel constant, so the stack is the same object at every
|
|
17
|
+
* rung — a fixed inset barely stacks at `lg` and eats the marks at `sm`. Just
|
|
18
|
+
* under a third leaves each mark's whole leading edge, which is as much of an
|
|
19
|
+
* image or a pair of initials as the reader needs to tell the marks apart.
|
|
20
|
+
*/
|
|
21
|
+
export const MARK_OVERLAP_RATIO = 0.3;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The same tuck, for a stack of THINGS — and it is deliberately shallower.
|
|
25
|
+
*
|
|
26
|
+
* A face is edge-anchored: hair, a jaw, a colour, all readable from the leading
|
|
27
|
+
* third, which is what lets a facepile overlap so hard. A product shot is the
|
|
28
|
+
* opposite — the subject is CENTRED with studio margin around it, so a third of
|
|
29
|
+
* the tile is exactly the part that carries the object. At the face ratio the
|
|
30
|
+
* remainder lands on top of the last photograph and reads as covering it rather
|
|
31
|
+
* than as following it, which is the one thing the mark must not do: it is a
|
|
32
|
+
* statement ABOUT the set, not a member of it competing for the same glance.
|
|
33
|
+
*
|
|
34
|
+
* Shallow rather than zero, because the marks still have to read as one object.
|
|
35
|
+
*/
|
|
36
|
+
export const THUMBNAIL_OVERLAP_RATIO = 0.15;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The hairline separating each mark from the one it covers.
|
|
40
|
+
*
|
|
41
|
+
* TRANSLUCENT INK, never the surface colour — and that is what lets a stack
|
|
42
|
+
* overlap at all. The conventional facepile rings each mark in the page
|
|
43
|
+
* background so the marks read as separate, which binds the ring to a colour
|
|
44
|
+
* the row does not keep: a register row washes to `zinc[50]` on hover,
|
|
45
|
+
* `zinc[100]` as the open record and `zinc[200]` while pressed, so a white ring
|
|
46
|
+
* is a halo in three of the four states — and those are the states a reader is
|
|
47
|
+
* in when they are actually looking at the row. A translucent dark edge
|
|
48
|
+
* DARKENS whatever is behind it instead of matching it, so it holds on every
|
|
49
|
+
* ground. `FileBadge` separates itself from its own surface the same way.
|
|
50
|
+
*
|
|
51
|
+
* A `boxShadow` rather than a border, because a border grows the box: a stacked
|
|
52
|
+
* mark would render 2px wider than the identical mark in the next column.
|
|
53
|
+
*/
|
|
54
|
+
export const MARK_RING = "0 0 0 1px rgba(0,0,0,0.12)";
|
package/src/popover.tsx
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
hasModalLayerAbove,
|
|
21
21
|
isInLayerAbove,
|
|
22
22
|
snapshotOpenModalLayers,
|
|
23
|
+
shouldDismissOnClick,
|
|
24
|
+
takePointerOrigin,
|
|
25
|
+
trackPointerOrigin,
|
|
23
26
|
} from "./popover_layers";
|
|
24
27
|
import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
|
|
25
28
|
import { useLoticsLocale } from "./locale";
|
|
@@ -403,16 +406,28 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
403
406
|
// filter popover, or a file-preview Modal / Alert opened from within — those are
|
|
404
407
|
// interactions WITH the popover's own flow, not a move away from it. The bottom
|
|
405
408
|
// sheet (`small`) is modal and dismisses via its scrim, so it opts out here.
|
|
409
|
+
// Gắn một lần cho cả trang, KHÔNG chờ popover mở: cú pointerdown cần nhớ chính
|
|
410
|
+
// là cú mở popover ra.
|
|
411
|
+
useEffect(() => trackPointerOrigin(), []);
|
|
412
|
+
|
|
406
413
|
useEffect(() => {
|
|
407
414
|
if (!open || small) return;
|
|
408
415
|
const onOutsideClick = (e: MouseEvent) => {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
+
if (
|
|
417
|
+
shouldDismissOnClick({
|
|
418
|
+
clickTarget: e.target instanceof Node ? e.target : null,
|
|
419
|
+
// Where the gesture BEGAN. A `click` is dispatched to the common
|
|
420
|
+
// ancestor of the mousedown and mouseup elements, so a press on the
|
|
421
|
+
// trigger released over the panel targets NEITHER of them.
|
|
422
|
+
gestureOrigin: takePointerOrigin(),
|
|
423
|
+
popover: popoverRef.current,
|
|
424
|
+
trigger: triggerRef.current,
|
|
425
|
+
popoverLevel: Number(popoverRef.current?.getAttribute("data-popover-level") ?? "0"),
|
|
426
|
+
modalsAtOpen: modalsAtOpenRef.current,
|
|
427
|
+
})
|
|
428
|
+
) {
|
|
429
|
+
onOpenChange(false);
|
|
430
|
+
}
|
|
416
431
|
};
|
|
417
432
|
document.addEventListener("click", onOutsideClick, true);
|
|
418
433
|
return () => document.removeEventListener("click", onOutsideClick, true);
|
package/src/popover_layers.ts
CHANGED
|
@@ -55,3 +55,73 @@ export function hasModalLayerAbove(
|
|
|
55
55
|
}
|
|
56
56
|
return false;
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The element the current pointer gesture STARTED on.
|
|
61
|
+
*
|
|
62
|
+
* A popover opens on the pointerdown of the gesture we later have to judge, so a
|
|
63
|
+
* listener installed once the popover is open has already missed it. One
|
|
64
|
+
* document-level listener, installed on first use and never removed, is enough
|
|
65
|
+
* for every popover on the page — and cheaper than one listener per instance.
|
|
66
|
+
*
|
|
67
|
+
* Non-DOM hosts (React Native) never install it and always read null, which
|
|
68
|
+
* degrades to judging a click on its own target — the behaviour before this
|
|
69
|
+
* existed.
|
|
70
|
+
*/
|
|
71
|
+
let lastPointerDownTarget: Node | null = null;
|
|
72
|
+
let tracking = false;
|
|
73
|
+
|
|
74
|
+
export function trackPointerOrigin(): void {
|
|
75
|
+
if (tracking || typeof document === "undefined") return;
|
|
76
|
+
tracking = true;
|
|
77
|
+
document.addEventListener(
|
|
78
|
+
"pointerdown",
|
|
79
|
+
(e) => {
|
|
80
|
+
lastPointerDownTarget = e.target instanceof Node ? e.target : null;
|
|
81
|
+
},
|
|
82
|
+
true,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Reads the origin and CONSUMES it: one pointerdown feeds one click, so a
|
|
87
|
+
* keyboard-driven click that follows sees null and is judged on its own
|
|
88
|
+
* target rather than inheriting an unrelated earlier gesture. */
|
|
89
|
+
export function takePointerOrigin(): Node | null {
|
|
90
|
+
const origin = lastPointerDownTarget;
|
|
91
|
+
lastPointerDownTarget = null;
|
|
92
|
+
return origin;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Should an ambient click dismiss this popover?
|
|
97
|
+
*
|
|
98
|
+
* The subtlety is `clickTarget`. A browser fires `click` at the nearest COMMON
|
|
99
|
+
* ANCESTOR of the mousedown and mouseup elements — so a gesture that presses the
|
|
100
|
+
* trigger and releases over the popover (which opened on pointerdown, under the
|
|
101
|
+
* cursor) produces a click on neither of them. Judged on that target alone the
|
|
102
|
+
* popover dismisses itself on the very gesture that opened it, and only when the
|
|
103
|
+
* two happen to overlap: press where the panel will NOT appear and it behaves,
|
|
104
|
+
* which is why this reads as intermittent rather than broken.
|
|
105
|
+
*
|
|
106
|
+
* So the gesture's ORIGIN counts too. `gestureOrigin` is the last pointerdown
|
|
107
|
+
* target; pass null for a click with no pointer behind it (a keyboard Enter),
|
|
108
|
+
* and it falls back to the click target alone.
|
|
109
|
+
*/
|
|
110
|
+
export function shouldDismissOnClick(args: {
|
|
111
|
+
clickTarget: Node | null;
|
|
112
|
+
gestureOrigin: Node | null;
|
|
113
|
+
popover: Element | null;
|
|
114
|
+
trigger: Element | null;
|
|
115
|
+
popoverLevel: number;
|
|
116
|
+
modalsAtOpen: ReadonlySet<Element> | null;
|
|
117
|
+
}): boolean {
|
|
118
|
+
const { clickTarget, gestureOrigin, popover, trigger, popoverLevel, modalsAtOpen } = args;
|
|
119
|
+
if (!clickTarget) return false;
|
|
120
|
+
const mine = (node: Node | null): boolean => {
|
|
121
|
+
if (!node) return false;
|
|
122
|
+
if (popover?.contains(node)) return true;
|
|
123
|
+
if (trigger?.contains(node)) return true;
|
|
124
|
+
return isInLayerAbove(node, popoverLevel, modalsAtOpen);
|
|
125
|
+
};
|
|
126
|
+
return !mine(clickTarget) && !mine(gestureOrigin);
|
|
127
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
+
import { Icon } from "./icon";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { AVATAR_PX, AVATAR_TEXT, type AvatarSize } from "./avatar_size";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
7
|
+
import { MARK_RING, THUMBNAIL_OVERLAP_RATIO } from "./mark_stack";
|
|
8
|
+
|
|
9
|
+
/** One THING in the set — a product, a document, a unit. Not a party: a thing
|
|
10
|
+
* has no initials worth reading, so it is its picture or it is a placeholder. */
|
|
11
|
+
export interface ThumbnailStackItem {
|
|
12
|
+
/** Stable identity — the React key. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** What the thing is, for the screen reader. The stack shows no text. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** The thing's own picture. Absent renders the neutral placeholder. */
|
|
17
|
+
image?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Three reads as "a few"; past that the pictures stop being separable at a
|
|
21
|
+
* glance and a number is the more honest summary. Same default as a facepile,
|
|
22
|
+
* for the same reason. */
|
|
23
|
+
const DEFAULT_MAX = 3;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A SET of THINGS as pictures — the answer to "an order of what?" in one cell.
|
|
27
|
+
*
|
|
28
|
+
* The sibling of `AvatarGroup`, over a different subject. The two share their
|
|
29
|
+
* separating edge (`mark_stack.ts`) so a row carrying both reads as one device
|
|
30
|
+
* used twice. What differs is everything that follows from a thing not being a
|
|
31
|
+
* person — including how hard they tuck:
|
|
32
|
+
*
|
|
33
|
+
* **A shallower overlap.** A face is edge-anchored and survives being covered
|
|
34
|
+
* two thirds of the way; a product shot centres its subject in studio margin,
|
|
35
|
+
* so the face ratio puts the remainder on top of the last photograph. Both
|
|
36
|
+
* ratios live in `mark_stack.ts` beside each other, with the reason.
|
|
37
|
+
*
|
|
38
|
+
* **Square, not round.** Round is the shape the kit spends on a person's face.
|
|
39
|
+
* A rounded square is what `Avatar` already gives an organization, and a crate
|
|
40
|
+
* of goods belongs on that side of the axis.
|
|
41
|
+
*
|
|
42
|
+
* **No initials.** `Avatar` falls back to a person's initials because a name is
|
|
43
|
+
* a reasonable stand-in for a face. "CA" is not a stand-in for a bag of coffee
|
|
44
|
+
* — it is noise wearing the shape of information — so a thing with no picture
|
|
45
|
+
* renders a neutral placeholder that says only "something is here", and the
|
|
46
|
+
* label carries the meaning to anyone listening.
|
|
47
|
+
*
|
|
48
|
+
* **The remainder is the last mark IN the stack**, not text after it: `+9`
|
|
49
|
+
* trailing the pictures as loose type reads as a separate fact about the row
|
|
50
|
+
* instead of as the rest of this one.
|
|
51
|
+
*
|
|
52
|
+
* **It is INERT.** Its home is a register row, and a row that already presses
|
|
53
|
+
* must not hold a second destination four pixels to the left.
|
|
54
|
+
*
|
|
55
|
+
* **It caps what it was GIVEN.** The overflow counts from what will actually
|
|
56
|
+
* render, so `+9` never counts a mark the reader can see is missing. Drop what
|
|
57
|
+
* you cannot show before you pass it in.
|
|
58
|
+
*
|
|
59
|
+
* Pure: pass resolved labels and image urls; this fetches nothing.
|
|
60
|
+
*/
|
|
61
|
+
export interface ThumbnailStackProps {
|
|
62
|
+
items: readonly ThumbnailStackItem[];
|
|
63
|
+
/** Pictures shown before the remainder becomes `+N`. Default 3. */
|
|
64
|
+
max?: number;
|
|
65
|
+
/**
|
|
66
|
+
* A rung on the shared avatar scale — the same scale the marks in the
|
|
67
|
+
* neighbouring columns take, so a row of goods and a row of people sit on one
|
|
68
|
+
* beat. Everything inside derives from it; nothing here is a fixed pixel.
|
|
69
|
+
*/
|
|
70
|
+
size?: AvatarSize;
|
|
71
|
+
style?: StyleProp<ViewStyle>;
|
|
72
|
+
testID?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ThumbnailStack(props: ThumbnailStackProps) {
|
|
76
|
+
const { items, max = DEFAULT_MAX, size = "md", style, testID } = props;
|
|
77
|
+
const locale = useLoticsLocale();
|
|
78
|
+
|
|
79
|
+
if (items.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
const shown = items.slice(0, max);
|
|
82
|
+
const hidden = items.length - shown.length;
|
|
83
|
+
const px = AVATAR_PX[size];
|
|
84
|
+
const overlap = Math.round(px * THUMBNAIL_OVERLAP_RATIO);
|
|
85
|
+
const radius = Math.round(px * 0.2);
|
|
86
|
+
// Descending, so the FIRST picture ends up on top and the remainder lies
|
|
87
|
+
// under everything rather than covering one of the things it is counting.
|
|
88
|
+
const depth = shown.length + (hidden > 0 ? 1 : 0);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<View testID={testID} style={[{ flexDirection: "row", alignItems: "center" }, style]}>
|
|
92
|
+
{shown.map((item, index) => (
|
|
93
|
+
<View
|
|
94
|
+
key={item.id}
|
|
95
|
+
accessible
|
|
96
|
+
accessibilityLabel={item.label}
|
|
97
|
+
style={{
|
|
98
|
+
width: px,
|
|
99
|
+
height: px,
|
|
100
|
+
marginLeft: index === 0 ? 0 : -overlap,
|
|
101
|
+
// `zIndex` needs a positioned box to take effect in
|
|
102
|
+
// react-native-web; without it the marks stack in DOM order.
|
|
103
|
+
position: "relative",
|
|
104
|
+
zIndex: depth - index,
|
|
105
|
+
borderRadius: radius,
|
|
106
|
+
overflow: "hidden",
|
|
107
|
+
backgroundColor: colors.zinc["100"],
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
justifyContent: "center",
|
|
110
|
+
boxShadow: MARK_RING,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{item.image ? (
|
|
114
|
+
<Image
|
|
115
|
+
source={{ uri: item.image }}
|
|
116
|
+
style={{ width: px, height: px }}
|
|
117
|
+
resizeMode="cover"
|
|
118
|
+
accessibilityIgnoresInvertColors
|
|
119
|
+
/>
|
|
120
|
+
) : (
|
|
121
|
+
<Icon name="package" size={Math.round(px * 0.5)} color={colors.zinc["400"]} />
|
|
122
|
+
)}
|
|
123
|
+
</View>
|
|
124
|
+
))}
|
|
125
|
+
{hidden > 0 && (
|
|
126
|
+
<View
|
|
127
|
+
accessible
|
|
128
|
+
accessibilityLabel={locale.avatar.more(hidden)}
|
|
129
|
+
style={{
|
|
130
|
+
width: px,
|
|
131
|
+
height: px,
|
|
132
|
+
marginLeft: shown.length === 0 ? 0 : -overlap,
|
|
133
|
+
position: "relative",
|
|
134
|
+
zIndex: 0,
|
|
135
|
+
borderRadius: radius,
|
|
136
|
+
backgroundColor: colors.zinc["100"],
|
|
137
|
+
alignItems: "center",
|
|
138
|
+
justifyContent: "center",
|
|
139
|
+
boxShadow: MARK_RING,
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{/* The rung's own type — a fixed size here is how a stack ends up
|
|
143
|
+
with a 14px count beside 12px marks. */}
|
|
144
|
+
<Text size={AVATAR_TEXT[size]} weight="medium" color="zinc-700" aria-hidden>
|
|
145
|
+
{`+${hidden}`}
|
|
146
|
+
</Text>
|
|
147
|
+
</View>
|
|
148
|
+
)}
|
|
149
|
+
</View>
|
|
150
|
+
);
|
|
151
|
+
}
|