@solidrt/components 0.0.71 → 0.0.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -73,7 +73,7 @@ One bullet per module, generated from the first paragraph of its docs/ file.
73
73
  - `Text` - Themed text in a layout box. `variant` picks a typography role from the theme's type scale (`caption`/`label`/`body`/`title`/`heading`, default `body`); `color` picks a semantic theme color (`text`, `textMuted`, `primary`, `onPrimary`, `danger`, default `text`), with `muted` as sugar for `color="textMuted"`. Font fields go in `layout` (they affect measurement) and individually override the role; `style.color` still wins over `color`.
74
74
  - `Image` - Loads and displays an image from a URL or raw bytes (`src: string | Uint8Array`). URL loads are shared runtime-wide: mounts of the same URL reuse one fetch and one texture, and the bytes are cached on disk (fetched with `cache: "force-cache"` - no freshness check, so use versioned URLs for content that changes). Concurrent asset fetches are kept polite with a per-host limit; a failed load rejects the mounts sharing it and a later remount retries.
75
75
  - `SafeArea` - Wraps its children in a view padded clear of system UI intrusions (status bars, home indicators, notches). Top and bottom insets are applied by default; pass `false` to opt out of an edge, or a number to apply the inset with that minimum padding.
76
- - `TextInput` - Text input, single-line by default; `multiline` wraps at the field's width and edits across lines (Enter inserts a newline, Up/Down move by line; grows with content up to `maxRows` unless its layout sizes the box, and scrolls to the caret). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue`; `onSubmit` fires on Enter (single-line only). Also `placeholder`, `maxLength`, `autoFocus`, `disabled`, `onFocus`/`onBlur`, and `hints` for IME behavior (keyboard type, capitalization, autocorrect - identifier-like fields want `{ capitalize: "none", autocorrect: false }`). The font fields of `layout` (`fontSize`, `fontFamily`, `lineHeight`, `fontStyle`, `fontWeight`) shape the text as on `Text`, with the rows, caret and scrolling following.
76
+ - `TextInput` - Text input, single-line by default; `multiline` wraps at the field's width and edits across lines (Enter inserts a newline, Up/Down move by line; grows with content up to `maxRows` unless its layout sizes the box, and scrolls to the caret). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue`; `onSubmit` fires on Enter (single-line only), `onCancel` on Escape or the platform's back, each followed by the blur. Also `placeholder`, `maxLength`, `autoFocus`, `disabled`, `onFocus`/`onBlur`, and `hints` for IME behavior (keyboard type, capitalization, autocorrect - identifier-like fields want `{ capitalize: "none", autocorrect: false }`). The font fields of `layout` (`fontSize`, `fontFamily`, `lineHeight`, `fontStyle`, `fontWeight`) shape the text as on `Text`, with the rows, caret and scrolling following.
77
77
  - `RichTextEditor` - Edits a rich text `Document` (styled runs, paragraph attributes) in the same field as `TextInput`: always multiline, same caret, keys, wrapping, scrolling and sizing, and the same font fields on `layout` (the base font the document's runs style from). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue` (start from `plainDocument("")`). Formatting is driven through `editorRef`, which hands you the document buffer - the component ships no toolbar; the app renders its own controls.
78
78
  - `Document model` - The value model behind `RichTextEditor`: a `Document` is `{ text, runs, blocks }` - plain text plus attributed runs (inline formatting) and per-paragraph blocks. `plainDocument(text)` builds one from a string; `createDocumentBuffer(doc)` wraps one in the editing API (`format`, `formatBlock`, `insertAtom`, `attributes`, selection and edits) that `editorRef` hands out. `ATOM` is the inline-atom placeholder character (U+FFFC) for embedded objects.
79
79
  - `ScrollView` - A scrollable region; vertical by default, `horizontal` to flip. Both the wheel and dragging scroll the content: the drag activates after a small movement threshold along the scroll axis, also when it starts on a pressable (the press is cancelled and its feedback retracts), and keeps scrolling when the pointer leaves the box. Scrolling glides: the offset springs to each new target (250 ms, critically damped), so a wheel notch never jumps and a burst of notches reads as one motion; a dragging finger is tracked exactly, without the spring. A lift at speed flings: the content keeps moving from the finger's release speed and decays (iOS's deceleration), as one runtime-side animation to a projected destination, clamped to the range (it slows into an edge, no bounce). A finger landing on a moving list holds it where it is, a tap included.
@@ -95,11 +95,11 @@ One bullet per module, generated from the first paragraph of its docs/ file.
95
95
  - `Spinner` - An indeterminate spinner: a 270-degree arc that rotates continuously, driven by core `onFrame`, so it participates in demand-driven rendering and stops when unmounted. `size` (diameter, default 24), `thickness` (default 3), `speed` (revolutions per second, default 1). Color comes from the theme `primary`; override via `style.color`.
96
96
  - `ProgressBar` - A horizontal progress bar: determinate when given a `value` in `[0, 1]` (the fill grows from the left, gliding to each new value - the `fill` transition entry retimes it), indeterminate when `value` is undefined (a short segment slides back and forth, driven by core `onFrame`). Track is `surfaceAlt`, fill is `primary`; override via `style.backgroundColor` (track) and `style.color` (fill), and box the track with `borderColor`/`borderWidth`. The ends follow `radius.full` through `pill`, and the fill glows with the theme's accent glow.
97
97
  - `Portal` - Renders its child somewhere other than its lexical position: by default at the window root, so overlays (modals, menus, tooltips) escape the clipping and stacking of their surrounding layout; `mount` targets another node captured from a `ref` instead. A thin JSX wrapper over core `createPortal`. The child should be a single element with `position="absolute"`, since it is inserted into the window's flex root. Portals cannot mount during the app's initial render, so gate them behind a signal that starts false.
