@lotics/ui 18.0.0 → 18.1.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/docs/catalog.md CHANGED
@@ -171,7 +171,7 @@ component — children are the body), `Subsection` (+ `SubsectionHeading`/
171
171
  `SubsectionHeadingTitle` — the named group INSIDE a section, `###` lg-semibold title),
172
172
  `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
173
173
  top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
174
- SPACE-ONLY: titles carry the grouping, hairlines stay at the section level), `SectionCard`, `PageHeader` /
174
+ space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
175
175
  `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`,
176
176
  `Stepper`, `DangerZone` (the destructive section — delete/archive — set apart at the bottom
177
177
  of a record/settings surface).
@@ -388,9 +388,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
388
388
  `@lotics/ui/section` only when you specifically want this collapsible titled block.
389
389
  - **`section_stack`** — `SectionStack` · `SubsectionStack` — stacks that own the
390
390
  between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
391
- `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only (no
392
- hairline titles carry the grouping, `divided` an opt-in for a rare headingless stack);
393
- stop hand-rolling gap + `<Divider />` pairs.
391
+ `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
392
+ the groups are SHORT (the titles carry the grouping, hairlines stay at the section level) and
393
+ `divided` once they run LONG — past a screenful, 32px reads as one more row gap and the next
394
+ title arrives unmarked. Length decides, not headings. Stop hand-rolling gap + `<Divider />` pairs.
394
395
  - **`section_card`** — `SectionCard`: a titled card with a one-line description and an
395
396
  optional hairline-set `footer` (where `TrendFooter` and source notes go).
396
397
  - **`stack`** — `Stack`: gap-spaced row/column; `useSeparator` inserts `Separator`s between
@@ -454,8 +455,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
454
455
  - **`action_menu`** — `ActionMenu`: the ⋯ overflow menu (`ActionMenuItem[]`; danger items
455
456
  last).
456
457
  - **`menu_button`** — `MenuButton`: the menu/rail row (icon · title · `right` slot;
457
- `selected`/`focused`/`danger`; `role` menuitem|button|option) — popover menus, outline
458
- rails, section pickers.
458
+ `focused`/`danger`; `role` menuitem|button|option) — popover menus, outline rails, section
459
+ pickers. Its resting highlight has TWO meanings and they are not interchangeable:
460
+ **`selected`** is listbox SELECTION (emitted as `aria-selected`, and only under
461
+ `role="option"` — the attribute is invalid anywhere else), **`current`** is the current
462
+ item of a NAVIGATION set (`aria-current`, global, so any role) — `true`, or the token
463
+ naming the set (`"page"` for a routed pane, `"step"`, `"location"`). An outline rail and a
464
+ section picker take `current`; a picker option takes `selected`.
459
465
  - **`menu_list_item`** — `MenuListItem`: the richer listbox/menu row (title + description +
460
466
  `right`; `selected` vs roving `focused` via `aria-activedescendant`). No fixed height — it
461
467
  GROWS with its content, and `title` takes a node, so it is what `OptionList`/`Combobox` render
@@ -474,11 +480,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
474
480
  matches the surface it spans. `TableRow` / `LinkedRecordBox` are the in-kit consumers —
475
481
  reach for it when designing a row those don't cover.
476
482
  - **`linked_record_box`** — `LinkedRecordBox`: a bordered box scoping ANOTHER record's data
477
- (`icon` · `name` · `subtitle` · vertical `facts`), the WHOLE box a keyboard door into its
478
- detail (`onOpen`/`doorLabel`); verbs ride an `actions` slot in a hairline-fenced footer at the
479
- bottom (destructive LEFT, go-to RIGHT the divider draws itself when `actions` is present, so
480
- don't hand-add one). Use it wherever a record points at another (a shipment's customer, an invoice's
481
- party, a case's sibling). It **enforces the a11y contract** copy-pasting got wrong: a
483
+ (`icon` · `name` · `subtitle` · vertical `facts`); verbs ride an `actions` slot in a
484
+ hairline-fenced footer at the bottom (destructive LEFT, go-to RIGHT the divider draws itself
485
+ when `actions` is present, so don't hand-add one). Use it wherever a record points at another (a
486
+ shipment's customer, an invoice's party, a case's sibling). **`onOpen` picks the variant, and
487
+ they are exclusive at the type level**: WITH it (+ the required `doorLabel`) the whole box is a
488
+ keyboard door into the record's detail and `facts` values are TEXT (`""` → "—"); WITHOUT it the
489
+ box is a STATIC card — no door, no tab stop, no pointer, nothing announced as a button — and a
490
+ `facts` value may be a NODE (an inline editor; a node prints as authored, no "—"). Reach for the
491
+ static one when the linked record has NO page of its own: nothing to open, so the box is where
492
+ its values are read and edited, and the only interactive parts are `actions` + the fact nodes.
493
+ Under a door, `actions` is the ONLY place a control may live (a fact editor there would compete
494
+ with the press). The door variant **enforces the a11y contract** copy-pasting got wrong: a
482
495
  container with interactive descendants is never `role="button"` (invalid HTML) — an internal
483
496
  empty door sibling carries the tab stop / name / focus ring, verbs lift above via `zIndex`.
484
497
  The reassign picker / empty state that swaps in for the box is the consumer's.
@@ -495,7 +508,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
495
508
  - **`count`** — `Count`: a small circular count bubble (`color` highlight|muted|red).
496
509
  - **`shortcut_badge`** — `ShortcutBadge`: the keycap hint pill — a zinc-50 badge rendering a
497
510
  shortcut from a raw string or `ShortcutDescriptor` (⌘B on Mac, Ctrl+B elsewhere); null on
498
- small screens. `TextInputField shortcut` renders it built-in.
511
+ small screens. `TextInputField shortcut` renders it built-in, while the field is EMPTY —
512
+ it hints at reaching the field, so a value replaces it (with the clear ✕, when `clearable`).
499
513
  - **`keyboard`** — `isMac` · `ShortcutDescriptor` · `formatShortcut` (platform-aware
500
514
  shortcut formatting behind `ShortcutBadge`).
501
515
 
@@ -555,7 +569,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
555
569
  - **`text_input_field`** — `TextInputField`: the standard text input (multiline grows via
556
570
  the auto-grow engine; `shortcut` renders a `ShortcutBadge`). Its surface is WHITE on any
557
571
  background — on a tinted panel the input still reads as an open well, never blending into
558
- the tint; override via `style` only when a surface genuinely wants otherwise.
572
+ the tint; override via `style` only when a surface genuinely wants otherwise. It RESERVES
573
+ its own right gutter whenever a trailing affordance is up (the `clearable` ✕, the `shortcut`
574
+ badge), so a long value is held off it — never hand-roll a `paddingRight` to compensate. A
575
+ field with an empty slot reserves nothing, so its text starts and ends where it always did.
559
576
  - **`number_input`** — `NumberInput`: numeric input; `format` for currency/units.
560
577
  - **`search_input`** — `SearchInput`: the search box for toolbars/filters.
561
578
  - **`form_field`** — `FormField` + `useFormField`: label / description / warning / error /
@@ -857,8 +874,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
857
874
  is open. Lives in the primitives — never call it from a screen.
858
875
  - **`use_section_nav`** — `useSectionNav`: scroll-spy for a LONG record surface with a left
859
876
  outline rail — keys in page order → `{scrollRef, onScroll, register(key)→onLayout,
860
- jumpTo(key), activeKey}`; rail items are `MenuButton`s (`selected={activeKey===key}`);
861
- section wrappers must be DIRECT children of the ScrollView content. On NARROW containers
877
+ jumpTo(key), activeKey}`; rail items are `MenuButton`s (`current={activeKey===key}` — a
878
+ rail item is navigation, never a selected option);
879
+ section wrappers must be DIRECT children of the ScrollView content. The highlight and the
880
+ jump both re-measure the live DOM on web, so content loading in above a section never
881
+ strands them. On NARROW containers
862
882
  the rail becomes a PINNED bar naming the CURRENT section that opens a full-page
