@solidrt/components 0.0.72 → 0.0.74

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
@@ -96,10 +96,10 @@ One bullet per module, generated from the first paragraph of its docs/ file.
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
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 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, Escape and the platform's back close 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.
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, Escape and the platform's back close 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
@@ -361,7 +361,7 @@ import { For } from "@solidrt/core"
361
361
  </ScrollView>
362
362
  ```
363
363
 
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. 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:
365
365
 
366
366
  ```tsx
367
367
  let [scroll, setScroll] = createSignal<Scroll>()
@@ -521,9 +521,11 @@ return <Window {...input.handlers}>...</Window>
521
521
 
522
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.
523
523
 
524
- 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
525
 
526
- API: `createFocusNav`, `registerNavAction`, `uiActions`, `uiBindings`, `FocusNavOptions`, `UiActions`, `UiDevices` - typed and commented in [src/focus-nav.ts](./src/focus-nav.ts).
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)`.
527
+
528
+ API: `createFocusNav`, `registerNavAction`, `registerScrollViewport`, `uiActions`, `uiBindings`, `FocusNavOptions`, `UiActions`, `UiDevices` - typed and commented in [src/focus-nav.ts](./src/focus-nav.ts).
527
529
 
528
530
  ### Switch
529
531
 
@@ -727,7 +729,7 @@ API: `Modal`, `ModalProps` - typed and commented in [src/modal.tsx](./src/modal.
727
729
 
728
730
  ### Tooltip
729
731
 
730
- 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.
731
733
 
732
734
  ```jsx
733
735
  import { Tooltip, Button } from "@solidrt/components"
@@ -741,7 +743,7 @@ API: `Tooltip`, `TooltipProps` - typed and commented in [src/tooltip.tsx](./src/
741
743
 
742
744
  ### Select
743
745
 
744
- 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, Escape and the platform's back close 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.
745
747
 
746
748
  ```jsx
747
749
  import { Select } from "@solidrt/components"
@@ -775,7 +777,7 @@ API: `SegmentedControl`, `SegmentedControlProps` - typed and commented in [src/s
775
777
 
776
778
  ### ContextMenu
777
779
 
778
- 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, Escape and the platform's back close 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.
779
781
 
780
782
  ```jsx
781
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, Escape and the platform's back close 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)`.
@@ -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>()
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, Escape and the platform's back close 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"
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.72",
3
+ "version": "0.0.74",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -20,10 +20,10 @@
20
20
  "qrcode-generator": "^2.0.4"
21
21
  },
22
22
  "devDependencies": {
23
- "@solidrt/test": "0.0.72"
23
+ "@solidrt/test": "0.0.74"
24
24
  },
25
25
  "peerDependencies": {
26
- "@solidjs/signals": "2.0.0-rc.13",
27
- "@solidrt/core": "0.0.72"
26
+ "@solidjs/signals": "2.0.0-rc.14",
27
+ "@solidrt/core": "0.0.74"
28
28
  }
29
29
  }
@@ -1,13 +1,14 @@
1
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,8 +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, Escape and the user's back close without
37
- * 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.
38
39
  */
39
40
  export function ContextMenu(props: ContextMenuProps) {
40
41
  let [open, setOpen] = createSignal(false)
@@ -96,23 +97,28 @@ export function ContextMenu(props: ContextMenuProps) {
96
97
  )
97
98
  }
98
99
 
99
- // Anchored at the opening pointer position, flipping up when it would run
100
- // off the bottom. Same reflow-free placement as Tooltip/Select: portal at
101
- // 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.
102
104
  // An overlay face (the sheet sits on a scrim, so it stays flat).
