@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 +3 -3
- package/README.md +8 -6
- package/docs/context-menu.md +1 -1
- package/docs/focus-nav.md +3 -1
- package/docs/scroll-view.md +1 -1
- package/docs/select.md +1 -1
- package/docs/tooltip.md +1 -1
- package/package.json +4 -4
- package/src/context-menu.tsx +58 -25
- package/src/focus-nav.ts +44 -1
- package/src/index.ts +1 -1
- package/src/popup.tsx +94 -0
- package/src/scroll-view.tsx +10 -2
- package/src/select.tsx +107 -32
- package/src/tooltip.tsx +8 -12
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 (
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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"
|
package/docs/context-menu.md
CHANGED
|
@@ -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
|
|
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/scroll-view.md
CHANGED
|
@@ -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 (
|
|
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.
|
|
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.
|
|
23
|
+
"@solidrt/test": "0.0.74"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
27
|
-
"@solidrt/core": "0.0.
|
|
26
|
+
"@solidjs/signals": "2.0.0-rc.14",
|
|
27
|
+
"@solidrt/core": "0.0.74"
|
|
28
28
|
}
|
|
29
29
|
}
|
package/src/context-menu.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
27
|
-
let
|
|
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.
|
|
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,
|
|
100
|
-
//
|
|
101
|
-
// the window root, measured in onLayout, moved
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
let
|
|
111
|
-
let
|
|
112
|
-
|
|
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
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
package/src/scroll-view.tsx
CHANGED
|
@@ -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 }) =>
|
|
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,
|
|
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
|
-
//
|
|
28
|
-
let
|
|
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 (
|
|
35
|
-
*
|
|
36
|
-
* value/onChange contract either way; pressing outside, Escape and the
|
|
37
|
-
* back close without a change.
|
|
38
|
-
*
|
|
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={
|
|
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
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
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
|
-
|
|
142
|
+
let l = list && getBoundingBox(list)
|
|
143
|
+
if (!a || !b || !l) return
|
|
109
144
|
if (a.width !== minWidth()) setMinWidth(a.width)
|
|
110
|
-
let
|
|
111
|
-
let
|
|
112
|
-
|
|
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
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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.
|