@solidrt/components 0.0.49 → 0.0.51

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.
Files changed (71) hide show
  1. package/AGENTS.md +66 -85
  2. package/README.md +387 -314
  3. package/docs/badge.md +10 -0
  4. package/docs/button.md +21 -0
  5. package/docs/card.md +11 -0
  6. package/docs/checkbox.md +9 -0
  7. package/docs/context-menu.md +17 -0
  8. package/docs/density.md +13 -0
  9. package/docs/divider.md +10 -0
  10. package/docs/field.md +11 -0
  11. package/docs/focus-nav.md +18 -0
  12. package/docs/icon.md +13 -0
  13. package/docs/image.md +21 -0
  14. package/docs/index.md +13 -0
  15. package/docs/item.md +25 -0
  16. package/docs/modal.md +15 -0
  17. package/docs/nav-shell.md +18 -0
  18. package/docs/policy.md +21 -0
  19. package/docs/portal.md +15 -0
  20. package/docs/pressable.md +17 -0
  21. package/docs/progress-bar.md +10 -0
  22. package/docs/qrcode.md +10 -0
  23. package/docs/radio.md +13 -0
  24. package/docs/rich-text-document.md +5 -0
  25. package/docs/rich-text-editor.md +24 -0
  26. package/docs/safe-area.md +12 -0
  27. package/docs/scroll-view.md +14 -0
  28. package/docs/segmented-control.md +13 -0
  29. package/docs/select.md +15 -0
  30. package/docs/slider.md +9 -0
  31. package/docs/spacing.md +7 -0
  32. package/docs/spinner.md +10 -0
  33. package/docs/split-view.md +14 -0
  34. package/docs/switch.md +13 -0
  35. package/docs/text-input.md +23 -0
  36. package/docs/text.md +15 -0
  37. package/docs/theme.md +56 -0
  38. package/docs/tooltip.md +11 -0
  39. package/docs/types.md +5 -0
  40. package/docs/typography.md +5 -0
  41. package/docs/view.md +14 -0
  42. package/docs/window.md +15 -0
  43. package/package.json +6 -3
  44. package/src/badge.tsx +10 -8
  45. package/src/button.tsx +40 -28
  46. package/src/card.tsx +12 -10
  47. package/src/checkbox.tsx +21 -9
  48. package/src/context-menu.tsx +5 -3
  49. package/src/density.tsx +39 -0
  50. package/src/divider.tsx +3 -1
  51. package/src/editor-field.tsx +361 -0
  52. package/src/field.tsx +45 -0
  53. package/src/index.ts +9 -1
  54. package/src/item.tsx +116 -0
  55. package/src/nav-shell.tsx +1 -1
  56. package/src/policy.ts +0 -8
  57. package/src/press.ts +37 -8
  58. package/src/pressable.tsx +4 -1
  59. package/src/progress-bar.tsx +4 -2
  60. package/src/radio.tsx +14 -12
  61. package/src/rich-text-document.ts +247 -0
  62. package/src/rich-text-editor.tsx +149 -0
  63. package/src/segmented-control.tsx +19 -14
  64. package/src/select.tsx +37 -19
  65. package/src/slider.tsx +1 -1
  66. package/src/spacing.ts +1 -1
  67. package/src/spinner.tsx +6 -4
  68. package/src/switch.tsx +2 -1
  69. package/src/text-input.tsx +50 -262
  70. package/src/theme.ts +163 -76
  71. package/src/tooltip.tsx +7 -4