863
883
  section-picker `Modal` — never a horizontal tab strip. Worked example:
864
884
  [`tpl_record`](../examples/tpl_record.tsx).
@@ -63,9 +63,12 @@ restyle a heading level per-page.
63
63
  `SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold, with the same `info` ⓘ
64
64
  affordance as `SectionHeadingTitle`; siblings — a `Badge`, a
65
65
  `SectionHeadingMeta`, an action — ride the heading row's right edge). Sibling subsections stack
66
- in a **`SubsectionStack`** (a fixed 32px beat, SPACE-ONLY subsection titles carry the
67
- grouping; hairlines belong to the SECTION level, one rule per altitude; no margins, no
68
- hand-rolled dividers); a headingless `Subsection` is the
66
+ in a **`SubsectionStack`** (a fixed 32px beat; no margins, no hand-rolled dividers).
67
+ It is SPACE-ONLY while the groups are SHORT the titles carry the grouping and hairlines
68
+ belong to the SECTION level, one rule per altitude — and takes `divided` once the groups run
69
+ LONG: past a screenful of rows the 32px beat reads as one more row gap and the next title
70
+ arrives with nothing marking that a new group started. LENGTH is the discriminator, not
71
+ whether the groups are titled. A headingless `Subsection` is the
69
72
  section's lead group. Group leads INSIDE a subsection's rows are `md` medium at most. Do NOT
70
73
  hand-roll `Text weight="semibold"` group leads inside a section, and never promote a subsection
71
74
  to its own section-level heading just to separate it.
@@ -448,13 +451,19 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
448
451
  the band's invisible bottom half must never read as a hole above the description.
449
452
  - **A LINKED record never renders as field rows** — another record's data must not read as
450
453
  fields on THIS one. Use **`LinkedRecordBox`** (`tpl_record`'s Customer/sibling are the worked
451
- examples): a BORDERED, fully pressable box (glyph anchor · identity · facts stacked
452
- vertically) whose press opens the record's detail drawer; the verbs ride its `actions` slot
453
- in a hairline-fenced footer INSIDE the box destructive LEFT in danger, go-to RIGHT. One verb
454
- set across every box of a group (a party wall's five links all read "Gỡ", not a mix of
454
+ examples): a BORDERED box (glyph anchor · identity · facts stacked vertically) whose verbs
455
+ ride its `actions` slot in a hairline-fenced footer INSIDE the box destructive LEFT in
456
+ danger, go-to RIGHT. One verb set across every box of a group (a party wall's five links all read "Gỡ", not a mix of
455
457
  "Đổi"/"Gỡ"): with a clearable link, changing = remove → the empty-state picker re-prompts, so
456
- the destructive verb covers both required anchors and optional adds. Editing the linked record happens on
457
- ITS page, never here.
458
+ the destructive verb covers both required anchors and optional adds.
459
+ **`onOpen` decides whether the box is a DOOR, and the kit makes the two exclusive.** Pass it
460
+ (with `doorLabel`) when the linked record HAS a detail surface: the whole box presses open, its
461
+ facts are TEXT, and editing that record happens on ITS page, never here. OMIT it when the record
462
+ has NO page of its own — the box renders static (no tab stop, no pointer, nothing announced as a
463
+ button), the explicit `actions` verbs are its only affordances, and a `facts` value may then be a
464
+ NODE holding the inline editor, since there is no destination a press could steal. Never wire a
465
+ door "so the box presses": it sends the reader somewhere they did not ask to go and duplicates
466
+ whatever change verb the footer already shows.
458
467
  **The empty / attach state is a find-or-create `Combobox`, never a bare picker** (`tpl_record`'s
459
468
  Customer is the worked example): rows are `renderOptionContent`-rich, NOT a wall of bare names —
460
469
  the premium shape is a **glyph tile (28·radius 7·zinc-100) · the name over a muted metadata line**
@@ -468,13 +477,15 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
468
477
  WITHOUT leaving the record. `customOptionPlacement:"top"` keeps the create row visible while the
469
478
  keyboard highlight stays on the first MATCH, so Enter on a partial never makes a duplicate. A
470
479
  linked record seeded by a workflow (not user-attached) skips the picker — show the box or an
471
- `EmptyState`, no create affordance. The component OWNS the press ANATOMY (the error-prone part hand-rolling
472
- got wrong): the box is a `PressableRow` (role-less, non-focusable surface — hover wash spans
480
+ `EmptyState`, no create affordance. The component OWNS the door variant's press ANATOMY (the
481
+ error-prone part hand-rolling got wrong): the box is a `PressableRow` (role-less, non-focusable surface — hover wash spans
473
482
  the nested verbs) with the keyboard DOOR — an EMPTY absolutely-positioned `role="button"`
474
483
  sibling carrying the tab stop, the accessible name and the focus ring — beneath the content,
475
484
  the interior verbs lifted above it via `zIndex: 1`. NEVER `role="button"` on a container with
476
485
  interactive descendants: a button must not contain a button (invalid HTML, a hydration error,
477
- and AT double-announcement).
486
+ and AT double-announcement). That fenced, lifted footer is the ONLY place a control may sit
487
+ inside a door box — a control in the facts column would compete with the press for the same
488
+ click, which is why a node fact typechecks only on the doorless variant.
478
489
 
479
490
  ## Color discipline — solid / tint / ramp + ONE accent
480
491
 
@@ -346,7 +346,9 @@ level up — money never reads as one pot); each closes with a SEQUENTIAL confir
346
346
  its blockers ("Confirm check-in first."). The header status chip carries the phase.
347
347
 
348
348
  A LONG record surface pairs with a LEFT OUTLINE RAIL — `MenuButton` items + `useSectionNav`
349
- (scroll-spy: jump to a section, the highlight follows the scroll); on narrow containers the rail
349
+ (scroll-spy: jump to a section, the highlight follows the scroll); the active item is
350
+ `current={activeKey === key}`, the NAVIGATION state, never `selected`, which is listbox
351
+ selection. On narrow containers the rail
350
352
  becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
351
353
  per-phase dot on a rail item carries its confirmed state. A direction that is a TYPE (one record
352
354
  per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
package/docs/templates.md CHANGED
@@ -392,8 +392,9 @@ billing, and quick-capture templates. Top → bottom:
392
392
  section; the fenced red `DangerZone` card is the section's BODY.
393
393
  - A LEFT OUTLINE RAIL (`MenuButton` + `useSectionNav`) — **the rail is the page's COMPLETE
394
394
  map** (every section including Danger zone; subsections never join it) — headed by the
395
- bare circular `BackButton`; it scroll-spies the active section, and on narrow containers
396
- becomes the pinned current-section bar opening a section-picker `Modal`.
395
+ bare circular `BackButton`; it scroll-spies the active section (`current={activeKey === key}`
396
+ on each item navigation, not selection), and on narrow containers becomes the pinned
397
+ current-section bar opening a section-picker `Modal` whose items carry the same `current`.
397
398
  - **The page layout is the DOCS layout — the reading column is CENTRED IN THE VIEWPORT, and
398
399
  the rail sits in its left gutter.** The `ScrollView` is FULL-WIDTH — **never cap it at
399
400
  `CONTENT_MAX`, or the scroll container becomes the column and the gutters go wheel-dead**
@@ -2552,7 +2552,7 @@ export function TplRecord() {
2552
2552
  key={sec.key}
2553
2553
  icon={sec.icon}
2554
2554
  title={sec.label}
2555
- selected={nav.activeKey === sec.key}
2555
+ current={nav.activeKey === sec.key}
2556
2556
  onPress={() => {
2557
2557
  setSectionsOpen(false);
2558
2558
  nav.jumpTo(sec.key);
@@ -3075,7 +3075,7 @@ export function TplRecord() {
3075
3075
  <BackButton label="Records" onPress={() => {}} />
3076
3076
  <View style={{ gap: 2 }}>
3077
3077
  {SECTIONS.map((sec) => (
3078
- <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
3078
+ <MenuButton key={sec.key} icon={sec.icon} title={sec.label} current={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
3079
3079
  ))}
3080
3080
  </View>
3081
3081
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "18.0.0",
3
+ "version": "18.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/combobox.tsx CHANGED
@@ -360,12 +360,13 @@ export interface ComboboxInputProps {
360
360
  export function ComboboxInput(props: ComboboxInputProps) {
361
361
  const { icon, placeholder, clearable = false, onClear, clearLabel, accessibilityLabel, testID } = props;
362
362
  const ctx = useComboboxContext("ComboboxInput");
363
- // Select mode (no leading icon): the right slot holds the clear when the field
364
- // carries text (a reflected selection or a live query) AND it's clearable;
365
- // otherwise the trailing chevron, so an EMPTY picker still reads as a picker.
366
- // The two never show together replaces the chevron once there's something to
367
- // clear, and pressing it resets the query + reopens the browse list (via
368
- // `handleChangeText("")`) so the consumer's `onClear` lands back on the full set.
363
+ // `showClear` MIRRORS `TextInputField`'s own clear-✕ condition the field
364
+ // carries text (a reflected selection or a live query) AND it's clearable — so
365
+ // the that renders there and the chevron decided here can never both be up.
366
+ // The chevron is what THIS file owns, and it is select-mode only (no leading
367
+ // icon), so an EMPTY picker still reads as a picker; replaces it once there's
368
+ // something to clear, and pressing that resets the query + reopens the browse
369
+ // list (via `handleChangeText("")`) so `onClear` lands back on the full set.
369
370
  const showClear = clearable && ctx.query.length > 0;
370
371
  const showChevron = !icon && !showClear;
371
372
 
@@ -391,9 +392,12 @@ export function ComboboxInput(props: ComboboxInputProps) {
391
392
  autoCorrect={false}
392
393
  accessibilityLabel={accessibilityLabel}
393
394
  style={[
394
- // Select mode reserves the right gutter for whichever trailing affordance
395
- // is up (chevron when empty, when there's text) so neither overlaps it.
396
- !icon ? styles.selectInput : undefined,
395
+ // Only the CHEVRON's gutter belongs here — it is the one trailing
396
+ // affordance this file owns. `TextInputField` reserves its own slot for
397
+ // the clear ✕, so gating this on "select mode" instead would both
398
+ // over-ride that reserve with the wrong width and leave a SEARCH-mode
399
+ // (`icon`) combobox with no gutter at all.
400
+ showChevron ? styles.withChevron : undefined,
397
401
  // Gate on `showList`, not `open`: an empty query with no options has
398
402
  // `open=true` but renders NO dropdown, so a ring tied to `open` would
399
403
  // show with nothing below it AND linger after blur (no popover fires a
@@ -408,7 +412,7 @@ export function ComboboxInput(props: ComboboxInputProps) {
408
412
  />
409
413
  {showChevron ? (
410
414
  <View style={styles.chevron}>
411
- <Icon name="chevrons-up-down" size={16} color={colors.zinc["400"]} />
415
+ <Icon name="chevrons-up-down" size={CHEVRON_SIZE} color={colors.zinc["400"]} />
412
416
  </View>
413
417
  ) : null}
414
418
  </View>
@@ -562,17 +566,23 @@ export function ComboboxFooter(props: { children: ReactNode }) {
562
566
  return <View style={styles.footer}>{props.children}</View>;
563
567
  }
564
568
 
569
+ /** The chevron is absolutely positioned — out of flow — so the text reserves the
570
+ * span it occupies: the offset, the glyph, and a gap (a bare `Icon` carries no
571
+ * interior padding of its own). One source for the offset and the reserve. */
572
+ const CHEVRON_INSET = 10;
573
+ const CHEVRON_SIZE = 16;
574
+ const CHEVRON_GUTTER = CHEVRON_INSET + CHEVRON_SIZE + 6;
575
+
565
576
  const styles = StyleSheet.create({
566
577
  openRing: {
567
578
  boxShadow: FOCUS_RING,
568
579
  },
569
- // Select mode: room on the right for the trailing chevron.
570
- selectInput: {
571
- paddingRight: 32,
580
+ withChevron: {
581
+ paddingRight: CHEVRON_GUTTER,
572
582
  },
573
583
  chevron: {
574
584
  position: "absolute",
575
- right: 10,
585
+ right: CHEVRON_INSET,
576
586
  top: 0,
577
587
  bottom: 0,
578
588
  justifyContent: "center",
@@ -6,21 +6,21 @@ import { Text } from "./text";
6
6
  import { Icon, type IconName } from "./icon";
7
7
  import { colors } from "./colors";
8
8
 
9
- interface LinkedRecordBoxProps {
9
+ /** One reference fact. `V` is the value the variant admits — text under a door, any node without one. */
10
+ interface LinkedRecordFact<V = ReactNode> {
11
+ label: string;
12
+ value: V;
13
+ }
14
+
15
+ interface LinkedRecordBoxBaseProps {
10
16
  /** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
11
17
  icon: IconName;
12
18
  /** The linked record's display name. */
13
19
  name: string;
14
20
  /** A secondary identity line — a code, tax id, country, or "<desk> record". */
15
21
  subtitle?: string;
16
- /** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). */
17
- facts?: { label: string; value: string }[];
18
22
  /** Width of the facts' label column (default 80). Widen for longer labels. */
19
23
  factLabelWidth?: number;
20
- /** Accessible name for opening the record (e.g. "Acme Corp — details"). */
21
- doorLabel: string;
22
- /** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
23
- onOpen: () => void;
24
24
  /** The verbs, in a hairline-fenced footer at the bottom of the box, lifted above the door's
25
25
  * hit area. Convention: destructive LEFT, go-to RIGHT (a `<View style={{ flex: 1 }} />`
26
26
  * spacer between). The divider is drawn automatically when actions are present. Supplied by
@@ -28,22 +28,61 @@ interface LinkedRecordBoxProps {
28
28
  actions?: ReactNode;
29
29
  }
30
30
 
31
+ /** The box that OPENS — the whole surface is the door into the linked record's own detail. */
32
+ interface LinkedRecordBoxDoorProps extends LinkedRecordBoxBaseProps {
33
+ /** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
34
+ onOpen: () => void;
35
+ /** Accessible name for opening the record (e.g. "Acme Corp — details"). */
36
+ doorLabel: string;
37
+ /** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). TEXT only
38
+ * (`""` renders "—"): the box is one press target, so the only controls it may hold are the
39
+ * `actions` verbs, which the footer fences off and lifts above the door. A fact the reader
40
+ * must EDIT belongs on the linked record's own page — that is what the door is for. */
41
+ facts?: LinkedRecordFact<string>[];
42
+ }
43
+
44
+ /** The box with nothing to open — the linked record has no detail surface of its own. */
45
+ interface LinkedRecordBoxStaticProps extends LinkedRecordBoxBaseProps {
46
+ /** Absent by construction. Typed `never` rather than left off so `doorLabel` can never arrive
47
+ * without a handler — a named door that goes nowhere. */
48
+ onOpen?: never;
49
+ doorLabel?: never;
50
+ /** Reference facts, stacked VERTICALLY. A string renders as text (`""` → "—"); a NODE renders
51
+ * as given (no placeholder), so a fact can carry its own inline editor — safe here precisely
52
+ * because nothing about this box presses. */
53
+ facts?: LinkedRecordFact[];
54
+ }
55
+
56
+ type LinkedRecordBoxProps = LinkedRecordBoxDoorProps | LinkedRecordBoxStaticProps;
57
+
31
58
  /**
32
59
  * A LINKED RECORD shown for reference — a bordered box scoping ANOTHER record's data
33
- * (identity glyph · name · facts), the WHOLE box a keyboard-accessible door into that record's
34
- * detail. Use it wherever one record points at another (a shipment's customer, an invoice's
35
- * party, a case's sibling); the picker / empty-state that REASSIGNS the link is the consumer's,
36
- * shown in place of the box.
60
+ * (identity glyph · name · facts). Use it wherever one record points at another (a shipment's
61
+ * customer, an invoice's party, a case's sibling); the picker / empty-state that REASSIGNS the
62
+ * link is the consumer's, shown in place of the box.
63
+ *
64
+ * `onOpen` picks the variant:
65
+ *
66
+ * - **With `onOpen`** (+ `doorLabel`) the WHOLE box is a keyboard-accessible door into the
67
+ * record's detail. Its facts are TEXT, and the `actions` footer is the one place a control
68
+ * may live — everything else is press-through to the door.
69
+ * - **Without it** the box is a STATIC reference card: no door, no tab stop, no pointer, nothing
70
+ * announced as a button; the only interactive parts are `actions` and whatever a fact node
71
+ * holds. This is the shape for a record with NO page of its own — there is nothing to open, so
72
+ * the box IS where its values are read and edited, and an inline editor in a fact is honest.
37
73
  *
38
- * The a11y contract it enforces (the error-prone part, hence a primitive): a container with
39
- * interactive descendants must NEVER be `role="button"` (invalid HTML). So the box is a
40
- * role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible name /
41
- * focus ring, and the interior verbs lift above it via `zIndex`.
74
+ * The a11y contract the door variant enforces (the error-prone part, hence a primitive): a
75
+ * container with interactive descendants must NEVER be `role="button"` (invalid HTML). So the
76
+ * box is a role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible
77
+ * name / focus ring, and the interior verbs lift above it via `zIndex`.
42
78
  */
43
- export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth = 80, doorLabel, onOpen, actions }: LinkedRecordBoxProps) {
44
- return (
45
- <PressableRow onPress={onOpen} style={styles.box}>
46
- <PressDoor accessibilityLabel={doorLabel} onPress={onOpen} />
79
+ export function LinkedRecordBox(props: LinkedRecordBoxProps) {
80
+ const { icon, name, subtitle, factLabelWidth = 80, actions } = props;
81
+ // A door box's facts are strings, and a string IS a ReactNode — so both variants render
82
+ // through one path typed at the wider value.
83
+ const facts: LinkedRecordFact[] = props.facts ?? [];
84
+ const body = (
85
+ <>
47
86
  <View style={styles.identity}>
48
87
  <View style={styles.glyph}>
49
88
  <Icon name={icon} size={17} color={colors.zinc[600]} />
@@ -53,12 +92,18 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
53
92
  {subtitle ? <Text size="xs" color="muted" numberOfLines={1}>{subtitle}</Text> : null}
54
93
  </View>
55
94
  </View>
56
- {facts && facts.length > 0 ? (
95
+ {facts.length > 0 ? (
57
96
  <View style={{ gap: 6 }}>
58
97
  {facts.map((f) => (
59
98
  <View key={f.label} style={styles.fact}>
60
99
  <Text size="sm" color="muted" style={{ width: factLabelWidth }}>{f.label}</Text>
61
- <Text size="sm" style={{ flex: 1 }}>{f.value || ""}</Text>
100
+ {typeof f.value === "string" ? (
101
+ <Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
102
+ ) : (
103
+ // A node prints as authored — the "—" placeholder is a TEXT affordance, and a
104
+ // control renders its own empty state.
105
+ <View style={{ flex: 1, minWidth: 0 }}>{f.value}</View>
106
+ )}
62
107
  </View>
63
108
  ))}
64
109
  </View>
@@ -70,6 +115,17 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
70
115
  <View style={styles.actions}>{actions}</View>
71
116
  </>
72
117
  ) : null}
118
+ </>
119
+ );
120
+
121
+ // Nothing to open → a plain View, so the box takes no tab stop, announces no role, and paints
122
+ // no pointer/hover promise of a destination it doesn't have.
123
+ if (!props.onOpen) return <View style={styles.box}>{body}</View>;
124
+
125
+ return (
126
+ <PressableRow onPress={props.onOpen} style={styles.box}>
127
+ <PressDoor accessibilityLabel={props.doorLabel} onPress={props.onOpen} />
128
+ {body}
73
129
  </PressableRow>
74
130
  );
75
131
  }
@@ -12,7 +12,23 @@ export interface MenuButtonProps {
12
12
  right?: React.ReactNode;
13
13
  onPress?: () => void;
14
14
  onHoverIn?: () => void;
15
+ /**
16
+ * Persistent SELECTION highlight — the listbox pattern, where the row is an
17
+ * `option` and `aria-selected` is the property that carries its state. On any
18
+ * other role it is the highlight only: `aria-selected` is valid on
19
+ * option/tab/row/gridcell and nowhere else, so it is not emitted there (a menu
20
+ * carries state in the `right` slot — a check glyph).
21
+ */
15
22
  selected?: boolean;
23
+ /**
24
+ * This row is the CURRENT item in a set of NAVIGATION items — the section an
25
+ * outline rail sits on (`useSectionNav`'s `activeKey`), the open pane of a
26
+ * settings dialog. Renders `aria-current`, which is global (valid on any
27
+ * role): `true` is the generic "this is the current one", or name the set it
28
+ * is current WITHIN. Navigation is not selection — a rail item is `current`,
29
+ * a listbox row is `selected`. Both paint the same highlight.
30
+ */
31
+ current?: boolean | "page" | "step" | "location";
16
32
  focused?: boolean;
17
33
  disabled?: boolean;
18
34
  danger?: boolean;
@@ -42,6 +58,7 @@ export function MenuButton(props: MenuButtonProps) {
42
58
  onPress,
43
59
  onHoverIn,
44
60
  selected,
61
+ current,
45
62
  focused,
46
63
  disabled,
47
64
  danger,
@@ -84,18 +101,26 @@ export function MenuButton(props: MenuButtonProps) {
84
101
  </>
85
102
  );
86
103
 
104
+ // Selection and current-ness are different states with the same visual weight
105
+ // — a row is at most one of them, and either owns the resting highlight.
106
+ const highlighted = !!selected || !!current;
87
107
  const containerStyle = [
88
108
  styles.container,
89
- selected && styles.selected,
90
- focused && !selected && styles.focused,
109
+ highlighted && styles.highlighted,
110
+ focused && !highlighted && styles.focused,
91
111
  style,
92
112
  ];
93
113
 
114
+ // `aria-current` takes a TOKEN, and absence — never `false` — is how the other
115
+ // items in the set say "not this one".
116
+ const ariaCurrent = current === true ? "true" : current === false ? undefined : current;
117
+
94
118
  if (onPress) {
95
119
  return (
96
120
  <PressableHighlight
97
121
  focusRing
98
- ref={ref} testID={testID}
122
+ ref={ref}
123
+ testID={testID}
99
124
  nativeID={nativeID}
100
125
  onPress={() => {
101
126
  onPress?.();
@@ -106,7 +131,9 @@ export function MenuButton(props: MenuButtonProps) {
106
131
  style={containerStyle}
107
132
  role={role}
108
133
  accessibilityLabel={resolvedLabel}
109
- aria-selected={!!selected} aria-disabled={disabled || undefined}
134
+ aria-selected={role === "option" ? !!selected : undefined}
135
+ aria-current={ariaCurrent}
136
+ aria-disabled={disabled || undefined}
110
137
  >
111
138
  {inner}
112
139
  </PressableHighlight>
@@ -133,7 +160,7 @@ const styles = StyleSheet.create({
133
160
  flex: 1,
134
161
  alignItems: "flex-start",
135
162
  },
136
- selected: {
163
+ highlighted: {
137
164
  backgroundColor: colors.zinc["100"],
138
165
  },
139
166
  focused: {
@@ -50,6 +50,13 @@ export interface PressableHighlightProps extends PressableProps {
50
50
  * explicitly because the base React Native `PressableProps` type omits it.
51
51
  */
52
52
  onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
53
+ /**
54
+ * The item is the current one in a set of navigation items. Exposed
55
+ * explicitly because React Native's accessibility props carry only the ARIA
56
+ * attributes with a native counterpart, and `aria-current` has none —
57
+ * react-native-web forwards it to the DOM like any other `aria-*` prop.
58
+ */
59
+ "aria-current"?: "true" | "page" | "step" | "location";
53
60
  /**
54
61
  * Pass "none" on row/card surfaces: a pressable surface is a button, not a
55
62
  * text-selection surface — drag jitter on selectable text starts a
@@ -53,7 +53,8 @@ export interface SubsectionProps {
53
53
  * One named group INSIDE a `Section` — the level below `Section` on a long
54
54
  * record surface (`###` in the heading ramp). Compose a `SubsectionHeading`