103
105
  let Menu = () => {
104
106
  let menu: { id: number } | undefined
107
+ let list: { id: number } | undefined
105
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
106
112
  onLayout(() => {
107
113
  let b = menu && getBoundingBox(menu)
108
- if (!b) return
109
- let p = point()
110
- let x = Math.round(Math.min(Math.max(p.x, margin()), env.windowSize.width - b.width - margin()))
111
- let y = Math.round(
112
- Math.max(p.y + b.height > env.windowSize.height - margin() ? p.y - b.height : p.y, margin()),
113
- )
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)
114
120
  let cur = pos()
115
- 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 })
116
122
  })
117
123
  // The fade is driven by the position write, not a mount-time `from`: the
118
124
  // menu parks offscreen until measured, so a `from` would play unseen.
@@ -152,19 +158,43 @@ export function ContextMenu(props: ContextMenuProps) {
152
158
  width: theme.components.contextMenu?.borderWidth ?? theme.borderWidth.sm,
153
159
  }}
154
160
  />
155
- <For each={props.items}>
156
- {(item: ContextMenuItem) => <ItemRow item={item} padY={space("sm")} />}
157
- </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>
158
166
  </view>
159
167
  </view>,
160
168
  )
161
169
  }
162
170
 
163
171
  // Bottom sheet over a scrim; content is a sibling of the scrim (Modal's
164
- // trick) so a row press never has the scrim on its hit path.
165
- let Sheet = () =>
166
- createPortal(
167
- <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
+ >
168
198
  <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
169
199
  <d-rect transition={colorFade()} color={theme.color.scrim} />
170
200
  </view>
@@ -184,12 +214,15 @@ export function ContextMenu(props: ContextMenuProps) {
184
214
  material={theme.components.contextMenu?.material}
185
215
  glow={null}
186
216
  />
187
- <For each={props.items}>
188
- {(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.md * 1.5)} />}
189
- </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>
190
222
  </view>
191
223
  </view>,
192
224
  )
225
+ }
193
226
 
194
227
  return (
195
228
  <view
package/src/focus-nav.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  gamepad,
6
6
  getBoundingBoxViewport,
7
7
  getFocusables,
8
+ getLayoutBox,
8
9
  focusedNode,
9
10
  getNodePath,
10
11
  keyboard,
@@ -13,7 +14,7 @@ import {
13
14
  setFocus,
14
15
  untrack,
15
16
  } from "@solidrt/core"
16
- import type { Binding, GamepadDevice, InputMap, KeyboardDevice, Vec2 } from "@solidrt/core"
17
+ import type { Binding, GamepadDevice, InputMap, KeyboardDevice, Scroll, Vec2 } from "@solidrt/core"
17
18
 
18
19
  // Focus navigation: the pointer-free control path, so a TV remote, a
19
20
  // keyboard, or a gamepad can drive an app. Two movement types over the same
@@ -145,6 +146,47 @@ export function registerNavAction(nodeId: number, action: () => void): () => voi
145
146
  }
146
147
  }
147
148
 