98
- - `Modal` - A centered overlay rendered at the window root via core `createPortal`: it fills the window with a dimming backdrop (theme `scrim`; override via `backdropColor`, `"transparent"` for no dim) and centers `children` on top, the whole overlay fading in at mount and out on removal (an exiting modal takes no hits). Control visibility by mounting/unmounting it, e.g. `<Show when={open()}>`; the gating signal must start false since portals cannot mount during the initial render. Pressing the backdrop calls `onClose` (unless `dismissable` is false), pressing the content does not, and while mounted the modal traps `createFocusNav` inside itself.
99
- - `Tooltip` - A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, hides on leave and on press, and glows with the theme's overlay glow. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the side (`"top"`, the default, or `"bottom"`).
100
- - `Select` - A single-choice picker whose presentation forks on the interaction policy: `desktop`/`hybrid` opens an anchored dropdown under the trigger (flipping above when there is no room), `touch` opens a bottom sheet over a scrim. Same contract either way: `options` is an `Option[]` (`{ value, label }`), controlled via `value`/`onChange` or uncontrolled via `defaultValue`; pressing outside closes without a change. `placeholder` shows in the trigger while nothing is selected. Both presentations fade in and out, the dropdown glows with the theme's overlay glow, and the trigger's chevron flips while open. The option list is not scrollable yet, so keep it short. The chevron is the `theme.icons.chevronDown` slot when a theme sets one.
98
+ - `Modal` - A centered overlay rendered at the window root via core `createPortal`: it fills the window with a dimming backdrop (theme `scrim`; override via `backdropColor`, `"transparent"` for no dim) and centers `children` on top, the whole overlay fading in at mount and out on removal (an exiting modal takes no hits). Control visibility by mounting/unmounting it, e.g. `<Show when={open()}>`; the gating signal must start false since portals cannot mount during the initial render. Pressing the backdrop, Escape and the platform's back call `onClose` (unless `dismissable` is false, which keeps the modal up and still stops a back from reaching the screen beneath), pressing the content does not, and while mounted the modal traps `createFocusNav` inside itself.
99
+ - `Tooltip` - A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, hides on leave and on press, and glows with the theme's overlay glow. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the preferred side (`"top"`, the default, or `"bottom"`); the other side is taken when only it has the room.
100
+ - `Select` - A single-choice picker whose presentation forks on the interaction policy: `desktop`/`hybrid` opens an anchored dropdown under the trigger (above it when only that side has the room), `touch` opens a bottom sheet over a scrim. Same contract either way: `options` is an `Option[]` (`{ value, label }`), controlled via `value`/`onChange` or uncontrolled via `defaultValue`; pressing outside, Escape and the platform's back close without a change. `placeholder` shows in the trigger while nothing is selected. A list taller than the room it has scrolls, opening with the selected option in view, and while open the options are the focus navigation's scope (as a Modal's content is). Both presentations fade in and out, the dropdown glows with the theme's overlay glow, and the trigger's chevron flips while open. The chevron is the `theme.icons.chevronDown` slot when a theme sets one.
101
101
  - `SegmentedControl` - A single-choice row of equal-width segments joined flush: only the control's outermost corners are rounded, hairline dividers separate the segments, and the active segment is one `primary` indicator that springs between segments on a selection change - the `indicator` transition entry retimes it. Hovered segments tint with the theme `overlayHover` under non-touch interaction policies. `options` is an `Option[]`; controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Override the inactive fill via `style.backgroundColor` and the outer radius via `style.borderRadius`, and box the control with `borderColor`/`borderWidth`. The indicator glows with the theme's accent glow.
102
- - `ContextMenu` - Secondary actions on the wrapped content. The opening gesture follows the physical pointer: right-click for a mouse, long-press (500 ms, cancelled by finger travel; core's long-press recognizer) for touch and pen. The long-press wins the finger at its timer: a pressable inside retracts and does not fire on the lift, and a scroll that started first keeps the finger. The presentation forks on the interaction policy: `touch` gets a bottom sheet over a scrim, `desktop`/`hybrid` an anchored menu at the pointer that flips up near the bottom edge. Both presentations fade in and out, and the anchored menu glows with the theme's overlay glow. `items` is a `ContextMenuItem[]` (`{ label, onSelect?, disabled? }`); pressing outside closes without selecting.
102
+ - `ContextMenu` - Secondary actions on the wrapped content. The opening gesture follows the physical pointer: right-click for a mouse, long-press (500 ms, cancelled by finger travel; core's long-press recognizer) for touch and pen. The long-press wins the finger at its timer: a pressable inside retracts and does not fire on the lift, and a scroll that started first keeps the finger. The presentation forks on the interaction policy: `touch` gets a bottom sheet over a scrim, `desktop`/`hybrid` an anchored menu at the pointer, on the side of it with room. A list taller than the room it has scrolls. Both presentations fade in and out, and the anchored menu glows with the theme's overlay glow. `items` is a `ContextMenuItem[]` (`{ label, onSelect?, disabled? }`); pressing outside, Escape and the platform's back close without selecting.
103
103
  - `NavShell` - An app shell that arranges primary navigation around the content per `policy.navigation`: bottom tabs under it (`bottomTabs`), a narrow rail (`rail`), or a wide sidebar (`sidebar`) beside it. The content is a single stable node; switching arrangement only flips the shell's flex direction and remounts the stateless nav strip, so page state survives a resize across a breakpoint. `items` is a `NavItem[]` (`{ value, label, icon? }`; the icon renders above the label in tabs/rail, beside it in the sidebar); controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Safe areas are the caller's concern: wrap the shell in `SafeArea`. With `@solidrt/router` the shell is the tabs route's component, `value`/`onChange` wired to `useTabs()` (`active()`/`select(path)`) and `<Outlet />` as the content; the router then keeps a stack per tab and the tabs mounted.
104
104
  - `SplitView` - A list-detail container driven by `policy.layout`: `twoPane` shows the `list` pane (width `listWidth`, default `theme.size.splitViewList`) beside the `detail` pane, `singlePane` shows one at a time per `showDetail`. Keep pane state (selection, scroll) in the app, not in the panes: crossing a breakpoint re-arranges and can remount them. It draws no chrome and adds no padding; a back affordance in the single-pane detail is the app's to render (fork on `policy.layout`).
105
105
  - `QrCode` - Renders a QR code for `data` out of primitives: same-color modules in a row collapse into one box, drawn on a light quiet-zone panel; the grid recomputes only when `data` or `level` changes. It paints black on white by default (not the theme) so it stays scannable through a theme switch; override `color`/`background` only if the contrast still holds. `moduleSize` (default 6) is pixels per module, `margin` (default 16) the quiet zone (keep it non-zero), `level` the error correction (`L`/`M`/`Q`/`H`, default `M`: higher tolerates more damage but caps data length sooner), `radius` the panel's corner radius.
package/README.md CHANGED
@@ -276,7 +276,7 @@ API: `SafeArea` - typed and commented in [src/safe-area.tsx](./src/safe-area.tsx
276
276
 
277
277
  ### TextInput
278
278
 
279
- Text input, single-line by default; `multiline` wraps at the field's width and edits across lines (Enter inserts a newline, Up/Down move by line; grows with content up to `maxRows` unless its layout sizes the box, and scrolls to the caret). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue`; `onSubmit` fires on Enter (single-line only). Also `placeholder`, `maxLength`, `autoFocus`, `disabled`, `onFocus`/`onBlur`, and `hints` for IME behavior (keyboard type, capitalization, autocorrect - identifier-like fields want `{ capitalize: "none", autocorrect: false }`). The font fields of `layout` (`fontSize`, `fontFamily`, `lineHeight`, `fontStyle`, `fontWeight`) shape the text as on `Text`, with the rows, caret and scrolling following.
279
+ Text input, single-line by default; `multiline` wraps at the field's width and edits across lines (Enter inserts a newline, Up/Down move by line; grows with content up to `maxRows` unless its layout sizes the box, and scrolls to the caret). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue`; `onSubmit` fires on Enter (single-line only), `onCancel` on Escape or the platform's back, each followed by the blur. Also `placeholder`, `maxLength`, `autoFocus`, `disabled`, `onFocus`/`onBlur`, and `hints` for IME behavior (keyboard type, capitalization, autocorrect - identifier-like fields want `{ capitalize: "none", autocorrect: false }`). The font fields of `layout` (`fontSize`, `fontFamily`, `lineHeight`, `fontStyle`, `fontWeight`) shape the text as on `Text`, with the rows, caret and scrolling following.
280
280
 
281
281
  ```jsx
282
282
  import { TextInput } from "@solidrt/components"
@@ -296,7 +296,11 @@ function NameField() {
296
296
  }
297
297
  ```
298
298
 
299
- `style` overrides the themed colors, border, and radius (`borderWidth: 0` draws no border at all, and no focus ring). The mouse cursor is the I-beam over the field. `autoFocus` focuses on mount (the on-screen keyboard still waits for a tap). A tap anywhere outside the field blurs it, as does Escape.
299
+ `style` overrides the themed colors, border, and radius (`borderWidth: 0` draws no border at all, and no focus ring). The mouse cursor is the I-beam over the field. `autoFocus` focuses on mount (the on-screen keyboard still waits for a tap). A tap anywhere outside the field blurs it.
300
+
301
+ Leaving the field: Enter submits (`onSubmit` with the value, then the blur); Escape and the platform's back (the Android button, a pad's back button) cancel (`onCancel`, then the blur). `onCancel` only reports: the value stays as it is, and reverting it is the app's call. `onBlur` runs after either, and on a tap outside.
302
+
303
+ Keys the focused field keeps for itself, which never reach a parent `onKeyDown` or the window's shortcuts: Backspace, Delete, Left and Right, Home and End, Enter (and a remote's center key), Escape, Ctrl/Cmd+A and Ctrl/Cmd+V, Ctrl/Cmd+C and Ctrl/Cmd+X when text is selected, and every printable key while typing. A multiline field also keeps Up and Down. Everything else bubbles on (Tab, Up and Down in a single-line field, PageUp, function keys, Ctrl+S, ...), so a chat field that wants Up for its history wraps the field in a `View` with an `onKeyDown`.
300
304
 
301
305
  A multiline field sizes like a flex item. Unconstrained, it grows with its content, up to `maxRows` rows and then scrolls. Sized by its layout it is a fixed box that scrolls to the caret: an explicit `height`, or `flexGrow: 1` in a parent with a height (the field fills what is left and scrolls once the text outgrows it), or a parent too small for the content (the field shrinks to fit rather than overflowing).
302
306
 
@@ -357,7 +361,7 @@ import { For } from "@solidrt/core"
357
361
  </ScrollView>
358
362
  ```
359
363
 
360
- `scrollRef` hands out the scroll handle from `createScroll`: `offset()` and `range()` (the largest reachable offset, refreshed each layout) are reactive; `scrollTo({ x, y, behavior })` and `scrollBy({ x, y, behavior })` clamp to the range, an omitted axis stays put, and `behavior: "instant"` writes without the spring (the web's word; `"auto"` and `"smooth"` are the default motion). Scroll policies are written against it in the app. A transcript that opens at its newest message and then follows growth, without yanking a reader who has scrolled back:
364
+ `scrollRef` hands out the scroll handle from `createScroll`: `offset()` and `range()` (the largest reachable offset, refreshed each layout) are reactive; `scrollTo({ x, y, behavior })` and `scrollBy({ x, y, behavior })` clamp to the range, an omitted axis stays put, and `behavior: "instant"` writes without the spring (the web's word; `"auto"` and `"smooth"` are the default motion). Scroll policies are written against it in the app. The focus navigation needs none: a control it focuses inside the view is scrolled into view by the view itself (see `createFocusNav`). A transcript that opens at its newest message and then follows growth, without yanking a reader who has scrolled back:
361
365
 
362
366
  ```tsx
363
367
  let [scroll, setScroll] = createSignal<Scroll>()
@@ -374,7 +378,7 @@ createEffect(
374
378
  <ScrollView scrollRef={setScroll}>...</ScrollView>
375
379
  ```
376
380
 
377
- The range changes whenever the content or the viewport changes size. The first fill (nothing was scrollable before it, whether it mounted with the view or arrived a second later) lands instantly, as a chat opens at its end; after that the view follows the end only if it was at the previous end, and the spring makes that follow a glide. The handle arrives once the component has settled, after an effect's first compute, so hold it in a signal (a setter can be passed as the ref) rather than a plain variable, which the effect would find unset and never track. The offset is read untracked: the policy reacts to the range, not to every scroll.
381
+ The range changes whenever the content or the viewport changes size. The first fill (nothing was scrollable before it, whether it mounted with the view or arrived a second later) lands instantly, as a chat opens at its end; after that the view follows the end only if it was at the previous end, and the spring makes that follow a glide. The handle arrives during mount, outside any reactive scope, so a signal setter can be passed as the ref; holding it in a signal lets the effect track its arrival. The offset is read untracked: the policy reacts to the range, not to every scroll.
378
382
 
379
383
  A `scrollX`/`scrollY` entry in `transition` replaces the default spring: `transition={{ scrollY: { duration: 400, bounce: 0.2 } }}` (keep it a spring rather than a tween, because the wheel retargets mid-flight). The other entries animate the box itself and its background/border as on any component.
380
384
 
@@ -517,9 +521,11 @@ return <Window {...input.handlers}>...</Window>
517
521
 
518
522
  When the focused control disappears (an action replacing it, a screen change), focus lands on the nearest candidate to where it sat as soon as the successor is laid out - the ring follows a Disconnect button into the Connect button that replaces it. A deliberate blur (tapping outside, dismissing the keyboard) stays blurred; the next press resumes at the nearest candidate.
519
523
 
520
- An open `Modal` traps navigation inside itself with no extra wiring (topmost wins when stacked); pass `scope: () => nodeOrNull` to trap into some other subtree instead. `move`/`tab`/`activate` are exposed for custom triggers.
524
+ An open `Modal`, `Select` or its sheet traps navigation inside itself with no extra wiring (topmost wins when stacked); pass `scope: () => nodeOrNull` to trap into some other subtree instead. `move`/`tab`/`activate` are exposed for custom triggers.
525
+
526
+ A candidate the nav focuses is brought into view: every `ScrollView` above it scrolls by the amount its box overshoots the viewport, with the view's own motion, so a list longer than its box can be walked blind. A custom scroller built on `createScroll` joins in with `registerScrollViewport(viewportNodeId, scroll)`.
521
527
 
522
- API: `createFocusNav`, `registerNavAction`, `uiActions`, `uiBindings`, `FocusNavOptions`, `UiActions`, `UiDevices` - typed and commented in [src/focus-nav.ts](./src/focus-nav.ts).
528
+ API: `createFocusNav`, `registerNavAction`, `registerScrollViewport`, `uiActions`, `uiBindings`, `FocusNavOptions`, `UiActions`, `UiDevices` - typed and commented in [src/focus-nav.ts](./src/focus-nav.ts).
523
529
 
524
530
  ### Switch
525
531
 
@@ -705,7 +711,7 @@ API: `Portal`, `PortalProps` - typed and commented in [src/portal.tsx](./src/por
705
711
 
706
712
  ### Modal
707
713
 
708
- A centered overlay rendered at the window root via core `createPortal`: it fills the window with a dimming backdrop (theme `scrim`; override via `backdropColor`, `"transparent"` for no dim) and centers `children` on top, the whole overlay fading in at mount and out on removal (an exiting modal takes no hits). Control visibility by mounting/unmounting it, e.g. `<Show when={open()}>`; the gating signal must start false since portals cannot mount during the initial render. Pressing the backdrop calls `onClose` (unless `dismissable` is false), pressing the content does not, and while mounted the modal traps `createFocusNav` inside itself.
714
+ A centered overlay rendered at the window root via core `createPortal`: it fills the window with a dimming backdrop (theme `scrim`; override via `backdropColor`, `"transparent"` for no dim) and centers `children` on top, the whole overlay fading in at mount and out on removal (an exiting modal takes no hits). Control visibility by mounting/unmounting it, e.g. `<Show when={open()}>`; the gating signal must start false since portals cannot mount during the initial render. Pressing the backdrop, Escape and the platform's back call `onClose` (unless `dismissable` is false, which keeps the modal up and still stops a back from reaching the screen beneath), pressing the content does not, and while mounted the modal traps `createFocusNav` inside itself.
709
715
 
710
716
  ```jsx
711
717
  import { Modal, Card, Button } from "@solidrt/components"
@@ -723,7 +729,7 @@ API: `Modal`, `ModalProps` - typed and commented in [src/modal.tsx](./src/modal.
723
729
 
724
730
  ### Tooltip
725
731
 
726
- A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, hides on leave and on press, and glows with the theme's overlay glow. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the side (`"top"`, the default, or `"bottom"`).
732
+ A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, hides on leave and on press, and glows with the theme's overlay glow. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the preferred side (`"top"`, the default, or `"bottom"`); the other side is taken when only it has the room.
727
733
 
728
734
  ```jsx
729
735
  import { Tooltip, Button } from "@solidrt/components"
@@ -737,7 +743,7 @@ API: `Tooltip`, `TooltipProps` - typed and commented in [src/tooltip.tsx](./src/
737
743
 
738
744
  ### Select
739
745
 
740
- A single-choice picker whose presentation forks on the interaction policy: `desktop`/`hybrid` opens an anchored dropdown under the trigger (flipping above when there is no room), `touch` opens a bottom sheet over a scrim. Same contract either way: `options` is an `Option[]` (`{ value, label }`), controlled via `value`/`onChange` or uncontrolled via `defaultValue`; pressing outside closes without a change. `placeholder` shows in the trigger while nothing is selected. Both presentations fade in and out, the dropdown glows with the theme's overlay glow, and the trigger's chevron flips while open. The option list is not scrollable yet, so keep it short. The chevron is the `theme.icons.chevronDown` slot when a theme sets one.
746
+ A single-choice picker whose presentation forks on the interaction policy: `desktop`/`hybrid` opens an anchored dropdown under the trigger (above it when only that side has the room), `touch` opens a bottom sheet over a scrim. Same contract either way: `options` is an `Option[]` (`{ value, label }`), controlled via `value`/`onChange` or uncontrolled via `defaultValue`; pressing outside, Escape and the platform's back close without a change. `placeholder` shows in the trigger while nothing is selected. A list taller than the room it has scrolls, opening with the selected option in view, and while open the options are the focus navigation's scope (as a Modal's content is). Both presentations fade in and out, the dropdown glows with the theme's overlay glow, and the trigger's chevron flips while open. The chevron is the `theme.icons.chevronDown` slot when a theme sets one.
741
747
 
742
748
  ```jsx
743
749
  import { Select } from "@solidrt/components"
@@ -771,7 +777,7 @@ API: `SegmentedControl`, `SegmentedControlProps` - typed and commented in [src/s
771
777
 
772
778
  ### ContextMenu
773
779
 
774
- Secondary actions on the wrapped content. The opening gesture follows the physical pointer: right-click for a mouse, long-press (500 ms, cancelled by finger travel; core's long-press recognizer) for touch and pen. The long-press wins the finger at its timer: a pressable inside retracts and does not fire on the lift, and a scroll that started first keeps the finger. The presentation forks on the interaction policy: `touch` gets a bottom sheet over a scrim, `desktop`/`hybrid` an anchored menu at the pointer that flips up near the bottom edge. Both presentations fade in and out, and the anchored menu glows with the theme's overlay glow. `items` is a `ContextMenuItem[]` (`{ label, onSelect?, disabled? }`); pressing outside closes without selecting.
780
+ Secondary actions on the wrapped content. The opening gesture follows the physical pointer: right-click for a mouse, long-press (500 ms, cancelled by finger travel; core's long-press recognizer) for touch and pen. The long-press wins the finger at its timer: a pressable inside retracts and does not fire on the lift, and a scroll that started first keeps the finger. The presentation forks on the interaction policy: `touch` gets a bottom sheet over a scrim, `desktop`/`hybrid` an anchored menu at the pointer, on the side of it with room. A list taller than the room it has scrolls. Both presentations fade in and out, and the anchored menu glows with the theme's overlay glow. `items` is a `ContextMenuItem[]` (`{ label, onSelect?, disabled? }`); pressing outside, Escape and the platform's back close without selecting.
775
781
 
776
782
  ```jsx
777
783
  import { ContextMenu } from "@solidrt/components"
@@ -1,6 +1,6 @@
1
1
  # ContextMenu
2
2
 
3
- Secondary actions on the wrapped content. The opening gesture follows the physical pointer: right-click for a mouse, long-press (500 ms, cancelled by finger travel; core's long-press recognizer) for touch and pen. The long-press wins the finger at its timer: a pressable inside retracts and does not fire on the lift, and a scroll that started first keeps the finger. The presentation forks on the interaction policy: `touch` gets a bottom sheet over a scrim, `desktop`/`hybrid` an anchored menu at the pointer that flips up near the bottom edge. Both presentations fade in and out, and the anchored menu glows with the theme's overlay glow. `items` is a `ContextMenuItem[]` (`{ label, onSelect?, disabled? }`); pressing outside closes without selecting.
3
+ Secondary actions on the wrapped content. The opening gesture follows the physical pointer: right-click for a mouse, long-press (500 ms, cancelled by finger travel; core's long-press recognizer) for touch and pen. The long-press wins the finger at its timer: a pressable inside retracts and does not fire on the lift, and a scroll that started first keeps the finger. The presentation forks on the interaction policy: `touch` gets a bottom sheet over a scrim, `desktop`/`hybrid` an anchored menu at the pointer, on the side of it with room. A list taller than the room it has scrolls. Both presentations fade in and out, and the anchored menu glows with the theme's overlay glow. `items` is a `ContextMenuItem[]` (`{ label, onSelect?, disabled? }`); pressing outside, Escape and the platform's back close without selecting.
4
4
 
5
5
  ```jsx
6
6
  import { ContextMenu } from "@solidrt/components"
package/docs/focus-nav.md CHANGED
@@ -28,4 +28,6 @@ return <Window {...input.handlers}>...</Window>
28
28
 
29
29
  When the focused control disappears (an action replacing it, a screen change), focus lands on the nearest candidate to where it sat as soon as the successor is laid out - the ring follows a Disconnect button into the Connect button that replaces it. A deliberate blur (tapping outside, dismissing the keyboard) stays blurred; the next press resumes at the nearest candidate.
30
30
 
31
- An open `Modal` traps navigation inside itself with no extra wiring (topmost wins when stacked); pass `scope: () => nodeOrNull` to trap into some other subtree instead. `move`/`tab`/`activate` are exposed for custom triggers.
31
+ An open `Modal`, `Select` or its sheet traps navigation inside itself with no extra wiring (topmost wins when stacked); pass `scope: () => nodeOrNull` to trap into some other subtree instead. `move`/`tab`/`activate` are exposed for custom triggers.
32
+
33
+ A candidate the nav focuses is brought into view: every `ScrollView` above it scrolls by the amount its box overshoots the viewport, with the view's own motion, so a list longer than its box can be walked blind. A custom scroller built on `createScroll` joins in with `registerScrollViewport(viewportNodeId, scroll)`.
package/docs/modal.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Modal
2
2
 
3
- A centered overlay rendered at the window root via core `createPortal`: it fills the window with a dimming backdrop (theme `scrim`; override via `backdropColor`, `"transparent"` for no dim) and centers `children` on top, the whole overlay fading in at mount and out on removal (an exiting modal takes no hits). Control visibility by mounting/unmounting it, e.g. `<Show when={open()}>`; the gating signal must start false since portals cannot mount during the initial render. Pressing the backdrop calls `onClose` (unless `dismissable` is false), pressing the content does not, and while mounted the modal traps `createFocusNav` inside itself.
3
+ A centered overlay rendered at the window root via core `createPortal`: it fills the window with a dimming backdrop (theme `scrim`; override via `backdropColor`, `"transparent"` for no dim) and centers `children` on top, the whole overlay fading in at mount and out on removal (an exiting modal takes no hits). Control visibility by mounting/unmounting it, e.g. `<Show when={open()}>`; the gating signal must start false since portals cannot mount during the initial render. Pressing the backdrop, Escape and the platform's back call `onClose` (unless `dismissable` is false, which keeps the modal up and still stops a back from reaching the screen beneath), pressing the content does not, and while mounted the modal traps `createFocusNav` inside itself.
4
4
 
5
5
  ```jsx
6
6
  import { Modal, Card, Button } from "@solidrt/components"
@@ -11,7 +11,7 @@ import { For } from "@solidrt/core"
11
11
  </ScrollView>
12
12
  ```
13
13
 
14
- `scrollRef` hands out the scroll handle from `createScroll`: `offset()` and `range()` (the largest reachable offset, refreshed each layout) are reactive; `scrollTo({ x, y, behavior })` and `scrollBy({ x, y, behavior })` clamp to the range, an omitted axis stays put, and `behavior: "instant"` writes without the spring (the web's word; `"auto"` and `"smooth"` are the default motion). Scroll policies are written against it in the app. A transcript that opens at its newest message and then follows growth, without yanking a reader who has scrolled back:
14
+ `scrollRef` hands out the scroll handle from `createScroll`: `offset()` and `range()` (the largest reachable offset, refreshed each layout) are reactive; `scrollTo({ x, y, behavior })` and `scrollBy({ x, y, behavior })` clamp to the range, an omitted axis stays put, and `behavior: "instant"` writes without the spring (the web's word; `"auto"` and `"smooth"` are the default motion). Scroll policies are written against it in the app. The focus navigation needs none: a control it focuses inside the view is scrolled into view by the view itself (see `createFocusNav`). A transcript that opens at its newest message and then follows growth, without yanking a reader who has scrolled back:
15
15
 
16
16
  ```tsx
17
17
  let [scroll, setScroll] = createSignal<Scroll>()
@@ -28,7 +28,7 @@ createEffect(
28
28
  <ScrollView scrollRef={setScroll}>...</ScrollView>
29
29
  ```
30
30
 
31
- The range changes whenever the content or the viewport changes size. The first fill (nothing was scrollable before it, whether it mounted with the view or arrived a second later) lands instantly, as a chat opens at its end; after that the view follows the end only if it was at the previous end, and the spring makes that follow a glide. The handle arrives once the component has settled, after an effect's first compute, so hold it in a signal (a setter can be passed as the ref) rather than a plain variable, which the effect would find unset and never track. The offset is read untracked: the policy reacts to the range, not to every scroll.
31
+ The range changes whenever the content or the viewport changes size. The first fill (nothing was scrollable before it, whether it mounted with the view or arrived a second later) lands instantly, as a chat opens at its end; after that the view follows the end only if it was at the previous end, and the spring makes that follow a glide. The handle arrives during mount, outside any reactive scope, so a signal setter can be passed as the ref; holding it in a signal lets the effect track its arrival. The offset is read untracked: the policy reacts to the range, not to every scroll.
32
32
 
33
33
  A `scrollX`/`scrollY` entry in `transition` replaces the default spring: `transition={{ scrollY: { duration: 400, bounce: 0.2 } }}` (keep it a spring rather than a tween, because the wheel retargets mid-flight). The other entries animate the box itself and its background/border as on any component.
34
34
 
package/docs/select.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Select
2
2
 
3
- A single-choice picker whose presentation forks on the interaction policy: `desktop`/`hybrid` opens an anchored dropdown under the trigger (flipping above when there is no room), `touch` opens a bottom sheet over a scrim. Same contract either way: `options` is an `Option[]` (`{ value, label }`), controlled via `value`/`onChange` or uncontrolled via `defaultValue`; pressing outside closes without a change. `placeholder` shows in the trigger while nothing is selected. Both presentations fade in and out, the dropdown glows with the theme's overlay glow, and the trigger's chevron flips while open. The option list is not scrollable yet, so keep it short. The chevron is the `theme.icons.chevronDown` slot when a theme sets one.
3
+ A single-choice picker whose presentation forks on the interaction policy: `desktop`/`hybrid` opens an anchored dropdown under the trigger (above it when only that side has the room), `touch` opens a bottom sheet over a scrim. Same contract either way: `options` is an `Option[]` (`{ value, label }`), controlled via `value`/`onChange` or uncontrolled via `defaultValue`; pressing outside, Escape and the platform's back close without a change. `placeholder` shows in the trigger while nothing is selected. A list taller than the room it has scrolls, opening with the selected option in view, and while open the options are the focus navigation's scope (as a Modal's content is). Both presentations fade in and out, the dropdown glows with the theme's overlay glow, and the trigger's chevron flips while open. The chevron is the `theme.icons.chevronDown` slot when a theme sets one.
4
4
 
5
5
  ```jsx
6
6
  import { Select } from "@solidrt/components"
@@ -1,6 +1,6 @@
1
1
  # TextInput
2
2
 
3
- Text input, single-line by default; `multiline` wraps at the field's width and edits across lines (Enter inserts a newline, Up/Down move by line; grows with content up to `maxRows` unless its layout sizes the box, and scrolls to the caret). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue`; `onSubmit` fires on Enter (single-line only). Also `placeholder`, `maxLength`, `autoFocus`, `disabled`, `onFocus`/`onBlur`, and `hints` for IME behavior (keyboard type, capitalization, autocorrect - identifier-like fields want `{ capitalize: "none", autocorrect: false }`). The font fields of `layout` (`fontSize`, `fontFamily`, `lineHeight`, `fontStyle`, `fontWeight`) shape the text as on `Text`, with the rows, caret and scrolling following.
3
+ Text input, single-line by default; `multiline` wraps at the field's width and edits across lines (Enter inserts a newline, Up/Down move by line; grows with content up to `maxRows` unless its layout sizes the box, and scrolls to the caret). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue`; `onSubmit` fires on Enter (single-line only), `onCancel` on Escape or the platform's back, each followed by the blur. Also `placeholder`, `maxLength`, `autoFocus`, `disabled`, `onFocus`/`onBlur`, and `hints` for IME behavior (keyboard type, capitalization, autocorrect - identifier-like fields want `{ capitalize: "none", autocorrect: false }`). The font fields of `layout` (`fontSize`, `fontFamily`, `lineHeight`, `fontStyle`, `fontWeight`) shape the text as on `Text`, with the rows, caret and scrolling following.
4
4
 
5
5
  ```jsx
6
6
  import { TextInput } from "@solidrt/components"
@@ -20,7 +20,11 @@ function NameField() {
20
20
  }
21
21
  ```
22
22
 
23
- `style` overrides the themed colors, border, and radius (`borderWidth: 0` draws no border at all, and no focus ring). The mouse cursor is the I-beam over the field. `autoFocus` focuses on mount (the on-screen keyboard still waits for a tap). A tap anywhere outside the field blurs it, as does Escape.
23
+ `style` overrides the themed colors, border, and radius (`borderWidth: 0` draws no border at all, and no focus ring). The mouse cursor is the I-beam over the field. `autoFocus` focuses on mount (the on-screen keyboard still waits for a tap). A tap anywhere outside the field blurs it.
24
+
25
+ Leaving the field: Enter submits (`onSubmit` with the value, then the blur); Escape and the platform's back (the Android button, a pad's back button) cancel (`onCancel`, then the blur). `onCancel` only reports: the value stays as it is, and reverting it is the app's call. `onBlur` runs after either, and on a tap outside.
26
+
27
+ Keys the focused field keeps for itself, which never reach a parent `onKeyDown` or the window's shortcuts: Backspace, Delete, Left and Right, Home and End, Enter (and a remote's center key), Escape, Ctrl/Cmd+A and Ctrl/Cmd+V, Ctrl/Cmd+C and Ctrl/Cmd+X when text is selected, and every printable key while typing. A multiline field also keeps Up and Down. Everything else bubbles on (Tab, Up and Down in a single-line field, PageUp, function keys, Ctrl+S, ...), so a chat field that wants Up for its history wraps the field in a `View` with an `onKeyDown`.
24
28
 
25
29
  A multiline field sizes like a flex item. Unconstrained, it grows with its content, up to `maxRows` rows and then scrolls. Sized by its layout it is a fixed box that scrolls to the caret: an explicit `height`, or `flexGrow: 1` in a parent with a height (the field fills what is left and scrolls once the text outgrows it), or a parent too small for the content (the field shrinks to fit rather than overflowing).
26
30
 
package/docs/tooltip.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tooltip
2
2
 
3
- A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, hides on leave and on press, and glows with the theme's overlay glow. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the side (`"top"`, the default, or `"bottom"`).
3
+ A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, hides on leave and on press, and glows with the theme's overlay glow. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the preferred side (`"top"`, the default, or `"bottom"`); the other side is taken when only it has the room.
4
4
 
5
5
  ```jsx
6
6
  import { Tooltip, Button } from "@solidrt/components"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.71",
3
+ "version": "0.0.73",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -19,8 +19,11 @@
19
19
  "dependencies": {
20
20
  "qrcode-generator": "^2.0.4"
21
21
  },
22
+ "devDependencies": {
23
+ "@solidrt/test": "0.0.73"
24
+ },
22
25
  "peerDependencies": {
23
- "@solidjs/signals": "2.0.0-rc.13",
24
- "@solidrt/core": "0.0.71"
26
+ "@solidjs/signals": "2.0.0-rc.14",
27
+ "@solidrt/core": "0.0.73"
25
28
  }
26
29
  }
package/src/card.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Show, createRadialGradient, createSignal, getLayoutBox, withAlpha } from "@solidrt/core"
1
+ import { Show, createRadialGradient, createSignal, getLayoutBox, untrack, withAlpha } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { policy } from "./policy"
@@ -78,7 +78,10 @@ export function Card(props: CardProps) {
78
78
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
79
79
  ref={(n: { id: number }) => {
80
80
  node = n
81
- props.ref?.(n)
81
+ // A ref callback runs in the element's owned scope, where a prop
82
+ // read warns in dev (STRICT_READ_UNTRACKED); the caller's ref is a
83
+ // one-shot, read untracked.
84
+ untrack(() => props.ref)?.(n)
82
85
  }}
83
86
  repaintBoundary
84
87
  flexDirection="column"
package/src/carousel.tsx CHANGED
@@ -82,7 +82,10 @@ export function Carousel(props: CarouselProps) {
82
82
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
83
83
  ref={(n: { id: number }) => {
84
84
  node = n
85
- props.ref?.(n)
85
+ // A ref callback runs in the element's owned scope, where a prop
86
+ // read warns in dev (STRICT_READ_UNTRACKED); the caller's ref is a
87
+ // one-shot, read untracked.
88
+ untrack(() => props.ref)?.(n)
86
89
  }}
87
90
  overflow="hidden"
88
91
  {...props.layout}
@@ -1,13 +1,14 @@
1
- import { createSignal, createLongPress, createPortal, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core"
1
+ import { createEffect, createSignal, createLongPress, createPortal, onBack, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
3
  import { createPress } from "./press"
4
+ import { placePopup, PopupList } from "./popup"
4
5
  import { theme } from "./theme"
5
6
  import { policy } from "./policy"
6
7
  import { space } from "./spacing"
7
8
  import { typeStyle } from "./typography"
8
9
  import type { TransitionProps } from "./types"
9
10
  import { splitTransition, transitionEndFor } from "./types"
10
- import { colorFade, popupFade, popupFadeOut, PressFeedback } from "./motion"
11
+ import { colorFade, popupFadeOut, PressFeedback } from "./motion"
11
12
  import { Surface } from "./surface"
12
13
 
13
14
  export interface ContextMenuItem {
@@ -23,8 +24,8 @@ export interface ContextMenuProps extends TransitionProps {
23
24
  layout?: LayoutProps
24
25
  }
25
26
 
26
- // Minimum distance kept between the menu and the window edges.
27
- let margin = () => theme.spacing.sm
27
+ // Scrim kept visible above a bottom sheet at its tallest.
28
+ let sheetClearance = () => theme.spacing.xl
28
29
 
29
30
  /**
30
31
  * Secondary actions on the wrapped content. The opening gesture follows the
@@ -33,7 +34,8 @@ let margin = () => theme.spacing.sm
33
34
  * pressable inside retracts and never fires, and a scroll that started
34
35
  * first keeps it). The presentation forks on the interaction policy: touch
35
36
  * gets a bottom sheet over a scrim, desktop/hybrid an anchored menu at the
36
- * pointer. Pressing outside closes without selecting.
37
+ * pointer, on the side with room. A list taller than the room scrolls.
38
+ * Pressing outside, Escape and the user's back close without selecting.
37
39
  */
38
40
  export function ContextMenu(props: ContextMenuProps) {
39
41
  let [open, setOpen] = createSignal(false)
@@ -55,6 +57,19 @@ export function ContextMenu(props: ContextMenuProps) {
55
57
  item.onSelect?.()
56
58
  }
57
59
 
60
+ // Open, the menu is one step of core's back stack: Escape and the user's
61
+ // back close it as a press outside does, selecting nothing.
62
+ createEffect(
63
+ () => open(),
64
+ (isOpen) => {
65
+ if (!isOpen) return
66
+ return onBack((e) => {
67
+ e.preventDefault()
68
+ setOpen(false)
69
+ })
70
+ },
71
+ )
72
+
58
73
  let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 })
59
74
 
60
75
  let ItemRow = (p: { item: ContextMenuItem; padY: number }) => {
@@ -82,23 +97,28 @@ export function ContextMenu(props: ContextMenuProps) {
82
97
  )
83
98
  }
84
99
 
85
- // Anchored at the opening pointer position, flipping up when it would run
86
- // off the bottom. Same reflow-free placement as Tooltip/Select: portal at
87
- // the window root, measured in onLayout, moved with x/y paint transforms.
100
+ // Anchored at the opening pointer position, on the side with room
101
+ // (placePopup, a point anchor with no gap). Same reflow-free placement as
102
+ // Tooltip/Select: portal at the window root, measured in onLayout, moved
103
+ // with x/y paint transforms. The rows scroll once taller than the room.
88
104
  // An overlay face (the sheet sits on a scrim, so it stays flat).
89
105
  let Menu = () => {
90
106
  let menu: { id: number } | undefined
107
+ let list: { id: number } | undefined
91
108
  let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
109
+ let [listHeight, setListHeight] = createSignal<number | null>(null)
110
+ // The padding above and below the rows: the menu's height beyond the list's.
111
+ let chrome = () => 2 * theme.spacing.sm
92
112
  onLayout(() => {
93
113
  let b = menu && getBoundingBox(menu)
94
- if (!b) return
95
- let p = point()
96
- let x = Math.round(Math.min(Math.max(p.x, margin()), env.windowSize.width - b.width - margin()))
97
- let y = Math.round(
98
- Math.max(p.y + b.height > env.windowSize.height - margin() ? p.y - b.height : p.y, margin()),
99
- )
114
+ let l = list && getBoundingBox(list)
115
+ if (!b || !l) return
116
+ let at = point()
117
+ let p = placePopup({ x: at.x, y: at.y, width: 0, height: 0 }, { width: b.width, height: l.height + chrome() }, { gap: 0 })
118
+ let h = Math.min(l.height, p.room - chrome())
119
+ if (h !== listHeight()) setListHeight(h)
100
120
  let cur = pos()
101
- if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
121
+ if (!cur || cur.x !== p.x || cur.y !== p.y) setPos({ x: p.x, y: p.y })
102
122
  })
103
123
  // The fade is driven by the position write, not a mount-time `from`: the
104
124
  // menu parks offscreen until measured, so a `from` would play unseen.
@@ -138,19 +158,43 @@ export function ContextMenu(props: ContextMenuProps) {
138
158
  width: theme.components.contextMenu?.borderWidth ?? theme.borderWidth.sm,
139
159
  }}
140
160
  />
141
- <For each={props.items}>
142
- {(item: ContextMenuItem) => <ItemRow item={item} padY={space("sm")} />}
143
- </For>
161
+ <PopupList height={listHeight()} listRef={(n) => (list = n)}>
162
+ <For each={props.items}>
163
+ {(item: ContextMenuItem) => <ItemRow item={item} padY={space("sm")} />}
164
+ </For>
165
+ </PopupList>
144
166
  </view>
145
167
  </view>,
146
168
  )
147
169
  }
148
170
 
149
171
  // Bottom sheet over a scrim; content is a sibling of the scrim (Modal's
150
- // trick) so a row press never has the scrim on its hit path.
151
- let Sheet = () =>
152
- createPortal(
153
- <view position="absolute" top={0} left={0} right={0} bottom={0} opacity={1} transition={popupFade()}>
172
+ // trick) so a row press never has the scrim on its hit path. The rows
173
+ // scroll once the sheet would reach the clearance under the top safe
174
+ // area; hidden until measured, as the menu is, so the full-height list
175
+ // of the measuring frame is never seen.
176
+ let Sheet = () => {
177
+ let list: { id: number } | undefined
178
+ let [listHeight, setListHeight] = createSignal<number | null>(null)
179
+ // The padding above and below the rows: the sheet's height beyond the list's.
180
+ let chrome = () => 2 * theme.spacing.md + env.safeArea.bottom
181
+ onLayout(() => {
182
+ let l = list && getBoundingBox(list)
183
+ if (!l) return
184
+ let room = env.windowSize.height - env.safeArea.top - sheetClearance() - chrome()
185
+ let h = Math.min(l.height, room)
186
+ if (h !== listHeight()) setListHeight(h)
187
+ })
188
+ return createPortal(
189
+ <view
190
+ position="absolute"
191
+ top={0}
192
+ left={0}
193
+ right={0}
194
+ bottom={0}
195
+ opacity={listHeight() != null ? 1 : 0}
196
+ transition={popupFadeOut()}
197
+ >
154
198
  <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
155
199
  <d-rect transition={colorFade()} color={theme.color.scrim} />
156
200
  </view>
@@ -170,12 +214,15 @@ export function ContextMenu(props: ContextMenuProps) {
170
214
  material={theme.components.contextMenu?.material}
171
215
  glow={null}
172
216
  />
173
- <For each={props.items}>
174
- {(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.md * 1.5)} />}
175
- </For>
217
+ <PopupList height={listHeight()} listRef={(n) => (list = n)}>
218
+ <For each={props.items}>
219
+ {(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.md * 1.5)} />}
220
+ </For>
221
+ </PopupList>
176
222
  </view>
177
223
  </view>,
178
224
  )
225
+ }
179
226
 
180
227
  return (
181
228
  <view
@@ -1,4 +1,4 @@
1
- import { createSignal, createSwipe, getLayoutBox } from "@solidrt/core"
1
+ import { createSignal, createSwipe, getLayoutBox, untrack } from "@solidrt/core"
2
2
  import type { LayoutProps, SwipeDirection, TransitionEndEvent } from "@solidrt/core"
3
3
  import type { StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
4
4
  import { splitTransition, transitionEndFor } from "./types"
@@ -98,7 +98,10 @@ export function Dismissible(props: DismissibleProps) {
98
98
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
99
99
  ref={(n: { id: number }) => {
100
100
  node = n
101
- props.ref?.(n)
101
+ // A ref callback runs in the element's owned scope, where a prop
102
+ // read warns in dev (STRICT_READ_UNTRACKED); the caller's ref is a
103
+ // one-shot, read untracked.
104
+ untrack(() => props.ref)?.(n)
102
105
  }}
103
106
  overflow="hidden"
104
107
  {...props.layout}
@@ -12,6 +12,7 @@ import {
12
12
  createSignal,
13
13
  onCleanup,
14
14
  focusedNode,
15
+ onBack,
15
16
  setFocus,
16
17
  startTextInput,
17
18
  textInputActive,
@@ -63,6 +64,8 @@ export interface EditorFieldProps extends TransitionProps {
63
64
  renderLine: (r: LineRender) => Element
64
65
 
65
66
  onSubmit?: (value: string) => void
67
+ /** The user backed out of the field (Escape, or the platform's back): fires, then the field blurs. The value is left as it is. */
68
+ onCancel?: () => void
66
69
  onFocus?: () => void
67
70
  onBlur?: () => void
68
71
  placeholder?: string
@@ -91,7 +94,8 @@ export interface EditorFieldProps extends TransitionProps {
91
94
  // range replace it (the buffer's own behavior). Ctrl/Cmd+C/X/V go through
92
95
  // navigator.clipboard (copy and cut need a range; single-line paste flattens
93
96
  // line breaks). A tap outside the field blurs it (core's outside-tap blur in
94
- // window.ts), as does Escape.
97
+ // window.ts). Escape and the user's back (core's onBack stack: the Android
98
+ // button, the pad's back) cancel: onCancel fires, then the field blurs.
95
99
  // Font options compared field by field: the same keys with the same values.
96
100
  function sameFont(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
97
101
  let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
@@ -324,7 +328,7 @@ export function EditorField(props: EditorFieldProps) {
324
328
  // The remote center key's `key` is "Unidentified"; match its code.
325
329
  activateField()
326
330
  } else if (e.key === "Escape") {
327
- if (node) setFocus(null)
331
+ cancel()
328
332
  } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && textInputActive()) {
329
333
  // A printable key is this field's text (it arrives as a text-input
330
334
  // event); consumed here so a typed Space never reaches the window as
@@ -371,6 +375,28 @@ export function EditorField(props: EditorFieldProps) {
371
375
  }
372
376
  }
373
377
 
378
+ // Cancel mirrors submit: the callback, then the blur. Escape reaches it
379
+ // through the key handler (the focused field consumes the key, so a window
380
+ // shortcut on Escape never fires on the same press); the user's back
381
+ // through the back stack, where the field is a step while focused. Focus
382
+ // comes after any enclosing overlay mounted, so the field tops the stack
383
+ // and a back leaves the field before it closes the overlay.
384
+ let cancel = () => {
385
+ props.onCancel?.()
386
+ setFocus(null)
387
+ }
388
+
389
+ createEffect(
390
+ () => focused(),
391
+ (isFocused) => {
392
+ if (!isFocused) return
393
+ return onBack((e) => {
394
+ e.preventDefault()
395
+ cancel()
396
+ })
397
+ },
398
+ )
399
+
374
400
  let unregisterNav: (() => void) | null = null
375
401
 
376
402
  onCleanup(() => {