@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 +35 -15
- package/docs/composition.md +23 -12
- package/docs/data_entry.md +3 -1
- package/docs/templates.md +3 -2
- package/examples/tpl_record.tsx +2 -2
- package/package.json +1 -1
- package/src/combobox.tsx +24 -14
- package/src/linked_record_box.tsx +77 -21
- package/src/menu_button.tsx +32 -5
- package/src/pressable_highlight.tsx +7 -0
- package/src/section_heading.tsx +2 -1
- package/src/section_stack.tsx +14 -8
- package/src/text_input_field.tsx +46 -7
- package/src/use_section_nav.test.ts +103 -1
- package/src/use_section_nav.ts +47 -20
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
|
-
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
`
|
|
458
|
-
|
|
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`)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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 (`
|
|
861
|
-
|
|
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).
|
package/docs/composition.md
CHANGED
|
@@ -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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
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.
|
|
457
|
-
|
|
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
|
|
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
|
|
package/docs/data_entry.md
CHANGED
|
@@ -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);
|
|
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
|
|
396
|
-
|
|
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**
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -2552,7 +2552,7 @@ export function TplRecord() {
|
|
|
2552
2552
|
key={sec.key}
|
|
2553
2553
|
icon={sec.icon}
|
|
2554
2554
|
title={sec.label}
|
|
2555
|
-
|
|
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}
|
|
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
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
|
-
//
|
|
364
|
-
// carries text (a reflected selection or a live query) AND it's clearable
|
|
365
|
-
//
|
|
366
|
-
// The
|
|
367
|
-
//
|
|
368
|
-
//
|
|
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
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
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={
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
paddingRight: 32,
|
|
580
|
+
withChevron: {
|
|
581
|
+
paddingRight: CHEVRON_GUTTER,
|
|
572
582
|
},
|
|
573
583
|
chevron: {
|
|
574
584
|
position: "absolute",
|
|
575
|
-
right:
|
|
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
|
-
|
|
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)
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
39
|
-
* interactive descendants must NEVER be `role="button"` (invalid HTML). So the
|
|
40
|
-
* role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible
|
|
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(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/menu_button.tsx
CHANGED
|
@@ -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
|
-
|
|
90
|
-
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}
|
|
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
|
|
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
|
-
|
|
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
|
package/src/section_heading.tsx
CHANGED
|
@@ -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
|
-
* (
|
|
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>
|
package/src/section_stack.tsx
CHANGED
|
@@ -4,8 +4,9 @@ import { Divider } from "./divider";
|
|
|
4
4
|
|
|
5
5
|
interface StackProps {
|
|
6
6
|
children: React.ReactNode;
|
|
7
|
-
/** Hairline between blocks
|
|
8
|
-
*
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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>
|
package/src/text_input_field.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
) :
|
|
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
|
-
|
|
210
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
});
|
package/src/use_section_nav.ts
CHANGED
|
@@ -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 `
|
|
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"
|
|
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
|
|
35
|
-
* DOM position, so async content growing ABOVE a section (a photo
|
|
36
|
-
* late-loading table) can never stale
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
89
|
+
return sectionY.current;
|
|
65
90
|
};
|
|
66
91
|
const jumpTo = (key: K) => {
|
|
67
|
-
|
|
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 =
|
|
100
|
+
const sy = offsets[k];
|
|
74
101
|
if (sy != null && sy <= y) cur = k;
|
|
75
102
|
}
|
|
76
103
|
if (cur !== activeKey) setActiveKey(cur);
|