149
+ // Scroll viewports, by node id, with the handle that moves them: ScrollView
150
+ // registers its own. The nav consults them to bring a candidate it focuses
151
+ // into view, so a list longer than its box can be walked blind (a pad or
152
+ // remote has no pointer to scroll with).
153
+ let scrollViewports = new Map<number, Scroll>()
154
+
155
+ /**
156
+ * Registers a scroll viewport node and its handle with the focus
157
+ * navigation; returns the unregister. ScrollView does this for itself, so
158
+ * a custom scroller built on createScroll needs it only to have the nav
159
+ * reveal the controls it focuses inside.
160
+ */
161
+ export function registerScrollViewport(nodeId: number, scroll: Scroll): () => void {
162
+ scrollViewports.set(nodeId, scroll)
163
+ return () => {
164
+ if (scrollViewports.get(nodeId) === scroll) scrollViewports.delete(nodeId)
165
+ }
166
+ }
167
+
168
+ // Scrolls every registered viewport above the node, innermost first, by the
169
+ // amount the node's box overshoots the viewport's (window-relative boxes;
170
+ // the delta is scaled back to box pixels through the viewport's untransformed
171
+ // layout box, as ScrollView does for a finger). The viewport's own motion
172
+ // applies: the list springs to the candidate.
173
+ function revealInScrollViews(nodeId: number): void {
174
+ for (let id of getNodePath(nodeId)) {
175
+ let scroll = scrollViewports.get(id)
176
+ if (!scroll) continue
177
+ let nb = getBoundingBoxViewport({ id: nodeId })
178
+ let vb = getBoundingBoxViewport({ id })
179
+ let lb = getLayoutBox({ id })
180
+ if (!nb || !vb || !lb || vb.width === 0 || vb.height === 0) continue
181
+ // Past the start wins over past the end: a node taller than the viewport
182
+ // shows its top.
183
+ let overshoot = (lo: number, hi: number, vlo: number, vhi: number) => (lo < vlo ? lo - vlo : hi > vhi ? hi - vhi : 0)
184
+ let dx = overshoot(nb.x, nb.x + nb.width, vb.x, vb.x + vb.width) * (lb.width / vb.width)
185
+ let dy = overshoot(nb.y, nb.y + nb.height, vb.y, vb.y + vb.height) * (lb.height / vb.height)
186
+ if (dx !== 0 || dy !== 0) scroll.scrollBy({ x: dx, y: dy })
187
+ }
188
+ }
189
+
148
190
  // Open modals register their container here, most recent on top; the top of
149
191
  // the stack is every nav's default scope, so modals trap navigation with no
150
192
  // per-app wiring (see Modal). An explicit `scope` option overrides the stack.
