@lotics/ui 11.3.0 → 11.4.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
@@ -1076,7 +1076,7 @@ check_circle (CheckCircle — the completion ring: an empty ring that springs to
1076
1076
  floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
1077
1077
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
1078
1078
  sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Example: `tpl_task_board`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
1079
- step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · screen_router (ScreenRouter + Screen + useScreenRouter — the SCREENS compound: a flat navigation stack (`navigate("/case/:id")` pushes, `goBack` pops, `canGoBack`, route `params`; stacked screens stay mounted `display:none` so scroll survives the round trip). Dialog BAKES a router in (`<Dialog><Screen route="">…`); ANY other container hosts the standalone `<ScreenRouter>` — and it wraps AROUND the container so the CHROME can read the stack: a Drawer drilling into a LINKED record swaps its header to a BACK IconButton + the pushed record's id while `canGoBack` (sequence ◀ ▶ hides — stepping the root from inside a linked record disorients), and the pushed `<Screen route="/case/:id">` is a REAL editable workspace with its own footer CTAs. Key the router by record id so stepping ◀ ▶ resets the stack. Worked example: `tpl_item_list` drawer) · popover (Popover + PopoverTrigger + PopoverContent — **PopoverContent already insets its body 12px; put content directly in it, NEVER add your own padding View (that double-pads). Title/actions via PopoverHeader / PopoverFooter**) · popover_nav (usePopoverNav + PopoverScreen + PopoverNavHeader — the popover's built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes, `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the root and screens render conditionally (unmounted when inactive — no scroll preservation), `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack` (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover (an avatar/settings menu drilling into a sub-panel); route PATTERNS, `params`, and stacked-alive screens are `screen_router`'s job. Distinct from `Popover`'s plain `PopoverHeader` children container) · tooltip ·
1079
+ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · screen_router (ScreenRouter + Screen + useScreenRouter — the SCREENS compound: a flat navigation stack (`navigate("/case/:id")` pushes, `goBack` pops, `canGoBack`, route `params`; stacked screens stay mounted `display:none` so scroll survives the round trip). Dialog BAKES a router in (`<Dialog><Screen route="">…`); ANY other container hosts the standalone `<ScreenRouter>` — and it wraps AROUND the container so the CHROME can read the stack: a Drawer drilling into a LINKED record swaps its header to a BACK IconButton + the pushed record's id while `canGoBack` (sequence ◀ ▶ hides — stepping the root from inside a linked record disorients), and the pushed `<Screen route="/case/:id">` is a REAL editable workspace with its own footer CTAs. Key the router by record id so stepping ◀ ▶ resets the stack. Worked example: `tpl_item_list` drawer) · popover (Popover + PopoverTrigger + PopoverContent — **NON-MODAL: the anchored popover has NO blocking overlay, so the rest of the page stays interactive; clicking another control both dismisses this popover AND activates that control in one click; clicking outside, scrolling an ancestor, or Escape dismisses. Only `small` (bottom sheet) is modal (scrim). PopoverContent already insets its body 12px; put content directly in it, NEVER add your own padding View (that double-pads). Title/actions via PopoverHeader / PopoverFooter**) · popover_nav (usePopoverNav + PopoverScreen + PopoverNavHeader — the popover's built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes, `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the root and screens render conditionally (unmounted when inactive — no scroll preservation), `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack` (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover (an avatar/settings menu drilling into a sub-panel); route PATTERNS, `params`, and stacked-alive screens are `screen_router`'s job. Distinct from `Popover`'s plain `PopoverHeader` children container) · tooltip ·
1080
1080
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
1081
1081
  CalloutText · CalloutActions) · kpi_card · kpi_strip · summary_line (SummaryLine — the light inline summary of a register/list's FILTERED view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band) · metric · trend_chip · sparkline ·
1082
1082
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.3.0",
3
+ "version": "11.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/popover.tsx CHANGED
@@ -188,7 +188,7 @@ export interface PopoverContentProps {
188
188
  * restores it on close. Set false for an anchored panel whose trigger must
189
189
  * keep focus — e.g. a search-as-you-type combobox whose input lives outside
190
190
  * the popover and drives the list via `aria-activedescendant`. Focus then
191
- * stays on the trigger; keyboard still reaches it through the overlay. */
191
+ * stays on the trigger; keyboard still reaches the content. */
192
192
  manageFocus?: boolean;
193
193
  }
194
194
 
@@ -212,9 +212,9 @@ export function PopoverContent(props: PopoverContentProps) {
212
212
  const [isBottomSheetShown, setIsBottomSheetShown] = useState(false);
213
213
  const returnFocusRef = useRef<HTMLElement | null>(null);
214
214
  // Last known trigger geometry. The trigger can unmount while the popover is
215
- // open — a hover-revealed menu button stops being rendered the moment our
216
- // overlay covers its row and the hover ends — so positioning cannot rely on
217
- // `triggerRef.current` still being live when it runs.
215
+ // open — a hover-revealed menu button stops being rendered once its row loses
216
+ // hover/focus — so positioning cannot rely on `triggerRef.current` still being
217
+ // live when it runs.
218
218
  const triggerRectRef = useRef<DOMRect | null>(null);
219
219
 
220
220
  const handleClose = useCallback(() => {
@@ -224,9 +224,9 @@ export function PopoverContent(props: PopoverContentProps) {
224
224
 
225
225
  // Snapshot the trigger geometry synchronously the moment we open, while it is
226
226
  // guaranteed to still be mounted. `calculatePosition` runs later in a rAF, by
227
- // which point a hover-revealed trigger may already be gone (our overlay covers
228
- // its row, hover ends, the button unmounts). Capturing here keeps the popover
229
- // anchored to where the trigger was instead of stranding it off-screen.
227
+ // which point a hover-revealed trigger may already be gone (a hover-revealed row
228
+ // button can unmount once hover/focus leaves the row). Capturing here keeps the
229
+ // popover anchored to where the trigger was instead of stranding it off-screen.
230
230
  useLayoutEffect(() => {
231
231
  if (!open || small) return;
232
232
  if (triggerRef.current) {
@@ -249,7 +249,10 @@ export function PopoverContent(props: PopoverContentProps) {
249
249
  const firstFocusable = content.querySelector<HTMLElement>(
250
250
  'input, select, textarea, button, [role="button"], [role="menuitem"], [role="option"], [tabindex]:not([tabindex="-1"])',
251
251
  );
252
- (firstFocusable ?? content).focus();
252
+ // preventScroll: focusing the content must never scroll an ancestor into
253
+ // view — that scroll would trip the scroll-to-dismiss listener and close the
254
+ // popover the instant it opens.
255
+ (firstFocusable ?? content).focus({ preventScroll: true });
253
256
  });
254
257
 
255
258
  return () => {
@@ -262,11 +265,11 @@ export function PopoverContent(props: PopoverContentProps) {
262
265
  // popover (a Picker/InlineSelect/menu) it is <body>; restoring there strands
263
266
  // focus and the next Tab jumps to the page's first focusable. Fall back to
264
267
  // the prior element only if the trigger is gone (e.g. a hover-revealed menu
265
- // button that unmounted under the overlay).
268
+ // button that unmounted once its row lost hover).
266
269
  const trigger = triggerRef.current;
267
270
  const target = trigger && trigger.isConnected ? trigger : prior;
268
271
  if (target && typeof target.focus === "function") {
269
- target.focus();
272
+ target.focus({ preventScroll: true });
270
273
  }
271
274
  };
272
275
  }, [open, manageFocus]);
@@ -313,6 +316,34 @@ export function PopoverContent(props: PopoverContentProps) {
313
316
  };
314
317
  }, [open, small, onOpenChange]);
315
318
 
319
+ // Outside-dismiss (NON-MODAL anchored popover): with no blocking overlay, the
320
+ // rest of the page stays interactive, so we close on a click that lands outside
321
+ // this popover's content AND its trigger. We listen on `click` (not pointerdown)
322
+ // in the CAPTURE phase so that clicking ANOTHER field's trigger completes that
323
+ // trigger's own press — the old popover dismisses AND the new one opens in a
324
+ // single click, instead of the press being cancelled mid-gesture. A click INSIDE
325
+ // a deeper (nested) popover keeps this one open — a Select opened inside a filter
326
+ // popover must not dismiss the filter. The bottom sheet (`small`) is modal and
327
+ // dismisses via its scrim, so it opts out here.
328
+ useEffect(() => {
329
+ if (!open || small) return;
330
+ const onOutsideClick = (e: MouseEvent) => {
331
+ const node = e.target instanceof Node ? e.target : null;
332
+ if (!node) return;
333
+ if (popoverRef.current?.contains(node)) return; // inside my content
334
+ if (triggerRef.current?.contains(node)) return; // on my trigger — its own press toggles
335
+ // Keep open when the click is inside a DEEPER popover (e.g. a Select opened
336
+ // inside this one). `data-popover-level` is the same nesting depth as z-index.
337
+ const myLevel = Number(popoverRef.current?.getAttribute("data-popover-level") ?? "0");
338
+ const el = node instanceof Element ? node : node.parentElement;
339
+ const hit = el?.closest("[data-popover]");
340
+ if (hit && Number(hit.getAttribute("data-popover-level") ?? "0") > myLevel) return; // deeper popover
341
+ onOpenChange(false);
342
+ };
343
+ document.addEventListener("click", onOutsideClick, true);
344
+ return () => document.removeEventListener("click", onOutsideClick, true);
345
+ }, [open, small, onOpenChange, triggerRef]);
346
+
316
347
  const calculatePosition = useCallback(() => {
317
348
  if (!popoverRef.current) return;
318
349
  if (small) return;
@@ -476,11 +507,26 @@ export function PopoverContent(props: PopoverContentProps) {
476
507
  }
477
508
  });
478
509
 
510
+ // Non-modal: the page can now scroll UNDER an open popover. A scroll of the
511
+ // page or any ancestor is a "moved on" signal, so we DISMISS (as native
512
+ // <select> and most menu systems do) rather than chase the trigger and risk a
513
+ // panel detached from an off-screen anchor. Scrolling the popover's OWN content
514
+ // (a long option list) must NOT close it. Capture catches nested scrollers.
515
+ const onScroll = (e: Event) => {
516
+ if (e.target instanceof Node && popoverRef.current?.contains(e.target)) return;
517
+ onOpenChange(false);
518
+ };
519
+ window.addEventListener("scroll", onScroll, true);
520
+ // A window resize just re-anchors (the layout moved, the intent didn't).
521
+ window.addEventListener("resize", calculatePosition);
522
+
479
523
  return () => {
480
524
  cancelAnimationFrame(frame);
481
525
  resizeObserver?.disconnect();
526
+ window.removeEventListener("scroll", onScroll, true);
527
+ window.removeEventListener("resize", calculatePosition);
482
528
  };
483
- }, [open, calculatePosition, small]);
529
+ }, [open, calculatePosition, small, onOpenChange]);
484
530
 
485
531
  const handleOverlayClick = useCallback(
486
532
  (e: React.MouseEvent) => {
@@ -501,27 +547,32 @@ export function PopoverContent(props: PopoverContentProps) {
501
547
 
502
548
  return (
503
549
  <Portal>
504
- {/* Overlay */}
505
- <div
506
- style={{
507
- position: "fixed",
508
- top: 0,
509
- left: 0,
510
- right: 0,
511
- bottom: 0,
512
- backgroundColor: small ? "rgba(0, 0, 0, 0.5)" : "transparent",
513
- opacity: small ? (isBottomSheetShown ? 1 : 0) : 1,
514
- transition: small ? "opacity 0.3s ease" : undefined,
515
- zIndex: overlayZIndex,
516
- pointerEvents: "auto",
517
- }}
518
- onClick={handleOverlayClick}
519
- onMouseDown={(e) => e.stopPropagation()}
520
- />
550
+ {/* Modal scrim — ONLY for the bottom-sheet (`small`) mode, which IS modal.
551
+ The anchored popover is NON-MODAL: no overlay, so the rest of the page
552
+ stays interactive; outside-dismiss is the click listener above. */}
553
+ {small && (
554
+ <div
555
+ style={{
556
+ position: "fixed",
557
+ top: 0,
558
+ left: 0,
559
+ right: 0,
560
+ bottom: 0,
561
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
562
+ opacity: isBottomSheetShown ? 1 : 0,
563
+ transition: "opacity 0.3s ease",
564
+ zIndex: overlayZIndex,
565
+ pointerEvents: "auto",
566
+ }}
567
+ onClick={handleOverlayClick}
568
+ onMouseDown={(e) => e.stopPropagation()}
569
+ />
570
+ )}
521
571
  {/* Popover */}
522
572
  <div
523
573
  ref={popoverRef}
524
574
  data-popover="true"
575
+ data-popover-level={nestingLevel}
525
576
  data-testid={testID}
526
577
  role="dialog"
527
578
  aria-modal={small ? true : undefined}