@lotics/ui 46.13.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/composition.md +19 -0
- package/docs/reviewing.md +54 -1
- package/package.json +1 -1
- package/src/popover.tsx +22 -7
- package/src/popover_layers.ts +70 -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/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
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
|
+
}
|