@lotics/ui 11.0.0 → 11.2.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
@@ -233,7 +233,10 @@ value · trailing laid out like a TABLE: `labelWidth` / `trailingWidth` /
233
233
  need) holding `DetailRow`s — set `trailingWidth` when ANY row carries a
234
234
  trailing action/badge, so EVERY row reserves the column and one row's
235
235
  `Copy` button never makes its editor narrower than its neighbours'. An editor AT REST sits
236
- on a zinc-50 chip — THE editability affordance: users see what's editable without hovering.
236
+ on a zinc-50 chip — THE editability affordance: users see what's editable without hovering; the
237
+ picker-opening editors (`InlineSelect`/`InlineDatePicker`/`InlineTimePicker`) additionally carry a
238
+ rest glyph (chevron / calendar / clock) marking them tappable pickers without hover — the
239
+ touch-critical cue plain text/number fields don't get.
237
240
  `background="transparent"` opts a field out of the chip — for DENSE, uniformly-editable
238
241
  surfaces (a task list/board where EVERY cell edits: the chip repeated everywhere is noise and
239
242
  distinguishes nothing; hover/focus still reveal the input). Keep the chip wherever editable and
@@ -1062,7 +1065,7 @@ check_circle (CheckCircle — the completion ring: an empty ring that springs to
1062
1065
  floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
1063
1066
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
1064
1067
  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) ·
1065
- 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_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 ·
1068
+ 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 ·
1066
1069
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
1067
1070
  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 ·
1068
1071
  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.0.0",
3
+ "version": "11.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/icon.tsx CHANGED
@@ -40,6 +40,7 @@ import Box from "lucide-react-native/dist/esm/icons/box";
40
40
  import Brackets from "lucide-react-native/dist/esm/icons/brackets";
41
41
  import Calculator from "lucide-react-native/dist/esm/icons/calculator";
42
42
  import Calendar from "lucide-react-native/dist/esm/icons/calendar";
43
+ import CalendarClock from "lucide-react-native/dist/esm/icons/calendar-clock";
43
44
  import CalendarOff from "lucide-react-native/dist/esm/icons/calendar-off";
44
45
  import Camera from "lucide-react-native/dist/esm/icons/camera";
45
46
  import Check from "lucide-react-native/dist/esm/icons/check";
@@ -234,6 +235,7 @@ const iconComponents = {
234
235
  brackets: Brackets,
235
236
  calculator: Calculator,
236
237
  calendar: Calendar,
238
+ "calendar-clock": CalendarClock,
237
239
  "calendar-off": CalendarOff,
238
240
  camera: Camera,
239
241
  check: Check,
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text } from "./text";
4
+ import { Icon } from "./icon";
4
5
  import { colors } from "./colors";
5
6
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
6
7
  import { DatePickerPanel } from "./date_picker";
@@ -91,7 +92,10 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
91
92
  disabled={disabled}
92
93
  active={open && !disabled}
93
94
  accessibilityLabel={accessibilityLabel}
94
- trailing={saving ? <ActivityIndicator size={16} color={colors.zinc[400]} /> : undefined}
95
+ // A rest affordance (like the select's chevron): a calendar glyph marks
96
+ // the field as a tappable date control even when empty — no hover / pointer
97
+ // cursor needed, so it reads as interactive on touch.
98
+ trailing={saving ? <ActivityIndicator size={16} color={colors.zinc[400]} /> : <Icon name={format === "datetime" ? "calendar-clock" : "calendar"} size={18} color={colors.zinc[400]} />}
95
99
  />
96
100
  </PopoverTrigger>
97
101
  <PopoverContent>
@@ -112,6 +112,10 @@ interface InlineEditFrameProps {
112
112
  struck?: boolean;
113
113
  /** Resting surface — see {@link InlineEditBackground}. Default "tint". */
114
114
  background?: InlineEditBackground;
115
+ /** Right-aligned rest affordance shown in VIEW mode (e.g. a clock for a time
116
+ * field) — a hover-independent cue the field opens a picker. Omit for plain
117
+ * text/number inputs (typing IS the affordance). */
118
+ affordance?: ReactNode;
115
119
  }
