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