55
55
  * then the group's rows; sibling subsections stack in a `SubsectionStack`
56
- * (24 + hairline between groups — no margins, no hand-rolled dividers). A
56
+ * (32, space-only while the groups are short — no margins, no hand-rolled
57
+ * dividers; see `SubsectionStack` for when length earns `divided`). A
57
58
  * headingless `Subsection` is fine for the section's lead group.
58
59
  *
59
60
  * <Section>
@@ -4,8 +4,9 @@ import { Divider } from "./divider";
4
4
 
5
5
  interface StackProps {
6
6
  children: React.ReactNode;
7
- /** Hairline between blocks (default true). Turn off for a short surface
8
- * where the beat alone is enough. */
7
+ /** Hairline between blocks. Each stack defaults to what its altitude wants
8
+ * (on for sections, off for subsections); the rule for overriding it is on
9
+ * `SubsectionStack`. */
9
10
  divided?: boolean;
10
11
  style?: StyleProp<ViewStyle>;
11
12
  }
@@ -49,12 +50,17 @@ export function SectionStack(props: SectionStackProps) {
49
50
  export type SubsectionStackProps = StackProps;
50
51
 
51
52
  /**
52
- * The stack of `Subsection` groups INSIDE a `Section` — a fixed 32px beat,
53
- * SPACE-ONLY: subsection titles carry the grouping; hairlines belong to the
54
- * SECTION level (`SectionStack`), one rule per altitude. Compose it as the
55
- * section's body (after the `SectionHeading`); a section's groups divide
56
- * without hand-rolled gaps. `divided` stays an explicit opt-in for a rare
57
- * headingless stack that still needs a rule.
53
+ * The stack of `Subsection` groups INSIDE a `Section` — a fixed 32px beat.
54
+ * Compose it as the section's body (after the `SectionHeading`); a section's
55
+ * groups divide without hand-rolled gaps.
56
+ *
57
+ * SPACE-ONLY while the groups are SHORT (a handful of rows, taken in at a
58
+ * glance): the titles carry the grouping, and hairlines belong to the SECTION
59
+ * level (`SectionStack`), one rule per altitude. Turn `divided` on once the
60
+ * groups are LONG — past a screenful of rows, 32px reads as one more row gap
61
+ * and the next title arrives with nothing marking that a new group started, so
62
+ * the hairline becomes the boundary. LENGTH is the discriminator, not whether
63
+ * the groups are titled: a headingless stack follows the same rule.
58
64
  *
59
65
  * <Section>
60
66
  * <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
@@ -3,12 +3,13 @@ import {
3
3
  TextInput as RNTextInput,
4
4
  TextInputProps as RNTextInputProps,
5
5
  View,
6
+ type LayoutChangeEvent,
6
7
  } from "react-native";
7
8
  import { colors } from "./colors";
8
9
  import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
9
10
  import { useFocusRing, composeHandler } from "./use_focus_ring";
10
11
  import { useHover } from "./use_hover";
11
- import { Ref, useCallback } from "react";
12
+ import { Ref, useCallback, useState } from "react";
12
13
  import { Icon, IconName } from "./icon";
13
14
  import { IconButton } from "./icon_button";
14
15
  import { ShortcutBadge } from "./shortcut_badge";
@@ -99,6 +100,23 @@ export function TextInputField(props: TextInputFieldProps) {
99
100
 
100
101
  const editable = !!(!disabled || inputProps.editable);
101
102
 
103
+ // The ONE source for what occupies the trailing slot — the same flags decide
104
+ // which affordance renders AND how much the text reserves for it. Derived once
105
+ // so the two can never disagree (a gutter gated on anything else is how a long
106
+ // value ends up running underneath the ✕).
107
+ const showClear = !!clearable && !!value;
108
+ // The badge is a hint for reaching an EMPTY field; once there's a value the
109
+ // clear ✕ owns the slot and the hint is spent.
110
+ const showShortcut = !!shortcut && !value;
111
+ // The badge's width is content- AND screen-dependent (⌘B vs Ctrl+Shift+S;
112
+ // `ShortcutBadge` renders NOTHING on small screens), so unlike the fixed-size
113
+ // ✕ its gutter cannot be a constant — it is measured, which also makes the
114
+ // small-screen reserve correctly zero.
115
+ const [shortcutWidth, setShortcutWidth] = useState(0);
116
+ const onShortcutLayout = useCallback((e: LayoutChangeEvent) => {
117
+ setShortcutWidth(e.nativeEvent.layout.width);
118
+ }, []);
119
+
102
120
  const { measure, inputRef } = autoGrowResult;
103
121
 
104
122
  const handleChangeText = useCallback(
@@ -145,6 +163,8 @@ export function TextInputField(props: TextInputFieldProps) {
145
163
  autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
146
164
  !editable && styles.disabled,
147
165
  icon && styles.withIcon,
166
+ showClear && styles.withClear,
167
+ showShortcut && shortcutWidth > 0 && { paddingRight: SHORTCUT_INSET + shortcutWidth },
148
168
  hovered && editable && { borderColor: HOVER_BORDER },
149
169
  style,
150
170
  focusVisible && { boxShadow: FOCUS_RING },
@@ -157,7 +177,7 @@ export function TextInputField(props: TextInputFieldProps) {
157
177
  scrollEnabled={autoGrow ? autoGrowResult.scrollEnabled : undefined}
158
178
  onContentSizeChange={autoGrow ? autoGrowResult.onContentSizeChange : undefined}
159
179
  />
160
- {!!clearable && !!value ? (
180
+ {showClear ? (
161
181
  <IconButton
162
182
  icon="x"
163
183
  tooltip={clearLabel}
@@ -167,8 +187,8 @@ export function TextInputField(props: TextInputFieldProps) {
167
187
  }}
168
188
  style={styles.clear}
169
189
  />
170
- ) : shortcut ? (
171
- <View style={styles.shortcut}>
190
+ ) : showShortcut ? (
191
+ <View style={styles.shortcut} onLayout={onShortcutLayout}>
172
192
  <ShortcutBadge shortcut={shortcut} />
173
193
  </View>
174
194
  ) : null}
@@ -176,6 +196,20 @@ export function TextInputField(props: TextInputFieldProps) {
176
196
  );
177
197
  }
178
198
 
199
+ /**
200
+ * The trailing slot (the clear ✕, the shortcut badge) is ABSOLUTELY POSITIONED —
201
+ * out of flow, so the input's text does not stop at it. Whichever affordance is
202
+ * up, the text reserves the span it occupies; these constants drive BOTH the
203
+ * affordance's `right` offset and the reserved padding, so the two cannot drift
204
+ * apart. A slot standing empty reserves nothing, so an input with no trailing
205
+ * affordance is untouched.
206
+ */
207
+ const CLEAR_INSET = 6;
208
+ /** `IconButton size="md"` is a 28px box around an 18px glyph, so the button's own
209
+ * interior padding IS the visual gap — text may sit against the box edge. */
210
+ const CLEAR_GUTTER = CLEAR_INSET + 28;
211
+ const SHORTCUT_INSET = 12;
212
+
179
213
  const styles = StyleSheet.create({
180
214
  input: {
181
215
  borderRadius: CONTROL_RADIUS,
@@ -200,19 +234,24 @@ const styles = StyleSheet.create({
200
234
  withIcon: {
201
235
  paddingLeft: 34,
202
236
  },
237
+ withClear: {
238
+ paddingRight: CLEAR_GUTTER,
239
+ },
203
240
  icon: {
204
241
  top: 9,
205
242
  left: 8,
206
243
  position: "absolute",
207
244
  },
208
245
  clear: {
209
- top: 6,
210
- right: 6,
246
+ // Pinned to the TOP, not centred: on a multiline/auto-grow field the ✕ must
247
+ // stay reachable at the first line, not float in the middle of a tall box.
248
+ top: CLEAR_INSET,
249
+ right: CLEAR_INSET,
211
250
  position: "absolute",
212
251
  },
213
252
  shortcut: {
214
253
  position: "absolute",
215
- right: 12,
254
+ right: SHORTCUT_INSET,
216
255
  top: 0,
217
256
  bottom: 0,
218
257
  justifyContent: "center",
@@ -4,7 +4,13 @@ import { act, renderHook } from "@testing-library/react";
4
4
  import { useSectionNav } from "./use_section_nav";
5
5
  import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
6
6
 
7
- const layoutAt = (y: number) => ({ nativeEvent: { layout: { y } } }) as LayoutChangeEvent;
7
+ // RN-web's layout event carries the measured node as `nativeEvent.target`; RN's
8
+ // own (native-only) types don't model it, so it goes on after the cast.
9
+ const layoutAt = (y: number, target?: Element) => {
10
+ const e = { nativeEvent: { layout: { y } } } as LayoutChangeEvent;
11
+ if (target) Object.assign(e.nativeEvent, { target });
12
+ return e;
13
+ };
8
14
  const scrollTo = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as NativeSyntheticEvent<NativeScrollEvent>;
9
15
 
10
16
  function mounted() {
@@ -17,6 +23,68 @@ function mounted() {
17
23
  return hook;
18
24
  }
19
25
 
26
+ const rect = (top: number): DOMRect => ({
27
+ top,
28
+ bottom: top,
29
+ left: 0,
30
+ right: 0,
31
+ width: 0,
32
+ height: 0,
33
+ x: 0,
34
+ y: top,
35
+ toJSON: () => ({}),
36
+ });
37
+
38
+ /**
39
+ * A web page in miniature: a scroll container pinned at the viewport top plus
40
+ * section nodes sitting at live content offsets. `scrolledTo` moves the scroll
41
+ * position and every rect together (as a browser does), `shift` is the async
42
+ * content growth that moves sections WITHOUT an `onLayout` — the case a cached
43
+ * offset cannot see. A detached node measures as 0, like a real one.
44
+ */
45
+ function webPage<K extends string>(keys: readonly K[], contentY: Record<K, number>) {
46
+ let scrollY = 0;
47
+ let live: Record<K, number> = { ...contentY };
48
+ const node = document.createElement("div");
49
+ document.body.appendChild(node);
50
+ Object.defineProperty(node, "scrollTop", { get: () => scrollY });
51
+ node.getBoundingClientRect = () => rect(0);
52
+ const els = {} as Record<K, HTMLElement>;
53
+ for (const key of keys) {
54
+ const el = document.createElement("div");
55
+ document.body.appendChild(el);
56
+ el.getBoundingClientRect = () => (el.isConnected ? rect(live[key] - scrollY) : rect(0));
57
+ els[key] = el;
58
+ }
59
+ return {
60
+ node,
61
+ els,
62
+ shift: (next: Partial<Record<K, number>>) => {
63
+ live = { ...live, ...next };
64
+ },
65
+ scrolledTo: (y: number) => {
66
+ scrollY = y;
67
+ return scrollTo(y);
68
+ },
69
+ };
70
+ }
71
+
72
+ /** The registered page above, wired to a hook whose `scrollTo` calls are recorded. */
73
+ function webMounted(contentY: Record<"a" | "b" | "c", number>) {
74
+ const keys = ["a", "b", "c"] as const;
75
+ const page = webPage(keys, contentY);
76
+ const calls: { y: number }[] = [];
77
+ const hook = renderHook(() => useSectionNav(keys));
78
+ (hook.result.current.scrollRef as { current: unknown }).current = {
79
+ getScrollableNode: () => page.node,
80
+ scrollTo: (opts: { y: number }) => calls.push(opts),
81
+ };
82
+ act(() => {
83
+ for (const key of keys) hook.result.current.register(key)(layoutAt(contentY[key], page.els[key]));
84
+ });
85
+ return { result: hook.result, page, calls };
86
+ }
87
+
20
88
  describe("useSectionNav", () => {
21
89
  it("starts on the first key", () => {
22
90
  const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
@@ -66,4 +134,38 @@ describe("useSectionNav", () => {
66
134
  act(() => result.current.jumpTo("a"));
67
135
  expect(calls[1]?.y).toBe(0);
68
136
  });
137
+
138
+ it("on web the highlight follows content that shifted after layout", () => {
139
+ const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
140
+ act(() => result.current.onScroll(page.scrolledTo(321)));
141
+ expect(result.current.activeKey).toBe("b");
142
+
143
+ // a table above b finishes loading and pushes everything below it down 300px
144
+ page.shift({ b: 700, c: 1200 });
145
+ act(() => result.current.onScroll(page.scrolledTo(321)));
146
+ expect(result.current.activeKey).toBe("a");
147
+ act(() => result.current.onScroll(page.scrolledTo(700)));
148
+ expect(result.current.activeKey).toBe("b");
149
+
150
+ // and back: content above collapses, pulling the sections up again
151
+ page.shift({ b: 200, c: 500 });
152
+ act(() => result.current.onScroll(page.scrolledTo(450)));
153
+ expect(result.current.activeKey).toBe("c");
154
+ });
155
+
156
+ it("on web jumpTo lands on the section's shifted position", () => {
157
+ const { result, page, calls } = webMounted({ a: 0, b: 400, c: 900 });
158
+ page.shift({ b: 700 });
159
+ act(() => result.current.jumpTo("b"));
160
+ expect(calls[0]?.y).toBe(688);
161
+ });
162
+
163
+ it("a section removed from the DOM keeps its last offset instead of measuring as the page top", () => {
164
+ const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
165
+ page.els.c.remove();
166
+ // 500 + 80 = 580: past b, still short of c — a detached c measured live
167
+ // would read as the current scroll position and steal the highlight.
168
+ act(() => result.current.onScroll(page.scrolledTo(500)));
169
+ expect(result.current.activeKey).toBe("b");
170
+ });
69
171
  });
@@ -4,7 +4,7 @@ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, Scroll
4
4
  export interface SectionNavHandle<K extends string> {
5
5
  /** Attach to the content `ScrollView` (with `onScroll` + `scrollEventThrottle={16}`). */
6
6
  scrollRef: RefObject<ScrollView | null>;
7
- /** The section the scroll currently sits in — drives the rail's `selected`. */
7
+ /** The section the scroll currently sits in — drives the rail item's `current`. */
8
8
  activeKey: K;
9
9
  /** `onLayout={register(key)}` on each section wrapper. The wrapper must be a
10
10
  * DIRECT child of the ScrollView content (layout.y is content-relative). */
@@ -21,28 +21,30 @@ export interface SectionNavHandle<K extends string> {
21
21
  * top has passed the viewport edge is the active one).
22
22
  *
23
23
  * const nav = useSectionNav(["details", "gatein", "gateout"] as const);
24
- * <MenuButton title="Gate in" selected={nav.activeKey === "gatein"}
24
+ * <MenuButton title="Gate in" current={nav.activeKey === "gatein"}
25
25
  * onPress={() => nav.jumpTo("gatein")} />
26
26
  * <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16}>
27
27
  * <View onLayout={nav.register("gatein")}>…</View>
28
28
  *
29
+ * A rail item is the CURRENT item in a set of navigation links, so it takes
30
+ * `current` (→ `aria-current`), never `selected` (that is listbox selection).
31
+ *
29
32
  * Pass the keys in PAGE ORDER — the spy walks them top-down. Hide the rail on
30
33
  * narrow containers (the page still scrolls; the rail is a wide-screen aid).
31
34
  *
32
35
  * Contract: every key's section stays MOUNTED (an unmounted section leaves its
33
36
  * last offset registered — conditional sections belong inside an always-mounted
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).
37
+ * wrapper that carries the `onLayout`). On WEB both the jump AND the highlight
38
+ * read the live DOM position, so async content growing ABOVE a section (a photo
39
+ * grid, a late-loading table) can never stale either. On native there is no
40
+ * re-measure content above should settle before precision matters
41
+ * (`onLayout` won't refire on a pure position shift).
40
42
  */
41
43
  export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): SectionNavHandle<K> {
42
44
  const scrollRef = useRef<ScrollView>(null);
43
45
  const sectionY = useRef<Partial<Record<K, number>>>({});
44
46
  // 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.
47
+ // exposes it as `nativeEvent.target`, which lets us re-measure live.
46
48
  const sectionEl = useRef<Partial<Record<K, Element>>>({});
47
49
  const [activeKey, setActiveKey] = useState<K>(keys[0]);
48
50
  const register = (key: K) => (e: LayoutChangeEvent) => {
@@ -50,27 +52,52 @@ export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): Sec
50
52
  const target: unknown = (e.nativeEvent as { target?: unknown }).target;
51
53
  if (typeof Element !== "undefined" && target instanceof Element) sectionEl.current[key] = target;
52
54
  };
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];
55
+ /**
56
+ * The sections' CURRENT content-relative offsets: refreshed from the live DOM
57
+ * on web, the cached `layout.y` otherwise. THE source of truth for both the
58
+ * jump and the highlight — a spy reading a different number than the jump is
59
+ * a spy that points at the wrong section.
60
+ *
61
+ * WHY measure on every scroll event rather than trust the cache: `onLayout`
62
+ * does not refire when a section merely MOVES, so any async shift above it (a
63
+ * table finishing load, a disclosure opening, an image settling) strands the
64
+ * cache and the rail highlights the wrong section until something re-measures.
65
+ *
66
+ * WHY that is not the per-frame layout thrash it looks like: reading a rect
67
+ * forces a reflow only while layout is DIRTY — which is exactly the case where
68
+ * the cache would have lied. A pure scroll dirties no layout, so these reads
69
+ * hit a clean tree and cost about a property access. The pass only READS
70
+ * (container once, then each section), so a dirty frame costs ONE flush for
71
+ * all sections, not one per section. Coalescing behind rAF was rejected: it
72
+ * cannot beat "free when nothing changed", and it would make the highlight
73
+ * lag the scroll by a frame while adding a cancel-on-unmount lifecycle.
74
+ */
75
+ const measure = (): Partial<Record<K, number>> => {
57
76
  const sv = scrollRef.current as (ScrollView & { getScrollableNode?: () => unknown }) | null;
58
77
  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;
78
+ if (typeof Element === "undefined" || !(sc instanceof Element)) return sectionY.current;
79
+ // The content origin in viewport coordinates; a section's top minus it is
80
+ // the content-relative offset both `scrollTo` and the spy speak in.
81
+ const origin = sc.getBoundingClientRect().top - sc.scrollTop;
82
+ for (const k of keys) {
83
+ const el = sectionEl.current[k];
84
+ // A detached node measures as 0, which would pin the spy to the last
85
+ // section — a section that broke the mounted contract keeps its last
86
+ // known offset instead, exactly as it does on native.
87
+ if (el?.isConnected) sectionY.current[k] = el.getBoundingClientRect().top - origin;
63
88
  }
64
- return sectionY.current[key] ?? 0;
89
+ return sectionY.current;
65
90
  };
66
91
  const jumpTo = (key: K) => {
67
- scrollRef.current?.scrollTo({ y: Math.max(0, currentY(key) - 12), animated: true });
92
+ const y = measure()[key] ?? 0;
93
+ scrollRef.current?.scrollTo({ y: Math.max(0, y - 12), animated: true });
68
94
  };
69
95
  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
70
96
  const y = e.nativeEvent.contentOffset.y + 80;
97
+ const offsets = measure();
71
98
  let cur: K = keys[0];
72
99
  for (const k of keys) {
73
- const sy = sectionY.current[k];
100
+ const sy = offsets[k];
74
101
  if (sy != null && sy <= y) cur = k;
75
102
  }
76
103
  if (cur !== activeKey) setActiveKey(cur);