116
120
 
117
121
  interface InlineEditViewProps {
@@ -204,6 +208,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
204
208
  accessibilityLabel,
205
209
  struck,
206
210
  background,
211
+ affordance,
207
212
  } = props;
208
213
 
209
214
  if (!editing) {
@@ -216,6 +221,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
216
221
  disabled={disabled}
217
222
  accessibilityLabel={accessibilityLabel}
218
223
  struck={struck}
224
+ trailing={affordance}
219
225
  />
220
226
  );
221
227
  }
@@ -1,5 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { KeyboardEvent } from "react";
3
+ import { Icon } from "./icon";
4
+ import { colors } from "./colors";
3
5
  import { TimePicker } from "./time_picker";
4
6
  import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
5
7
 
@@ -53,6 +55,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
53
55
  error={edit.error}
54
56
  disabled={disabled}
55
57
  accessibilityLabel={accessibilityLabel}
58
+ affordance={<Icon name="clock" size={18} color={colors.zinc[400]} />}
56
59
  >
57
60
  <TimePicker
58
61
  value={edit.draft}
@@ -31,20 +31,40 @@ export interface SectionNavHandle<K extends string> {
31
31
  *
32
32
  * Contract: every key's section stays MOUNTED (an unmounted section leaves its
33
33
  * last offset registered — conditional sections belong inside an always-mounted
34
- * wrapper that carries the `onLayout`), and offsets refresh only when a
35
- * section's own layout changes react-native-web's `onLayout` won't refire on
36
- * a pure position shift, so content above the sections should settle before
37
- * precision matters.
34
+ * wrapper that carries the `onLayout`). On WEB, `jumpTo` re-measures the live
35
+ * DOM position, so async content growing ABOVE a section (a photo grid, a
36
+ * late-loading table) can never stale a jump; the cached offsets only drive
37
+ * the scroll-spy highlight, which self-corrects as jumps refresh them. On
38
+ * native there is no re-measure — content above should settle before precision
39
+ * matters (`onLayout` won't refire on a pure position shift).
38
40
  */
39
41
  export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): SectionNavHandle<K> {
40
42
  const scrollRef = useRef<ScrollView>(null);
41
43
  const sectionY = useRef<Partial<Record<K, number>>>({});
44
+ // The registered wrapper's DOM node (web only) — RN-web's layout event
45
+ // exposes it as `nativeEvent.target`, which lets jumpTo re-measure live.
46
+ const sectionEl = useRef<Partial<Record<K, Element>>>({});
42
47
  const [activeKey, setActiveKey] = useState<K>(keys[0]);
43
48
  const register = (key: K) => (e: LayoutChangeEvent) => {
44
49
  sectionY.current[key] = e.nativeEvent.layout.y;
50
+ const target: unknown = (e.nativeEvent as { target?: unknown }).target;
51
+ if (typeof Element !== "undefined" && target instanceof Element) sectionEl.current[key] = target;
52
+ };
53
+ /** The section's CURRENT content-relative offset — measured live on web
54
+ * (immune to async content shifting the page), cached `layout.y` otherwise. */
55
+ const currentY = (key: K): number => {
56
+ const el = sectionEl.current[key];
57
+ const sv = scrollRef.current as (ScrollView & { getScrollableNode?: () => unknown }) | null;
58
+ const sc = typeof sv?.getScrollableNode === "function" ? sv.getScrollableNode() : null;
59
+ if (typeof Element !== "undefined" && el instanceof Element && sc instanceof Element) {
60
+ const y = el.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop;
61
+ sectionY.current[key] = y; // refresh the spy's cache while we're here
62
+ return y;
63
+ }
64
+ return sectionY.current[key] ?? 0;
45
65
  };
46
66
  const jumpTo = (key: K) => {
47
- scrollRef.current?.scrollTo({ y: Math.max(0, (sectionY.current[key] ?? 0) - 12), animated: true });
67
+ scrollRef.current?.scrollTo({ y: Math.max(0, currentY(key) - 12), animated: true });
48
68
  };
49
69
  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
50
70
  const y = e.nativeEvent.contentOffset.y + 80;