@solidrt/components 0.0.71 → 0.0.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/image.tsx CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createImage, createEffect, Loading, Errored, pct } from "@solidrt/core"
2
- import type { ImageSource, LayoutProps, Pct, PointerProps, TextureProps } from "@solidrt/core"
2
+ import type { ImageSource, InputProps, LayoutProps, Pct, TextureProps } from "@solidrt/core"
3
3
  import type { StyleProps, TransitionProps } from "./types"
4
4
  import { splitTransition, transitionEndFor } from "./types"
5
5
 
6
- export interface ImageProps extends PointerProps, TransitionProps {
6
+ export interface ImageProps extends InputProps, TransitionProps {
7
7
  src: string | Uint8Array
8
8
  /**
9
9
  * How the image maps into the Image's box (CSS object-fit): "fill"
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/modal.tsx CHANGED
@@ -1,19 +1,21 @@
1
- import { createPortal, onCleanup } from "@solidrt/core"
2
- import type { Color, PointerEvent } from "@solidrt/core"
1
+ import { createPortal, onBack, onCleanup } from "@solidrt/core"
2
+ import type { Color } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { pushNavScope } from "./focus-nav"
5
5
  import { colorFade, popupFade } from "./motion"
6
6
 
7
7
  export interface ModalProps {
8
- // Called when the backdrop (the area around the content) is pressed, unless
9
- // `dismissable` is false.
8
+ // Called when the backdrop (the area around the content) is pressed, or on
9
+ // Escape or the platform's back, unless `dismissable` is false.
10
10
  onClose?: () => void
11
11
  // The modal content, centered over the backdrop.
12
12
  children?: any
13
13
  // Scrim color behind the content. Defaults to the theme scrim; pass
14
14
  // "transparent" for no dim.
15
15
  backdropColor?: Color
16
- // Whether pressing the backdrop calls onClose. Defaults to true.
16
+ // Whether a backdrop press, Escape or back calls onClose. Defaults to true.
17
+ // False keeps the modal up until the app removes it; back still stops at
18
+ // the modal, never reaching the screen beneath.
17
19
  dismissable?: boolean
18
20
  }
19
21
 
@@ -30,12 +32,24 @@ export interface ModalProps {
30
32
  * works because pointer events dispatch to the whole hit path with no
31
33
  * stopPropagation, so the content is kept a sibling of the backdrop (not a
32
34
  * child): a press on the content never has the backdrop on its path.
35
+ *
36
+ * While mounted the modal is one step of core's back stack: Escape and the
37
+ * user's back close it as the backdrop does. Registered at mount, so a field
38
+ * focused inside it (which registers at focus) sits above it and a back
39
+ * leaves the field first, the modal next.
33
40
  */
34
41
  export function Modal(props: ModalProps) {
35
- let dismiss = (_e: PointerEvent) => {
42
+ let dismiss = () => {
36
43
  if (props.dismissable !== false) props.onClose?.()
37
44
  }
38
45
 
46
+ // A modal that is not dismissable still takes the step and does nothing:
47
+ // it wants an answer, and the screen beneath must not get the back.
48
+ onBack((e) => {
49
+ e.preventDefault()
50
+ dismiss()
51
+ })
52
+
39
53
  // While mounted, the modal is a focus-navigation trap: its container tops
40
54
  // the nav scope stack, so createFocusNav only reaches controls inside it.
41
55
  let popNavScope: (() => void) | null = null
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/pressable.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { children, untrack } from "@solidrt/core"
2
- import type { Element, LayoutProps, PointerProps } from "@solidrt/core"
2
+ import type { Element, InputProps, LayoutProps } from "@solidrt/core"
3
3
  import type { StyleProps, TransitionProps } from "./types"
4
4
  import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
5
5
  import { createPress, type PressState } from "./press"
@@ -7,7 +7,7 @@ import { colorFade, scaleFeedback } from "./motion"
7
7
 
8
8
  export type { PressState } from "./press"
9
9
 
10
- export interface PressableProps extends PointerProps, TransitionProps {
10
+ export interface PressableProps extends InputProps, TransitionProps {
11
11
  // children and style may be functions of the press state, so a caller can
12
12
  // restyle on press/hover without wiring their own signals. The state is live
13
13
  // (getters, not a snapshot): read it inside a prop or child expression, never
@@ -1,10 +1,11 @@
1
- import { createPan, createScroll, createSignal, getBoundingBoxViewport, getLayoutBox, onSettled, untrack } from "@solidrt/core"
2
- import type { LayoutProps, PointerEvent, PointerProps, Scroll, TransitionCurve, TransitionEndEvent, WheelEvent } from "@solidrt/core"
1
+ import { callRef, createPan, createScroll, createSignal, getBoundingBoxViewport, getLayoutBox, onCleanup } from "@solidrt/core"
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
- extends PointerProps,
8
+ extends InputProps,
8
9
  TransitionProps<TransitionViewProp | TransitionStyleProp | TransitionScrollProp> {
9
10
  children?: any
10
11
  ref?: (node: { id: number }) => void
@@ -14,8 +15,8 @@ export interface ScrollViewProps
14
15
  horizontal?: boolean
15
16
  /** Receives the scroll handle (offset, range, scrollTo) for driving the view
16
17
  * from app code; scroll policies such as following a growing log are written
17
- * against it. Called once the component has settled, outside any reactive
18
- * scope, so a signal setter can be passed directly. */
18
+ * against it. Called during mount, outside any reactive scope, so a signal
19
+ * setter can be passed directly. */
19
20
  scrollRef?: (scroll: Scroll) => void
20
21
  }
21
22
 
@@ -72,11 +73,11 @@ export function ScrollView(props: ScrollViewProps) {
72
73
  () => content,
73
74
  { axis: props.horizontal ? "horizontal" : "vertical" },
74
75
  )
75
- // Handed out from onSettled rather than the body: the body is an owned
76
- // scope, where a signal write (an app passing its setter) is refused.
77
- onSettled(() => {
78
- untrack(() => props.scrollRef)?.(scroll)
79
- })
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?.())
80
81
 
81
82
  // Content follows the finger: it moves opposite to scroll offsets, which
82
83
  // grow toward the bottom/right. The lift's velocity (parent-frame px/s,
@@ -212,7 +213,10 @@ export function ScrollView(props: ScrollViewProps) {
212
213
  an instant write needs the withdrawn declaration to land before the
213
214
  value in the same flush, or the value starts a spring anyway. */}
214
215
  <view
215
- ref={(n: { id: number }) => (viewport = n)}
216
+ ref={(n: { id: number }) => {
217
+ viewport = n
218
+ unregister = registerScrollViewport(n.id, scroll)
219
+ }}
216
220
  flex={1}
217
221
  overflow="hidden"
218
222
  clipRadius={props.style?.borderRadius}
package/src/select.tsx CHANGED
@@ -1,6 +1,8 @@
1
- import { createSignal, createPortal, 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,20 +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 closes without a change.
37
- * 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.
38
40
  */
39
41
  export function Select<T>(props: SelectProps<T>) {
40
42
  let trigger: { id: number } | undefined
43
+ // The selected option's row while a presentation is open.
44
+ let selectedRow: { id: number } | undefined
41
45
  let [open, setOpen] = createSignal(false)
42
46
  // The value overload: as far as TypeScript knows T could be a function,
43
47
  // which createSignal would read as a compute; the cast picks the value form.
@@ -51,6 +55,19 @@ export function Select<T>(props: SelectProps<T>) {
51
55
  props.onChange?.(v)
52
56
  }
53
57
 
58
+ // Open, the picker is one step of core's back stack: Escape and the user's
59
+ // back close it as a press outside does, without a change.
60
+ createEffect(
61
+ () => open(),
62
+ (isOpen) => {
63
+ if (!isOpen) return
64
+ return onBack((e) => {
65
+ e.preventDefault()
66
+ setOpen(false)
67
+ })
68
+ },
69
+ )
70
+
54
71
  let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 })
55
72
 
56
73
  // One option row, shared by both presentations; only the vertical padding
@@ -59,7 +76,11 @@ export function Select<T>(props: SelectProps<T>) {
59
76
  let press = createPress({ onPress: () => choose(p.option.value) })
60
77
  return (
61
78
  <view
62
- 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
+ }}
63
84
  repaintBoundary
64
85
  flexDirection="row"
65
86
  alignItems="center"
@@ -80,30 +101,64 @@ export function Select<T>(props: SelectProps<T>) {
80
101
  )
81
102
  }
82
103
 
83
- // Anchored under the trigger, sized at least as wide as it. Positioned like
84
- // Tooltip: portal at the window root, pinned at 0,0 and moved with the x/y
85
- // paint transforms after measuring, so tracking the anchor never reflows.
86
- // 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).
87
128
  let Dropdown = () => {
88
129
  let menu: { id: number } | undefined
130
+ let list: { id: number } | undefined
131
+ let scroll: Scroll | undefined
89
132
  let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
90
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
91
139
  onLayout(() => {
92
140
  let a = trigger && getBoundingBox(trigger)
93
141
  let b = menu && getBoundingBox(menu)
94
- if (!a || !b) return
142
+ let l = list && getBoundingBox(list)
143
+ if (!a || !b || !l) return
95
144
  if (a.width !== minWidth()) setMinWidth(a.width)
96
- let x = Math.round(Math.min(Math.max(a.x, margin()), env.windowSize.width - b.width - margin()))
97
- let below = a.y + a.height + gap()
98
- 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)
99
148
  let cur = pos()
100
- 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)
101
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?.())
102
156
  // The fade is driven by the position write, not a mount-time `from`: the
103
157
  // menu parks offscreen until measured, so a `from` would play unseen.
104
158
  // The exit fade lives on this root (the node the close unmounts).
105
159
  return createPortal(
106
160
  <view
161
+ ref={(n: { id: number }) => (popScope = pushNavScope(n))}
107
162
  position="absolute"
108
163
  top={0}
109
164
  left={0}
@@ -134,19 +189,50 @@ export function Select<T>(props: SelectProps<T>) {
134
189
  glow={styled().glow}
135
190
  outline={{ color: theme.color.border, width: theme.borderWidth.sm }}
136
191
  />
137
- <For each={props.options}>
138
- {(o: Option<T>) => <OptionRow option={o} padY={space("sm")} />}
139
- </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>
140
197
  </view>
141
198
  </view>,
142
199
  )
143
200
  }
144
201
 
145
202
  // Bottom sheet over a scrim; the content is a sibling of the scrim (Modal's
146
- // trick) so pressing an option never has the scrim on its hit path.
147
- let Sheet = () =>
148
- createPortal(
149
- <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
+ >
150
236
  <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
151
237
  <d-rect transition={colorFade()} color={theme.color.scrim} />
152
238
  </view>
@@ -160,12 +246,15 @@ export function Select<T>(props: SelectProps<T>) {
160
246
  paddingBottom={theme.spacing.md + env.safeArea.bottom}
161
247
  >
162
248
  <Surface role="overlay" fill={theme.color.surface} radius={theme.radius.sm} material={styled().material} glow={null} />
163
- <For each={props.options}>
164
- {(o: Option<T>) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
165
- </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>
166
254
  </view>
167
255
  </view>,
168
256
  )
257
+ }
169
258
 
170
259
  let press = createPress({
171
260
  onPress: () => {
@@ -10,6 +10,13 @@ export interface TextInputProps extends TransitionProps {
10
10
  defaultValue?: string
11
11
  onInput?: (value: string) => void
12
12
  onSubmit?: (value: string) => void
13
+ /**
14
+ * The user backed out of the field: Escape, or the platform's back (the
15
+ * Android button, a pad's back). Fires, then the field blurs, as `onSubmit`
16
+ * does on Enter; `onBlur` follows either. Reports only: the value stays as
17
+ * it is, reverting it is the app's call.
18
+ */
19
+ onCancel?: () => void
13
20
  onFocus?: () => void
14
21
  onBlur?: () => void
15
22
 
@@ -75,6 +82,7 @@ export function TextInput(props: TextInputProps) {
75
82
  </d-text>
76
83
  )}
77
84
  onSubmit={props.onSubmit}
85
+ onCancel={props.onCancel}
78
86
  onFocus={props.onFocus}
79
87
  onBlur={props.onBlur}
80
88
  placeholder={props.placeholder}
package/src/text.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createMemo } from "@solidrt/core"
2
- import type { PointerProps } from "@solidrt/core"
2
+ import type { InputProps } from "@solidrt/core"
3
3
  import type { StyleProps, TextLayoutProps, TransitionProps, TransitionViewProp } from "./types"
4
4
  import { splitTextLayout, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
5
5
  import { theme, type TextVariant } from "./theme"
@@ -10,7 +10,7 @@ import { colorFade } from "./motion"
10
10
  // make sense as a text fill; style.color takes raw values for anything else.
11
11
  export type TextColor = "text" | "textMuted" | "primary" | "onPrimary" | "danger"
12
12
 
13
- export interface TextProps extends PointerProps, TransitionProps<TransitionViewProp | "color"> {
13
+ export interface TextProps extends InputProps, TransitionProps<TransitionViewProp | "color"> {
14
14
  children?: any
15
15
  // Typography role from the theme's type scale; defaults to "body". Explicit
16
16
  // layout font props override the role's fields individually. fontSize
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.
package/src/view.tsx CHANGED
@@ -1,9 +1,9 @@
1
- import type { LayoutProps, PointerProps } from "@solidrt/core"
1
+ import type { InputProps, LayoutProps } from "@solidrt/core"
2
2
  import type { StyleProps, TransitionProps } from "./types"
3
3
  import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
4
4
  import { colorFade } from "./motion"
5
5
 
6
- export interface ViewProps extends PointerProps, TransitionProps {
6
+ export interface ViewProps extends InputProps, TransitionProps {
7
7
  children?: any
8
8
  ref?: (node: { id: number }) => void
9
9
  layout?: LayoutProps