@@ -0,0 +1,361 @@
1
+ // The editable field shared by TextInput and the rich text editor: a
2
+ // focusable box that edits a text buffer through the keyboard and text
3
+ // session, lays its lines out with core's createTextEditorLayout, keeps the
4
+ // caret in view and draws the caret; how a line's text is drawn and what the
5
+ // buffer holds are the caller's (renderLine, buffer). Internal to the
6
+ // package.
7
+ import {
8
+ For,
9
+ createEffect,
10
+ createMemo,
11
+ createSignal,
12
+ onCleanup,
13
+ focusedNode,
14
+ setFocus,
15
+ startTextInput,
16
+ textInputActive,
17
+ untrack,
18
+ } from "@solidrt/core"
19
+ import { createTextEditorLayout } from "@solidrt/core/text-input"
20
+ import type { EditorLine, TextBuffer } from "@solidrt/core/text-input"
21
+ import type { Color, Gradient, KeyEvent, LayoutProps, PointerEvent, TextInputHints } from "@solidrt/core"
22
+ import type { Element } from "solid-js"
23
+ import type { MeasureTextOptions, TextRunRange } from "flux:rendertree"
24
+ import { registerNavAction } from "./focus-nav"
25
+ import type { StyleProps } from "./types"
26
+ import { theme } from "./theme"
27
+ import { policy } from "./policy"
28
+ import { space } from "./spacing"
29
+
30
+ // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
31
+ // edge column cannot drift apart.
32
+ const CARET_WIDTH = 1
33
+
34
+ // Shaping width of the placeholder: effectively unbounded, so it never wraps;
35
+ // the viewport clips it.
36
+ const PLACEHOLDER_SHAPE_WIDTH = 1e9
37
+
38
+ /** What the shell hands renderLine for one laid-out line. */
39
+ export type LineRender = {
40
+ line: () => EditorLine
41
+ /** The field's font options (size, line height), the base for the line's text. */
42
+ font: () => MeasureTextOptions
43
+ /** The field's text color. */
44
+ color: () => Color | Gradient
45
+ }
46
+
47
+ export interface EditorFieldProps {
48
+ /**
49
+ * Creates the buffer the field edits, given the grapheme `step` from the
50
+ * field's geometry (createTextEditorLayout.step); called once.
51
+ */
52
+ buffer: (step: (text: string, offset: number, direction: "left" | "right") => number) => TextBuffer
53
+ /** Styled ranges over the text for the geometry (prepareText `runs`). */
54
+ runs?: () => TextRunRange[] | undefined
55
+ /** Draws one line: detached content at the line's y inside the viewport. */
56
+ renderLine: (r: LineRender) => Element
57
+
58
+ onSubmit?: (value: string) => void
59
+ onFocus?: () => void
60
+ onBlur?: () => void
61
+ placeholder?: string
62
+ disabled?: boolean
63
+ autoFocus?: boolean
64
+ multiline?: boolean
65
+ maxRows?: number
66
+ hints?: TextInputHints
67
+ ref?: (node: { id: number }) => void
68
+ layout?: LayoutProps
69
+ style?: StyleProps
70
+ }
71
+
72
+ // The caret moves through the text (Left/Right/Home/End, Up/Down by line when
73
+ // multiline), edits happen at the caret, and the inner box scrolls to keep it
74
+ // in view. Printable text arrives via onTextInput (post-IME commit).
75
+ // onKeyDown handles caret movement, Backspace/Delete, Enter/select, Escape -
76
+ // and stops those keys from bubbling further. Focused and editing are
77
+ // distinct (see activateField): navigation focuses, select begins editing,
78
+ // Enter while editing submits (single-line) or inserts a newline
79
+ // (multiline). A tap puts the caret at the nearest position. Range selection
80
+ // (shift-movement, highlight) is not wired yet. Outside-click-to-blur is the
81
+ // caller's job.
82
+ export function EditorField(props: EditorFieldProps) {
83
+ let [caretOn, setCaretOn] = createSignal(true)
84
+
85
+ let node: { id: number } | undefined
86
+ let viewport: { id: number } | undefined
87
+ let blinkId: any = null
88
+
89
+ // Derived from core's reactive focus (setFocus is the only writer); the
90
+ // onFocus/onBlur handlers below keep only their side effects (blink timer,
91
+ // caller callbacks). focusedNode() is read FIRST, unconditionally: the
92
+ // memo may first compute before the ref has set `node`, and
93
+ // short-circuiting past the read would leave it dependency-free, frozen
94
+ // false forever.
95
+ let focused = createMemo(() => {
96
+ let id = focusedNode()
97
+ return id != null && id === node?.id
98
+ })
99
+
100
+ // Grapheme steps from the editor's caret stops; the editor is created
101
+ // below and only consulted from event handlers, after both exist. The
102
+ // factory is a one-shot by contract: read once, deliberately untracked.
103
+ let buffer = untrack(() => props.buffer)((_text, offset, direction) => editor.step(offset, direction))
104
+ let value = buffer.value
105
+
106
+ // autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
107
+ // the node's onTextInput handler to toggle the keyboard, and those handlers
108
+ // are only registered after the element's props are applied. The ref can fire
109
+ // before that, so focusing there would no-op.
110
+ createEffect(
111
+ () => props.autoFocus,
112
+ (autoFocus) => {
113
+ if (autoFocus && node) setFocus(node.id)
114
+ },
115
+ )
116
+
117
+ let handlePointerDown = () => {
118
+ if (props.disabled) return
119
+ if (node) setFocus(node.id)
120
+ }
121
+
122
+ // Tap-to-position: the viewport's local point plus its scroll is a content
123
+ // point; the nearest caret stop on the line under it takes the caret. Runs
124
+ // before the field's own handler above (bubbling), which focuses.
125
+ let handleViewportPointerDown = (e: PointerEvent) => {
126
+ if (props.disabled) return
127
+ let line = editor.lineAtY(e.localY + editor.scrollY())
128
+ let offset = editor.offsetAtX(line, e.localX + editor.scrollX())
129
+ buffer.setSelection(offset, offset)
130
+ setCaretOn(true)
131
+ }
132
+
133
+ let handleFocus = () => {
134
+ setCaretOn(true)
135
+ if (blinkId == null) {
136
+ blinkId = setInterval(() => setCaretOn((v) => !v), 500)
137
+ }
138
+ props.onFocus?.()
139
+ }
140
+
141
+ let handleBlur = () => {
142
+ if (blinkId != null) {
143
+ clearInterval(blinkId)
144
+ blinkId = null
145
+ }
146
+ props.onBlur?.()
147
+ }
148
+
149
+ // Keys the input consumes stop propagating: an ancestor (or an app-global
150
+ // shortcut on the window) must not also act on an ArrowLeft that moved the
151
+ // caret. Anything else (e.g. ctrl+s) bubbles on.
152
+ let handleKeyDown = (e: KeyEvent) => {
153
+ if (props.disabled) return
154
+ let consumed = true
155
+ if (e.key === "Backspace") {
156
+ buffer.deleteBackward()
157
+ setCaretOn(true)
158
+ } else if (e.key === "Delete") {
159
+ buffer.deleteForward()
160
+ setCaretOn(true)
161
+ } else if (e.key === "ArrowLeft") {
162
+ buffer.move("left")
163
+ setCaretOn(true)
164
+ } else if (e.key === "ArrowRight") {
165
+ buffer.move("right")
166
+ setCaretOn(true)
167
+ } else if (e.key === "Home" || e.key === "End") {
168
+ // Multiline: the current line's ends (offsetAtX at 0 / far right, so a
169
+ // wrap boundary resolves to the position that shows on this line).
170
+ if (props.multiline) {
171
+ let offset = editor.offsetAtX(editor.caretLine(), e.key === "Home" ? 0 : 1e9)
172
+ buffer.setSelection(offset, offset)
173
+ } else {
174
+ buffer.move(e.key === "Home" ? "start" : "end")
175
+ }
176
+ setCaretOn(true)
177
+ } else if (props.multiline && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
178
+ moveLine(e.key === "ArrowUp" ? -1 : 1)
179
+ setCaretOn(true)
180
+ } else if (props.multiline && e.key === "Enter" && textInputActive()) {
181
+ buffer.insertText("\n")
182
+ setCaretOn(true)
183
+ } else if (e.key === "Enter" || e.code === "Select") {
184
+ // The remote center key's `key` is "Unidentified"; match its code.
185
+ activateField()
186
+ } else if (e.key === "Escape") {
187
+ if (node) setFocus(null)
188
+ } else {
189
+ consumed = false
190
+ }
191
+ if (consumed) e.stopPropagation()
192
+ }
193
+
194
+ let handleTextInput = (e: any) => {
195
+ if (props.disabled) return
196
+ buffer.insertText(e.text ?? "")
197
+ setCaretOn(true)
198
+ }
199
+
200
+ // Up/Down: the offset on the neighbouring line nearest the caret's x; on
201
+ // the first/last line they go to the text's start/end, as editors do.
202
+ let moveLine = (delta: number) => {
203
+ let target = editor.caretLine() + delta
204
+ let count = editor.lines().length
205
+ let offset =
206
+ target < 0 ? 0 : target >= count ? value().length : editor.offsetAtX(target, editor.caret().x)
207
+ buffer.setSelection(offset, offset)
208
+ }
209
+
210
+ // Select on the focused field: focused and editing are distinct states. A
211
+ // field reached by navigation is focused but has no text session yet -
212
+ // select begins one (raising the on-screen keyboard where used, e.g. a TV
213
+ // with no keyboard attached); while editing, it submits (a multiline field
214
+ // has no submit: Enter inserts a newline and select is left to bubble to
215
+ // the caller). On platforms where the session starts invisibly at focus
216
+ // (desktop, physical keyboard) the first branch never runs and Enter
217
+ // submits as always. Registered as the nav action too, for a controller's
218
+ // south button.
219
+ let activateField = () => {
220
+ if (props.disabled) return
221
+ if (!textInputActive()) {
222
+ startTextInput()
223
+ } else if (!props.multiline) {
224
+ props.onSubmit?.(value())
225
+ setFocus(null)
226
+ }
227
+ }
228
+
229
+ let unregisterNav: (() => void) | null = null
230
+
231
+ onCleanup(() => {
232
+ if (blinkId != null) clearInterval(blinkId)
233
+ unregisterNav?.()
234
+ })
235
+
236
+ // Style overrides fall back to theme defaults. The border doubles as the
237
+ // focus ring: primary while focused, when the focus-ring policy asks for a
238
+ // visible indicator.
239
+ let textColor = () => props.style?.color ?? theme.color.text
240
+ let surfaceColor = () => props.style?.backgroundColor ?? theme.color.surface
241
+ let borderColor = () =>
242
+ props.style?.borderColor ?? (focused() && policy.focusRing ? theme.color.primary : theme.color.border)
243
+ let borderWidth = () => props.style?.borderWidth ?? theme.borderWidth.sm
244
+ let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
245
+
246
+ let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
247
+ let showCaret = () => focused() && caretOn() && !showPlaceholder()
248
+
249
+ // Everything inside the viewport is detached: the value is drawn per
250
+ // laid-out line by renderLine (createTextEditorLayout breaks the lines from
251
+ // the prepared text, at the viewport width when multiline) and the caret is
252
+ // a d-rect at the measured before-caret width on its line, so typing, caret
253
+ // movement, blink and scroll never touch layout. The single-line viewport
254
+ // carries an explicit height (detached content takes no layout slot) equal
255
+ // to the one-line height; a multiline viewport stretches to the field's
256
+ // height. The editor layout keeps the caret in view and flushes the offsets
257
+ // before paint; scrollX/scrollY are paint-time translates that also apply
258
+ // to detached children.
259
+ // All metrics derive from the scaled body size, so the field, the caret,
260
+ // and the scroll math grow together under policy.textScale.
261
+ let fontSize = () => theme.text.body.size * policy.textScale
262
+ let font = () => ({ fontSize: fontSize(), lineHeight: theme.text.body.lineHeight })
263
+ let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
264
+ let editor = createTextEditorLayout(
265
+ () => viewport,
266
+ () => ({
267
+ text: value(),
268
+ font: font(),
269
+ runs: props.runs?.(),
270
+ caret: buffer.caret(),
271
+ // Constant, not tied to caret visibility: the caret's footprint does not
272
+ // change as it blinks, so reserving the column only when shown would swing
273
+ // the scroll offset every blink and shift text that exactly fills the box.
274
+ caretWidth: CARET_WIDTH,
275
+ wrap: props.multiline ?? false,
276
+ }),
277
+ )
278
+ let caret = editor.caret
279
+
280
+ // Multiline viewport height: a caller-given field height stretches the
281
+ // viewport (fixed box, scrolls); otherwise the content height, at least one
282
+ // row and at most maxRows rows. Single-line is always one row.
283
+ let viewportHeight = (): number | undefined => {
284
+ if (!props.multiline) return rowHeight()
285
+ if (props.layout?.height != null) return undefined
286
+ let lines = editor.lines()
287
+ let last = lines[lines.length - 1]!
288
+ let content = Math.ceil(last.y + last.height)
289
+ let max = props.maxRows != null ? props.maxRows * rowHeight() : Infinity
290
+ return Math.max(rowHeight(), Math.min(content, max))
291
+ }
292
+
293
+ return (
294
+ <view
295
+ ref={(n: { id: number }) => {
296
+ node = n
297
+ unregisterNav?.()
298
+ unregisterNav = registerNavAction(n.id, activateField)
299
+ props.ref?.(n)
300
+ }}
301
+ textInputHints={props.multiline ? { multiline: true, ...props.hints } : props.hints}
302
+ focusable
303
+ flexDirection="row"
304
+ alignItems="center"
305
+ paddingLeft={space("md")}
306
+ paddingRight={space("md")}
307
+ paddingTop={space("sm")}
308
+ paddingBottom={space("sm")}
309
+ {...props.layout}
310
+ x={props.style?.x}
311
+ y={props.style?.y}
312
+ scale={props.style?.scale}
313
+ rotate={props.style?.rotate}
314
+ opacity={props.style?.opacity}
315
+ onPointerDown={handlePointerDown}
316
+ onFocus={handleFocus}
317
+ onBlur={handleBlur}
318
+ onKeyDown={handleKeyDown}
319
+ onTextInput={handleTextInput}
320
+ >
321
+ <d-rect color={surfaceColor()} radius={borderRadius()} />
322
+ <d-rect
323
+ drawStyle="stroke"
324
+ color={borderColor()}
325
+ strokeWidth={borderWidth()}
326
+ radius={borderRadius()}
327
+ />
328
+ <view
329
+ ref={(n: { id: number }) => (viewport = n)}
330
+ flex={1}
331
+ height={viewportHeight()}
332
+ alignSelf={props.multiline ? "stretch" : undefined}
333
+ overflow="hidden"
334
+ scrollX={editor.scrollX()}
335
+ scrollY={editor.scrollY()}
336
+ onPointerDown={handleViewportPointerDown}
337
+ >
338
+ {showPlaceholder() ? (
339
+ <d-text w={PLACEHOLDER_SHAPE_WIDTH} {...font()} color={theme.color.textMuted} maxLines={1}>
340
+ {props.placeholder ?? ""}
341
+ </d-text>
342
+ ) : (
343
+ <>
344
+ <For each={editor.lines()} keyed={false}>
345
+ {(line) => props.renderLine({ line, font, color: textColor })}
346
+ </For>
347
+ {showCaret() ? (
348
+ <d-rect
349
+ color={textColor()}
350
+ x={caret().x}
351
+ y={caret().y + (caret().height - fontSize()) / 2}
352
+ w={CARET_WIDTH}
353
+ h={fontSize()}
354
+ />
355
+ ) : null}
356
+ </>
357
+ )}
358
+ </view>
359
+ </view>
360
+ )
361
+ }
package/src/field.tsx ADDED
@@ -0,0 +1,45 @@
1
+ import { Show } from "@solidrt/core"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { space } from "./spacing"
5
+ import { typeStyle } from "./typography"
6
+
7
+ export interface FieldProps {
8
+ // Label above the control.
9
+ label?: string
10
+ // Help text under the control; replaced by `error` while one is set.
11
+ description?: string
12
+ // Validation message: rendered in the danger color in place of the
13
+ // description.
14
+ error?: string
15
+ // The control (TextInput, Select, Slider, ...), rendered as-is.
16
+ children?: any
17
+ layout?: LayoutProps
18
+ }
19
+
20
+ // A form row: label above, control, help or error line below. It draws no
21
+ // chrome and does not reach into the control - error styling of the input
22
+ // itself stays the input's style prop (no hidden magic). The message line
23
+ // only occupies space while there is one, so forms do not jump on the first
24
+ // keystroke unless an error appears; reserve the space with a constant
25
+ // `description` if that matters.
26
+ export function Field(props: FieldProps) {
27
+ return (
28
+ <view flexDirection="column" gap={space("sm")} {...props.layout}>
29
+ <Show when={props.label != null}>
30
+ <text color={theme.color.text} {...typeStyle("label")}>
31
+ {props.label}
32
+ </text>
33
+ </Show>
34
+ {props.children}
35
+ <Show when={props.error != null || props.description != null}>
36
+ <text
37
+ color={props.error != null ? theme.color.danger : theme.color.textMuted}
38
+ {...typeStyle("caption")}
39
+ >
40
+ {props.error ?? props.description}
41
+ </text>
42
+ </Show>
43
+ </view>
44
+ )
45
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ export { Text, type TextProps, type TextColor } from "./text"
4
4
  export { Image, type ImageProps } from "./image"
5
5
  export { SafeArea } from "./safe-area"
6
6
  export { TextInput, type TextInputProps } from "./text-input"
7
+ export { RichTextEditor, type RichTextEditorProps } from "./rich-text-editor"
8
+ export { createDocumentBuffer, plainDocument, ATOM, type Document, type DocumentRun, type DocumentBuffer, type DocumentBufferOptions, type Attributes, type AttributePatch } from "./rich-text-document"
7
9
  export { ScrollView, type ScrollViewProps } from "./scroll-view"
8
10
  export { Pressable, type PressableProps, type PressState } from "./pressable"
9
11
  export { Button, type ButtonProps, type ButtonVariant } from "./button"
@@ -13,6 +15,8 @@ export { Checkbox, type CheckboxProps } from "./checkbox"
13
15
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
14
16
  export { Slider, type SliderProps } from "./slider"
15
17
  export { Card, type CardProps } from "./card"
18
+ export { Item, type ItemProps } from "./item"
19
+ export { Field, type FieldProps } from "./field"
16
20
  export { Divider, type DividerProps } from "./divider"
17
21
  export { Badge, type BadgeProps, type BadgeVariant } from "./badge"
18
22
  export { Spinner, type SpinnerProps } from "./spinner"
@@ -30,9 +34,13 @@ export { Icon, type IconProps } from "./icon"
30
34
  export {
31
35
  theme,
32
36
  setTheme,
37
+ defineTheme,
33
38
  darkTheme,
34
39
  lightTheme,
35
40
  type Theme,
41
+ type ThemeDefinition,
42
+ type ThemeColor,
43
+ type ThemedComponent,
36
44
  type TextStyle,
37
45
  type TextVariant,
38
46
  } from "./theme"
@@ -41,7 +49,6 @@ export {
41
49
  setPolicy,
42
50
  setPolicyResolver,
43
51
  defaultPolicyResolver,
44
- densityScale,
45
52
  type Policies,
46
53
  type PolicyResolver,
47
54
  type InteractionPolicy,
@@ -50,6 +57,7 @@ export {
50
57
  type NavigationPolicy,
51
58
  type LayoutPolicy,
52
59
  } from "./policy"
60
+ export { Density, type DensityProps, densityScale } from "./density"
53
61
  export { typeStyle, typeWeight, lightOnDark } from "./typography"
54
62
  export { space } from "./spacing"
55
63
  export type { StyleProps, TextLayoutProps, Option } from "./types"
package/src/item.tsx ADDED
@@ -0,0 +1,116 @@
1
+ import { Show, children } from "@solidrt/core"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { createPress, type PressState } from "./press"
4
+ import { theme } from "./theme"
5
+ import { policy } from "./policy"
6
+ import { space } from "./spacing"
7
+ import { typeStyle } from "./typography"
8
+ import type { StyleProps } from "./types"
9
+
10
+ export interface ItemProps {
11
+ // Leading content: an icon, avatar, checkbox, ...
12
+ startContent?: any
13
+ // Primary text. A string/number renders as themed body text; anything else
14
+ // as-is.
15
+ label: any
16
+ // Secondary line under the label. A string/number renders as themed muted
17
+ // caption text; anything else as-is.
18
+ description?: any
19
+ // Trailing content: a badge, timestamp, chevron, action, ...
20
+ endContent?: any
21
+ // Present = the row is interactive: hover/pressed overlay tints, focusable,
22
+ // Enter/remote activation. A returned promise defers further activations
23
+ // until it settles; non-thenable returns are ignored.
24
+ onPress?: () => unknown
25
+ // Fills the row with surfaceAlt to mark the current selection.
26
+ selected?: boolean
27
+ disabled?: boolean
28
+ // Focus-navigation candidacy; defaults to true for interactive rows.
29
+ focusable?: boolean
30
+ ref?: (node: { id: number }) => void
31
+ layout?: LayoutProps
32
+ style?: StyleProps
33
+ }
34
+
35
+ // A list row: leading content, a label with an optional description under it,
36
+ // trailing content pushed to the end. The dense-data workhorse - rows compose
37
+ // with <For> inside a plain column (or a ScrollView); this package ships no
38
+ // List wrapper because a column view IS the list. Paddings and the gap are
39
+ // density-scaled, so a <Density> region compacts rows wholesale. With onPress
40
+ // the row presses like a menu entry: overlay tints for hover/pressed (no
41
+ // scale - rows sit flush in a list), focus ring under the focusRing policy.
42
+ export function Item(props: ItemProps) {
43
+ // Theme-level per-component overrides merged under the instance style.
44
+ let styled = () => ({ ...theme.components.item, ...props.style })
45
+ let interactive = () => props.onPress != null && !props.disabled
46
+ let press = createPress(props)
47
+
48
+ let bg = () => styled().backgroundColor ?? (props.selected ? theme.color.surfaceAlt : "transparent")
49
+ let overlay = (s: PressState) =>
50
+ !interactive()
51
+ ? "transparent"
52
+ : s.pressed
53
+ ? theme.color.overlayPressed
54
+ : s.hovered && policy.interaction !== "touch"
55
+ ? theme.color.overlayHover
56
+ : "transparent"
57
+ let radius = () => styled().borderRadius ?? theme.radius.sm
58
+
59
+ // Resolved once via children(): the typeof probe and the mount site must
60
+ // share one build (see Button).
61
+ let label = children(() => props.label)
62
+ let labelIsText = () => typeof label() === "string" || typeof label() === "number"
63
+ let description = children(() => props.description)
64
+ let descriptionIsText = () => typeof description() === "string" || typeof description() === "number"
65
+
66
+ return (
67
+ <view
68
+ ref={(n: { id: number }) => {
69
+ press.ref(n)
70
+ props.ref?.(n)
71
+ }}
72
+ repaintBoundary
73
+ flexDirection="row"
74
+ alignItems="center"
75
+ gap={space("md")}
76
+ paddingTop={space("md")}
77
+ paddingBottom={space("md")}
78
+ paddingLeft={space("lg")}
79
+ paddingRight={space("lg")}
80
+ {...props.layout}
81
+ x={styled().x}
82
+ y={styled().y}
83
+ scale={styled().scale}
84
+ rotate={styled().rotate}
85
+ opacity={props.disabled ? 0.5 : styled().opacity}
86
+ // A passive row attaches no press recognizer: it must not claim
87
+ // pointers from its children (a Switch in a settings row) or from an
88
+ // enclosing pressable. Interactivity is decided at mount.
89
+ {...(props.onPress != null ? press.handlers : {})}
90
+ focusable={(props.focusable ?? true) && interactive()}
91
+ pointerEvents={props.disabled ? "none" : undefined}
92
+ >
93
+ <d-rect color={bg()} radius={radius()} />
94
+ <d-rect color={overlay(press.state())} radius={radius()} />
95
+ {props.startContent}
96
+ <view flexDirection="column" flexGrow={1} flexShrink={1} gap={2}>
97
+ <Show when={labelIsText()} fallback={label()}>
98
+ <text color={theme.color.text} {...typeStyle("body")} maxLines={1}>
99
+ {label()}
100
+ </text>
101
+ </Show>
102
+ <Show when={props.description != null}>
103
+ <Show when={descriptionIsText()} fallback={description()}>
104
+ <text color={theme.color.textMuted} {...typeStyle("caption")} maxLines={1}>
105
+ {description()}
106
+ </text>
107
+ </Show>
108
+ </Show>
109
+ </view>
110
+ {props.endContent}
111
+ <Show when={press.focused() && policy.focusRing}>
112
+ <d-rect drawStyle="stroke" color={theme.color.text} strokeWidth={2} radius={radius()} />
113
+ </Show>
114
+ </view>
115
+ )
116
+ }
package/src/nav-shell.tsx CHANGED
@@ -49,7 +49,7 @@ export function NavShell(props: NavShellProps) {
49
49
  item.value === value()
50
50
  ? theme.color.surfaceAlt
51
51
  : hovered && policy.interaction !== "touch"
52
- ? theme.color.surfaceHover
52
+ ? theme.color.overlayHover
53
53
  : "transparent"
54
54
 
55
55
  // Icon over a small label, centered; shared by the tab bar and the rail.
package/src/policy.ts CHANGED
@@ -118,11 +118,3 @@ export function setPolicy(partial: Partial<Policies>) {
118
118
  setOverrides((prev) => ({ ...prev, ...partial }))
119
119
  }
120
120
 
121
- // How density maps to component metrics: a multiplier on control sizes,
122
- // paddings, and hit targets. Comfortable is the components' designed size.
123
- const DENSITY_SCALE: Record<DensityPolicy, number> = { comfortable: 1, compact: 0.85, dense: 0.7 }
124
-
125
- /** Reactive density multiplier for control metrics. */
126
- export function densityScale(): number {
127
- return DENSITY_SCALE[policy.density]
128
- }
package/src/press.ts CHANGED
@@ -6,10 +6,16 @@ import { registerNavAction } from "./focus-nav"
6
6
  // so a consumer that reads one inside a JSX prop or child expression tracks that
7
7
  // signal there and nothing else re-runs. Read them in those positions, not
8
8
  // eagerly into a local, or the read lands in whatever scope destructured it.
9
- export type PressState = { pressed: boolean; hovered: boolean; focused: boolean }
9
+ export type PressState = { pressed: boolean; hovered: boolean; focused: boolean; pending: boolean }
10
10
 
11
11
  export interface PressOptions {
12
- onPress?: () => void
12
+ // A returned promise marks the press `pending` until it settles - further
13
+ // activations (pointer, key, remote) are ignored meanwhile, so an async
14
+ // action (save, submit) cannot double-fire. A rejection still clears
15
+ // pending and surfaces as an unhandled rejection. Typed `unknown` (not
16
+ // `void | Promise<void>`) so plain handlers like `() => setOpen(true)`
17
+ // keep compiling; any non-thenable return is ignored.
18
+ onPress?: () => unknown
13
19
  disabled?: boolean
14
20
  onPointerDown?: (e: PointerEvent) => void
15
21
  onPointerUp?: (e: PointerEvent) => void
@@ -55,6 +61,28 @@ export function createPress(options: PressOptions) {
55
61
  let node: { id: number } | null = null
56
62
  let unregisterNav: (() => void) | null = null
57
63
 
64
+ // Async onPress: while a returned promise is unsettled the press is pending
65
+ // and activations are ignored. `inflight` is a plain boolean because signal
66
+ // writes flush on the microtask, so two activations in one dispatch would
67
+ // both read pending() as false; the signal exists for the UI.
68
+ let [pending, setPending] = createSignal(false)
69
+ let inflight = false
70
+ let activate = () => {
71
+ if (options.disabled || inflight) return
72
+ let result = options.onPress?.()
73
+ if (result && typeof (result as Promise<void>).then === "function") {
74
+ inflight = true
75
+ setPending(true)
76
+ // finally, not then(clear, clear): pending clears either way, but a
77
+ // rejection keeps propagating to the unhandled-rejection report
78
+ // instead of being swallowed here.
79
+ ;(result as Promise<void>).finally(() => {
80
+ inflight = false
81
+ setPending(false)
82
+ })
83
+ }
84
+ }
85
+
58
86
  // Focus is derived from core's reactive focus rather than tracked through
59
87
  // the onFocus/onBlur handlers - one source of truth. Memoized so a focus
60
88
  // move propagates into styling only for the two controls whose value flips.
@@ -89,14 +117,15 @@ export function createPress(options: PressOptions) {
89
117
  get focused() {
90
118
  return focused()
91
119
  },
120
+ get pending() {
121
+ return pending()
122
+ },
92
123
  }
93
124
  let state = (): PressState => live
94
125
  let ref = (n: { id: number }) => {
95
126
  node = n
96
127
  unregisterNav?.()
97
- unregisterNav = registerNavAction(n.id, () => {
98
- if (!options.disabled) options.onPress?.()
99
- })
128
+ unregisterNav = registerNavAction(n.id, activate)
100
129
  }
101
130
 
102
131
  let within = (e: PointerEvent) => {
@@ -145,7 +174,7 @@ export function createPress(options: PressOptions) {
145
174
  if (active === e.pointerId) {
146
175
  let fire = inside
147
176
  cancel()
148
- if (fire) options.onPress?.()
177
+ if (fire) activate()
149
178
  }
150
179
  options.onPointerUp?.(e)
151
180
  },
@@ -161,7 +190,7 @@ export function createPress(options: PressOptions) {
161
190
  // The remote center key's `key` is "Unidentified"; match its code.
162
191
  if ((e.key === "Enter" || e.key === " " || e.code === "Select") && !e.repeat && !options.disabled) {
163
192
  e.stopPropagation()
164
- options.onPress?.()
193
+ activate()
165
194
  }
166
195
  options.onKeyDown?.(e)
167
196
  },
@@ -173,5 +202,5 @@ export function createPress(options: PressOptions) {
173
202
  },
174
203
  }
175
204
 
176
- return { pressed, hovered, focused, state, ref, handlers, cancel }
205
+ return { pressed, hovered, focused, pending, state, ref, handlers, cancel }
177
206
  }