@solidrt/components 0.0.71 → 0.0.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -5
- package/README.md +16 -10
- package/docs/context-menu.md +1 -1
- package/docs/focus-nav.md +3 -1
- package/docs/modal.md +1 -1
- package/docs/scroll-view.md +2 -2
- package/docs/select.md +1 -1
- package/docs/text-input.md +6 -2
- package/docs/tooltip.md +1 -1
- package/package.json +6 -3
- package/src/card.tsx +5 -2
- package/src/carousel.tsx +4 -1
- package/src/context-menu.tsx +72 -25
- package/src/dismissible.tsx +5 -2
- package/src/editor-field.tsx +28 -2
- package/src/focus-nav.ts +44 -1
- package/src/image.tsx +2 -2
- package/src/index.ts +1 -1
- package/src/modal.tsx +20 -6
- package/src/popup.tsx +94 -0
- package/src/pressable.tsx +2 -2
- package/src/scroll-view.tsx +15 -11
- package/src/select.tsx +120 -31
- package/src/text-input.tsx +8 -0
- package/src/text.tsx +2 -2
- package/src/tooltip.tsx +8 -12
- package/src/view.tsx +2 -2
- package/src/window.tsx +2 -2
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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 = (
|
|
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,
|
|
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
|
|
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
|
package/src/scroll-view.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { createPan, createScroll, createSignal, getBoundingBoxViewport, getLayoutBox,
|
|
2
|
-
import type { LayoutProps, PointerEvent,
|
|
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
|
|
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
|
|
18
|
-
*
|
|
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
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
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 }) =>
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
37
|
-
*
|
|
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={
|
|
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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
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
|
-
|
|
142
|
+
let l = list && getBoundingBox(list)
|
|
143
|
+
if (!a || !b || !l) return
|
|
95
144
|
if (a.width !== minWidth()) setMinWidth(a.width)
|
|
96
|
-
let
|
|
97
|
-
let
|
|
98
|
-
|
|
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
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
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: () => {
|
package/src/text-input.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|
|
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.
|
package/src/view.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
6
|
+
export interface ViewProps extends InputProps, TransitionProps {
|
|
7
7
|
children?: any
|
|
8
8
|
ref?: (node: { id: number }) => void
|
|
9
9
|
layout?: LayoutProps
|