@@ -230,6 +272,7 @@ export function createFocusNav(options?: FocusNavOptions) {
230
272
  let focusCandidate = (p: Placed) => {
231
273
  lastPos = { x: p.x, y: p.y }
232
274
  setFocus(p.id)
275
+ revealInScrollViews(p.id)
233
276
  }
234
277
 
235
278
  // Entry focus with no history (very first press) or where predictability
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ export { Carousel, type CarouselProps } from "./carousel"
12
12
  export { Pressable, type PressableProps, type PressState } from "./pressable"
13
13
  export { createPress, type PressOptions } from "./press"
14
14
  export { Button, type ButtonProps, type ButtonVariant } from "./button"
15
- export { createFocusNav, registerNavAction, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
15
+ export { createFocusNav, registerNavAction, registerScrollViewport, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
16
16
  export { Switch, type SwitchProps } from "./switch"
17
17
  export { Checkbox, type CheckboxProps } from "./checkbox"
18
18
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
package/src/popup.tsx ADDED
@@ -0,0 +1,94 @@
1
+ import { env } from "@solidrt/core"
2
+ import type { BoundingBox, Scroll } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { ScrollView } from "./scroll-view"
5
+
6
+ // The anchored popups (Tooltip, Select's dropdown, ContextMenu) share one
7
+ // placement: portal at the window root, parked offscreen until measured in
8
+ // onLayout, then moved with the x/y paint transforms so tracking the anchor
9
+ // never reflows. The math lives here so every popup keeps the same rules:
10
+ // a side is taken only when it has the room, both axes are clamped to the
11
+ // window margin, and a popup taller than any side caps itself to the room
12
+ // the placement reports.
13
+
14
+ // Distance between an anchor and the popup it opens.
15
+ export let popupGap = () => theme.spacing.sm
16
+ // Minimum distance kept between a popup and the window edges.
17
+ export let popupMargin = () => theme.spacing.sm
18
+
19
+ export type PopupSide = "top" | "bottom"
20
+
21
+ export interface Placement {
22
+ x: number
23
+ y: number
24
+ // The side of the anchor the popup sits on.
25
+ side: PopupSide
26
+ // Height available on that side, between the anchor (plus gap) and the
27
+ // window margin. A popup taller than this caps its content to it; `y`
28
+ // already assumes it does.
29
+ room: number
30
+ }
31
+
32
+ export interface PlaceOptions {
33
+ // Preferred side (default "bottom"). Kept when the popup fits there;
34
+ // otherwise the other side when it fits, else whichever has more room.
35
+ side?: PopupSide
36
+ // Horizontal alignment to the anchor: on its left edge (default) or
37
+ // centered on it. Clamped to the window either way.
38
+ align?: "start" | "center"
39
+ // Distance from the anchor's edge; default popupGap(). A popup anchored
40
+ // at a point (a context menu at the pointer) passes 0.
41
+ gap?: number
42
+ }
43
+
44
+ /**
45
+ * Where a popup of `size` goes next to `anchor`, both in window
46
+ * coordinates (getBoundingBox from the window root, inside onLayout).
47
+ */
48
+ export function placePopup(anchor: BoundingBox, size: { width: number; height: number }, options: PlaceOptions = {}): Placement {
49
+ let gap = options.gap ?? popupGap()
50
+ let margin = popupMargin()
51
+ let window = env.windowSize
52
+ let roomBelow = window.height - margin - (anchor.y + anchor.height + gap)
53
+ let roomAbove = anchor.y - gap - margin
54
+ let preferred = options.side ?? "bottom"
55
+ let fits = (side: PopupSide) => size.height <= (side === "bottom" ? roomBelow : roomAbove)
56
+ let other: PopupSide = preferred === "bottom" ? "top" : "bottom"
57
+ let side: PopupSide = fits(preferred) ? preferred : fits(other) ? other : roomBelow >= roomAbove ? "bottom" : "top"
58
+ let room = side === "bottom" ? roomBelow : roomAbove
59
+ let x = options.align === "center" ? anchor.x + anchor.width / 2 - size.width / 2 : anchor.x
60
+ x = Math.round(Math.min(Math.max(x, margin), window.width - size.width - margin))
61
+ let y = Math.round(side === "bottom" ? anchor.y + anchor.height + gap : anchor.y - gap - Math.min(size.height, room))
62
+ return { x, y, side, room }
63
+ }
64
+
65
+ export interface PopupListProps {
66
+ // The list's height: its rows' natural height capped to the room, as the
67
+ // owner measured and resolved it in its onLayout; null before the first
68
+ // layout.
69
+ height: number | null
70
+ // The rows' column, for the owner to measure (its box is the natural
71
+ // height, whatever the viewport shows).
72
+ listRef?: (node: { id: number }) => void
73
+ scrollRef?: (scroll: Scroll) => void
74
+ children?: any
75
+ }
76
+
77
+ /**
78
+ * The rows of a popup: a column that scrolls once taller than the room it
79
+ * has. A scroll viewport has no natural height (its minimum is 0, so a max
80
+ * alone collapses it: see createScroll), so the owner measures the column
81
+ * through `listRef` and sizes the list explicitly. Until measured it stands
82
+ * at the window height, so the viewport never reads 0 while its popup is
83
+ * still parked offscreen or hidden.
84
+ */
85
+ export function PopupList(props: PopupListProps) {
86
+ let height = () => (props.height == null ? env.windowSize.height : Math.max(0, props.height))
87
+ return (
88
+ <ScrollView layout={{ height: height() }} scrollRef={props.scrollRef}>
89
+ <view ref={props.listRef} flexDirection="column">
90
+ {props.children}
91
+ </view>
92
+ </ScrollView>
93
+ )
94
+ }
@@ -1,7 +1,8 @@
1
- import { callRef, createPan, createScroll, createSignal, getBoundingBoxViewport, getLayoutBox } from "@solidrt/core"
1
+ import { callRef, createPan, createScroll, createSignal, getBoundingBoxViewport, getLayoutBox, onCleanup } from "@solidrt/core"
2
2
  import type { InputProps, LayoutProps, PointerEvent, Scroll, TransitionCurve, TransitionEndEvent, WheelEvent } from "@solidrt/core"
3
3
  import type { StyleProps, TransitionProps, TransitionScrollProp, TransitionStyleProp, TransitionViewProp } from "./types"
4
4
  import { splitTransition, transitionEndFor } from "./types"
5
+ import { registerScrollViewport } from "./focus-nav"
5
6
 
6
7
  export interface ScrollViewProps
7
8
  extends InputProps,
@@ -73,6 +74,10 @@ export function ScrollView(props: ScrollViewProps) {
73
74
  { axis: props.horizontal ? "horizontal" : "vertical" },
74
75
  )
75
76
  callRef(() => props.scrollRef, scroll)
77
+ // The focus navigation brings a control it focuses in the viewport into
78
+ // view; registered from the viewport's ref, released with the component.
79
+ let unregister: (() => void) | undefined
80
+ onCleanup(() => unregister?.())
76
81
 
77
82
  // Content follows the finger: it moves opposite to scroll offsets, which
78
83
  // grow toward the bottom/right. The lift's velocity (parent-frame px/s,
@@ -208,7 +213,10 @@ export function ScrollView(props: ScrollViewProps) {
208
213
  an instant write needs the withdrawn declaration to land before the
209
214
  value in the same flush, or the value starts a spring anyway. */}
210
215
  <view
211
- ref={(n: { id: number }) => (viewport = n)}
216
+ ref={(n: { id: number }) => {
217
+ viewport = n
218
+ unregister = registerScrollViewport(n.id, scroll)
219
+ }}
212
220
  flex={1}
213
221
  overflow="hidden"
214
222
  clipRadius={props.style?.borderRadius}
package/src/select.tsx CHANGED
@@ -1,6 +1,8 @@
1
- import { createEffect, createSignal, createPortal, onBack, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core"
2
- import type { LayoutProps } from "@solidrt/core"
1
+ import { createEffect, createSignal, createPortal, onBack, onCleanup, onLayout, getBoundingBox, Show, For, env, untrack } from "@solidrt/core"
2
+ import type { BoundingBox, LayoutProps, Scroll } from "@solidrt/core"
3
3
  import { createPress } from "./press"
4
+ import { pushNavScope } from "./focus-nav"
5
+ import { placePopup, PopupList } from "./popup"
4
6
  import { theme } from "./theme"
5
7
  import { policy } from "./policy"
6
8
  import { space } from "./spacing"
@@ -8,7 +10,7 @@ import { typeStyle } from "./typography"
8
10
  import type { Option, StyleProps, TransitionProps } from "./types"
9
11
  import { splitTransition, transitionEndFor } from "./types"
10
12
  import { Icon } from "./icon"
11
- import { colorFade, popupFade, popupFadeOut, PressFeedback, travelMotion } from "./motion"
13
+ import { colorFade, popupFadeOut, PressFeedback, travelMotion } from "./motion"
12
14
  import { Surface } from "./surface"
13
15
 
14
16
  export interface SelectProps<T = unknown> extends TransitionProps {
@@ -24,21 +26,22 @@ export interface SelectProps<T = unknown> extends TransitionProps {
24
26
  style?: StyleProps
25
27
  }
26
28
 
27
- // Distance between the trigger and the dropdown.
28
- let gap = () => theme.spacing.sm
29
- // Minimum distance kept between the dropdown and the window edges.
30
- let margin = () => theme.spacing.sm
29
+ // Scrim kept visible above a bottom sheet at its tallest.
30
+ let sheetClearance = () => theme.spacing.xl
31
31
 
32
32
  /**
33
33
  * A single-choice picker whose presentation forks on the interaction policy:
34
- * desktop/hybrid opens an anchored dropdown under the trigger (flipping above
35
- * when there is no room), touch opens a bottom sheet over a scrim. Same
36
- * value/onChange contract either way; pressing outside, Escape and the user's
37
- * back close without a change.
38
- * The option list is not scrollable yet, so keep it short.
34
+ * desktop/hybrid opens an anchored dropdown under the trigger (above it when
35
+ * only that side has the room), touch opens a bottom sheet over a scrim.
36
+ * Same value/onChange contract either way; pressing outside, Escape and the
37
+ * user's back close without a change. A list taller than the room scrolls,
38
+ * opening with the selected option in view, and while open the options are
39
+ * the focus navigation's scope.
39
40
  */
40
41
  export function Select<T>(props: SelectProps<T>) {
41
42
  let trigger: { id: number } | undefined
43
+ // The selected option's row while a presentation is open.
44
+ let selectedRow: { id: number } | undefined
42
45
  let [open, setOpen] = createSignal(false)
43
46
  // The value overload: as far as TypeScript knows T could be a function,
44
47
  // which createSignal would read as a compute; the cast picks the value form.
@@ -73,7 +76,11 @@ export function Select<T>(props: SelectProps<T>) {
73
76
  let press = createPress({ onPress: () => choose(p.option.value) })
74
77
  return (
75
78
  <view
76
- ref={press.ref}
79
+ ref={(n: { id: number }) => {
80
+ press.ref(n)
81
+ // A mount-time snapshot: the row that is selected as the list opens.
82
+ if (p.option.value === untrack(value)) selectedRow = n
83
+ }}
77
84
  repaintBoundary
78
85
  flexDirection="row"
79
86
  alignItems="center"
@@ -94,30 +101,64 @@ export function Select<T>(props: SelectProps<T>) {
94
101
  )
95
102
  }
96
103
 
97
- // Anchored under the trigger, sized at least as wide as it. Positioned like
98
- // Tooltip: portal at the window root, pinned at 0,0 and moved with the x/y
99
- // paint transforms after measuring, so tracking the anchor never reflows.
100
- // Glows with theme.glow.overlay (the sheet sits on a scrim and does not).
104
+ // Once per opening, once the list has its final size (the scroll range
105
+ // matches the rows' overflow, so the write is not clamped against a stale
106
+ // range): the selected row to the middle of the list, without motion.
107
+ let createReveal = () => {
108
+ let done = false
109
+ return (scroll: Scroll | undefined, list: BoundingBox, height: number) => {
110
+ if (done || !scroll) return
111
+ let overflow = Math.max(0, list.height - height)
112
+ if (overflow === 0) {
113
+ done = true
114
+ return
115
+ }
116
+ if (scroll.range().y !== overflow) return
117
+ let row = selectedRow && getBoundingBox(selectedRow)
118
+ done = true
119
+ if (row) scroll.scrollTo({ y: row.y - list.y - (height - row.height) / 2, behavior: "instant" })
120
+ }
121
+ }
122
+
123
+ // Anchored under the trigger, sized at least as wide as it, on the side
124
+ // with room (placePopup): portal at the window root, pinned at 0,0 and
125
+ // moved with the x/y paint transforms after measuring, so tracking the
126
+ // anchor never reflows. The rows scroll once taller than the room. Glows
127
+ // with theme.glow.overlay (the sheet sits on a scrim and does not).
101
128
  let Dropdown = () => {
102
129
  let menu: { id: number } | undefined
130
+ let list: { id: number } | undefined
131
+ let scroll: Scroll | undefined
103
132
  let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
104
133
  let [minWidth, setMinWidth] = createSignal(0)
134
+ let [listHeight, setListHeight] = createSignal<number | null>(null)
135
+ let reveal = createReveal()
136
+ selectedRow = undefined
137
+ // The padding above and below the rows: the popup's height beyond the list's.
138
+ let chrome = () => 2 * theme.spacing.sm
105
139
  onLayout(() => {
106
140
  let a = trigger && getBoundingBox(trigger)
107
141
  let b = menu && getBoundingBox(menu)
108
- if (!a || !b) return
142
+ let l = list && getBoundingBox(list)
143
+ if (!a || !b || !l) return
109
144
  if (a.width !== minWidth()) setMinWidth(a.width)
110
- let x = Math.round(Math.min(Math.max(a.x, margin()), env.windowSize.width - b.width - margin()))
111
- let below = a.y + a.height + gap()
112
- let y = Math.round(below + b.height > env.windowSize.height - margin() ? a.y - b.height - gap() : below)
145
+ let p = placePopup(a, { width: b.width, height: l.height + chrome() })
146
+ let h = Math.min(l.height, p.room - chrome())
147
+ if (h !== listHeight()) setListHeight(h)
113
148
  let cur = pos()
114
- if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
149
+ if (!cur || cur.x !== p.x || cur.y !== p.y) setPos({ x: p.x, y: p.y })
150
+ reveal(scroll, l, h)
115
151
  })
152
+ // Open, the options are the focus navigation's scope (as a Modal is), so
153
+ // a pad or keyboard cannot walk onto the screen behind the dropdown.
154
+ let popScope: (() => void) | undefined
155
+ onCleanup(() => popScope?.())
116
156
  // The fade is driven by the position write, not a mount-time `from`: the
117
157
  // menu parks offscreen until measured, so a `from` would play unseen.
118
158
  // The exit fade lives on this root (the node the close unmounts).
119
159
  return createPortal(
120
160
  <view
161
+ ref={(n: { id: number }) => (popScope = pushNavScope(n))}
121
162
  position="absolute"
122
163
  top={0}
123
164
  left={0}
@@ -148,19 +189,50 @@ export function Select<T>(props: SelectProps<T>) {
148
189
  glow={styled().glow}
149
190
  outline={{ color: theme.color.border, width: theme.borderWidth.sm }}
150
191
  />
151
- <For each={props.options}>
152
- {(o: Option<T>) => <OptionRow option={o} padY={space("sm")} />}
153
- </For>
192
+ <PopupList height={listHeight()} listRef={(n) => (list = n)} scrollRef={(s) => (scroll = s)}>
193
+ <For each={props.options}>
194
+ {(o: Option<T>) => <OptionRow option={o} padY={space("sm")} />}
195
+ </For>
196
+ </PopupList>
154
197
  </view>
155
198
  </view>,
156
199
  )
157
200
  }
158
201
 
159
202
  // Bottom sheet over a scrim; the content is a sibling of the scrim (Modal's
160
- // trick) so pressing an option never has the scrim on its hit path.
161
- let Sheet = () =>
162
- createPortal(
163
- <view position="absolute" top={0} left={0} right={0} bottom={0} opacity={1} transition={popupFade()}>
203
+ // trick) so pressing an option never has the scrim on its hit path. The
204
+ // rows scroll once the sheet would reach the clearance under the top safe
205
+ // area; hidden until measured, as the dropdown is, so the full-height
206
+ // list of the measuring frame is never seen.
207
+ let Sheet = () => {
208
+ let list: { id: number } | undefined
209
+ let scroll: Scroll | undefined
210
+ let [listHeight, setListHeight] = createSignal<number | null>(null)
211
+ let reveal = createReveal()
212
+ selectedRow = undefined
213
+ // The padding above and below the rows: the sheet's height beyond the list's.
214
+ let chrome = () => 2 * theme.spacing.md + env.safeArea.bottom
215
+ onLayout(() => {
216
+ let l = list && getBoundingBox(list)
217
+ if (!l) return
218
+ let room = env.windowSize.height - env.safeArea.top - sheetClearance() - chrome()
219
+ let h = Math.min(l.height, room)
220
+ if (h !== listHeight()) setListHeight(h)
221
+ reveal(scroll, l, h)
222
+ })
223
+ let popScope: (() => void) | undefined
224
+ onCleanup(() => popScope?.())
225
+ return createPortal(
226
+ <view
227
+ ref={(n: { id: number }) => (popScope = pushNavScope(n))}
228
+ position="absolute"
229
+ top={0}
230
+ left={0}
231
+ right={0}
232
+ bottom={0}
233
+ opacity={listHeight() != null ? 1 : 0}
234
+ transition={popupFadeOut()}
235
+ >
164
236
  <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
165
237
  <d-rect transition={colorFade()} color={theme.color.scrim} />
166
238
  </view>
@@ -174,12 +246,15 @@ export function Select<T>(props: SelectProps<T>) {
174
246
  paddingBottom={theme.spacing.md + env.safeArea.bottom}
175
247
  >
176
248
  <Surface role="overlay" fill={theme.color.surface} radius={theme.radius.sm} material={styled().material} glow={null} />
177
- <For each={props.options}>
178
- {(o: Option<T>) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
179
- </For>
249
+ <PopupList height={listHeight()} listRef={(n) => (list = n)} scrollRef={(s) => (scroll = s)}>
250
+ <For each={props.options}>
251
+ {(o: Option<T>) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
252
+ </For>
253
+ </PopupList>
180
254
  </view>
181
255
  </view>,
182
256
  )
257
+ }
183
258
 
184
259
  let press = createPress({
185
260
  onPress: () => {
package/src/tooltip.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, env, children } from "@solidrt/core"
1
+ import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, children } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
+ import { placePopup } from "./popup"
3
4
  import { theme } from "./theme"
4
5
  import { policy } from "./policy"
5
6
  import { space } from "./spacing"
@@ -17,16 +18,13 @@ export interface TooltipProps extends TransitionProps {
17
18
  children?: any
18
19
  // Hover delay in milliseconds before showing.
19
20
  delay?: number
20
- // Which side of the anchor the bubble appears on.
21
+ // Which side of the anchor the bubble prefers; the other side is taken
22
+ // when only it has the room.
21
23
  placement?: "top" | "bottom"
22
24
  layout?: LayoutProps
23
25
  }
24
26
 
25
27
  const DELAY = 500
26
- // Distance between the anchor and the bubble.
27
- let gap = () => theme.spacing.sm
28
- // Minimum distance kept between the bubble and the window edges.
29
- let margin = () => theme.spacing.sm
30
28
 
31
29
  /**
32
30
  * A hover-only affordance: under desktop/hybrid interaction policies, resting a
@@ -57,8 +55,8 @@ export function Tooltip(props: TooltipProps) {
57
55
  // The bubble sits at the window root's origin and is placed with the x/y
58
56
  // paint transforms, so repositioning never reflows. Measured after its first
59
57
  // layout (parked offscreen until then), then pinned to the anchor's current
60
- // box; recomputed each layout so it stays attached when the anchor moves
61
- // (scrolling, resizes).
58
+ // box (placePopup, centered on the anchor); recomputed each layout so it
59
+ // stays attached when the anchor moves (scrolling, resizes).
62
60
  let Bubble = () => {
63
61
  let bubble: { id: number } | undefined
64
62
  let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
@@ -66,11 +64,9 @@ export function Tooltip(props: TooltipProps) {
66
64
  let a = anchor && getBoundingBox(anchor)
67
65
  let b = bubble && getBoundingBox(bubble)
68
66
  if (!a || !b) return
69
- let x = a.x + a.width / 2 - b.width / 2
70
- x = Math.round(Math.min(Math.max(x, margin()), env.windowSize.width - b.width - margin()))
71
- let y = Math.round(props.placement === "bottom" ? a.y + a.height + gap() : a.y - b.height - gap())
67
+ let p = placePopup(a, b, { side: props.placement ?? "top", align: "center" })
72
68
  let cur = pos()
73
- if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
69
+ if (!cur || cur.x !== p.x || cur.y !== p.y) setPos({ x: p.x, y: p.y })
74
70
  })
75
71
  // Resolved once via children(): the typeof probe and the mount sites must
76
72
  // share one build - reading the raw getter again would orphan native nodes.