@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 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, the only one that catches a row whose halves both measure fine and whose text is cut. Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
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
 
@@ -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. Truncation — every string the layout cut, at the width a phone actually is
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.13.0",
3
+ "version": "46.14.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
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
- const node = e.target instanceof Node ? e.target : null;
410
- if (!node) return;
411
- if (popoverRef.current?.contains(node)) return; // inside my content
412
- if (triggerRef.current?.contains(node)) return; // on my trigger its own press toggles
413
- const myLevel = Number(popoverRef.current?.getAttribute("data-popover-level") ?? "0");
414
- if (isInLayerAbove(node, myLevel, modalsAtOpenRef.current)) return;
415
- onOpenChange(false);
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);
@@ -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
+ }