@solidrt/components 0.0.63 → 0.0.64

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 (48) hide show
  1. package/AGENTS.md +18 -14
  2. package/README.md +126 -21
  3. package/docs/badge.md +1 -1
  4. package/docs/checkbox.md +1 -1
  5. package/docs/context-menu.md +1 -1
  6. package/docs/item.md +1 -1
  7. package/docs/light.md +12 -0
  8. package/docs/motion.md +13 -0
  9. package/docs/press.md +19 -0
  10. package/docs/progress-bar.md +1 -1
  11. package/docs/radio.md +1 -1
  12. package/docs/segmented-control.md +1 -1
  13. package/docs/select.md +1 -1
  14. package/docs/slider.md +1 -1
  15. package/docs/surface.md +19 -0
  16. package/docs/switch.md +1 -1
  17. package/docs/theme.md +34 -6
  18. package/docs/tooltip.md +1 -1
  19. package/docs/types.md +1 -1
  20. package/docs/window.md +2 -0
  21. package/examples/lit.tsx +182 -0
  22. package/package.json +2 -2
  23. package/src/badge.tsx +25 -6
  24. package/src/button.tsx +44 -29
  25. package/src/card.tsx +76 -20
  26. package/src/checkbox.tsx +23 -19
  27. package/src/context-menu.tsx +21 -9
  28. package/src/editor-field.tsx +12 -7
  29. package/src/focus-nav.ts +17 -6
  30. package/src/glow.ts +35 -0
  31. package/src/index.ts +16 -1
  32. package/src/item.tsx +35 -13
  33. package/src/light.ts +274 -0
  34. package/src/motion.tsx +13 -1
  35. package/src/nav-shell.tsx +19 -10
  36. package/src/press.ts +2 -1
  37. package/src/pressable.tsx +6 -4
  38. package/src/progress-bar.tsx +25 -7
  39. package/src/radio.tsx +34 -22
  40. package/src/segmented-control.tsx +104 -23
  41. package/src/select.tsx +40 -34
  42. package/src/slider.tsx +74 -22
  43. package/src/surface.tsx +287 -0
  44. package/src/switch.tsx +48 -26
  45. package/src/theme.ts +201 -40
  46. package/src/tooltip.tsx +18 -14
  47. package/src/types.ts +66 -2
  48. package/src/window.tsx +26 -3
package/src/card.tsx CHANGED
@@ -1,67 +1,123 @@
1
- import { Show } from "@solidrt/core"
2
- import type { LayoutProps } from "@solidrt/core"
1
+ import { Show, createRadialGradient, createSignal, getLayoutBox, withAlpha } from "@solidrt/core"
2
+ import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
+ import { policy } from "./policy"
4
5
  import { typeStyle } from "./typography"
5
6
  import { space } from "./spacing"
6
- import type { StyleProps, TransitionProps } from "./types"
7
- import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
7
+ import { Surface } from "./surface"
8
+ import type { ElevationLevel, StyleProps, TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
8
10
  import { colorFade } from "./motion"
9
11
 
10
12
  export interface CardProps extends TransitionProps {
11
13
  children?: any
12
14
  // Optional heading rendered above the content.
13
15
  title?: string
16
+ // Hover lift: under the pointer the card rises one elevation level (its
17
+ // key shadow grows and moves out on the elevation motion, the card rises
18
+ // LIFT_RISE px) and a faint highlight in the light's color follows the
19
+ // pointer over it. Nothing rotates or scales, so edges and text stay on
20
+ // the pixel grid. Hover interaction policies only; under the stock
21
+ // presets, with no height to rise from, only the highlight shows.
22
+ lift?: boolean
14
23
  ref?: (node: { id: number }) => void
15
24
  layout?: LayoutProps
16
25
  style?: StyleProps
17
26
  }
18
27
 
19
- // A themed surface container: a padded column box with rounded corners, reading
28
+ // The level a lifting card rises to.
29
+ const LIFTED: Record<ElevationLevel, ElevationLevel> = { flat: "raised", raised: "floating", floating: "overlay", overlay: "overlay" }
30
+ // Whole pixels, so the card comes to rest on the pixel grid.
31
+ const LIFT_RISE = 2
32
+ // Alpha of the light-colored highlight that follows the pointer over a
33
+ // lifted card.
34
+ const GLARE = 0.08
35
+
36
+ // A themed surface container: a padded column box with rounded corners, a
37
+ // surface face drawn by Surface (raised by default, theme.elevation.raised,
38
+ // zero under the stock presets; style.elevation changes the level), reading
20
39
  // its colors from the theme so it recolors live. Borderless by default; pass
21
- // style.borderWidth or style.borderColor to draw an outline. Override any paint
22
- // via style, spacing/sizing via layout.
40
+ // style.borderWidth or style.borderColor to draw an outline. Override any
41
+ // paint via style, spacing/sizing via layout.
23
42
  export function Card(props: CardProps) {
24
43
  // Theme-level per-component overrides merged under the instance style.
25
44
  let styled = () => ({ ...theme.components.card, ...props.style })
26
45
  let bg = () => styled().backgroundColor ?? theme.color.surface
27
46
  let radius = () => styled().borderRadius ?? theme.radius.lg
47
+ let elevation = (): ElevationLevel => styled().elevation ?? "raised"
28
48
  let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
29
49
 
50
+ let node: { id: number } | undefined
51
+ // Pointer position over the card, 0..1 per axis; null when not over it.
52
+ let [pointer, setPointer] = createSignal<[number, number] | null>(null)
53
+ let lifts = () => props.lift === true && policy.interaction !== "touch"
54
+ let track = (e: PointerEvent) => {
55
+ if (!lifts() || !node) return
56
+ let box = getLayoutBox(node)
57
+ if (!box || box.width === 0 || box.height === 0) return
58
+ setPointer([e.localX / box.width, e.localY / box.height])
59
+ }
60
+ let glare = () => {
61
+ let p = pointer()
62
+ if (!p) return "transparent"
63
+ return createRadialGradient(p[0], p[1], 0.9, [
64
+ { offset: 0, color: withAlpha(theme.light.color, GLARE) },
65
+ { offset: 1, color: withAlpha(theme.light.color, 0) },
66
+ ])
67
+ }
68
+ // The rise: a whole-pixel translate on the elevation motion's timing,
69
+ // only where the face has a height to rise from.
70
+ let rises = () => lifts() && policy.motion === "normal" && theme.elevation[elevation()] > 0
71
+ let riseTransition = () => (rises() ? { y: { duration: theme.motion.slow, bounce: 0.2 } } : undefined)
72
+
30
73
  let split = () => splitTransition(props.transition)
31
74
 
32
75
  return (
33
76
  <view
34
77
  transition={split().root}
35
78
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
36
- ref={props.ref}
79
+ ref={(n: { id: number }) => {
80
+ node = n
81
+ props.ref?.(n)
82
+ }}
37
83
  repaintBoundary
38
84
  flexDirection="column"
39
85
  gap={space("lg")}
40
86
  padding={space("xl")}
41
87
  {...props.layout}
42
88
  x={styled().x}
43
- y={styled().y}
89
+ y={(styled().y ?? 0) - (rises() && pointer() ? LIFT_RISE : 0)}
44
90
  scale={styled().scale}
45
91
  rotate={styled().rotate}
46
92
  opacity={styled().opacity}
93
+ onPointerMove={lifts() ? track : undefined}
94
+ onPointerEnter={lifts() ? track : undefined}
95
+ onPointerLeave={lifts() ? () => setPointer(null) : undefined}
47
96
  >
48
- <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
97
+ <Surface
98
+ role="surface"
99
+ elevation={elevation()}
100
+ fill={bg()}
101
+ radius={radius()}
102
+ material={styled().material}
103
+ glow={styled().glow}
104
+ liftTo={lifts() ? LIFTED[elevation()] : undefined}
105
+ lifted={pointer() != null}
106
+ outline={hasBorder() ? { color: styled().borderColor ?? theme.color.border, width: styled().borderWidth ?? theme.borderWidth.sm } : null}
107
+ fillTransition={split().background}
108
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
109
+ outlineTransition={split().border}
110
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
111
+ />
112
+ <Show when={lifts()}>
113
+ <d-rect color={glare()} radius={radius()} />
114
+ </Show>
49
115
  <Show when={props.title != null}>
50
116
  <text transition={colorFade()} color={theme.color.text} {...typeStyle("title")}>
51
117
  {props.title}
52
118
  </text>
53
119
  </Show>
54
120
  {props.children}
55
- <Show when={hasBorder()}>
56
- <d-rect
57
- drawStyle="stroke"
58
- transition={withTransitionDefaults(split().border, colorFade())}
59
- onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
60
- color={styled().borderColor ?? theme.color.border}
61
- strokeWidth={styled().borderWidth ?? theme.borderWidth.sm}
62
- radius={radius()}
63
- />
64
- </Show>
65
121
  </view>
66
122
  )
67
123
  }
package/src/checkbox.tsx CHANGED
@@ -5,6 +5,7 @@ import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { densityScale } from "./density"
7
7
  import { Icon } from "./icon"
8
+ import { Surface } from "./surface"
8
9
  import type { StyleProps, TransitionProps } from "./types"
9
10
  import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
10
11
  import { colorFade, markMotion, pressScale, scaleFeedback } from "./motion"
@@ -21,11 +22,12 @@ export interface CheckboxProps extends TransitionProps {
21
22
 
22
23
  const SIZE = 20
23
24
 
24
- // A checkbox. When checked, fills with primary and draws a checkmark; otherwise
25
- // shows an empty bordered box. The fill fades, the mark pops in/out
26
- // (markMotion), and a press shrinks the box slightly (pressScale). Controlled
27
- // via checked/onChange, or uncontrolled via defaultChecked. When disabled, it
28
- // takes no pointer events at all.
25
+ // A checkbox. Unchecked it is a sunken control face (a bordered well in the
26
+ // surface color); checked it fills with primary as an accent face, with the
27
+ // accent material's glow, and draws a checkmark. The fill fades, the mark
28
+ // pops in/out (markMotion), and a press shrinks the box slightly
29
+ // (pressScale). Controlled via checked/onChange, or uncontrolled via
30
+ // defaultChecked. When disabled, it takes no pointer events at all.
29
31
  export function Checkbox(props: CheckboxProps) {
30
32
  let [internal, setInternal] = createSignal(props.defaultChecked ?? false)
31
33
  let checked = () => (props.checked !== undefined ? props.checked : internal())
@@ -36,6 +38,9 @@ export function Checkbox(props: CheckboxProps) {
36
38
  props.onChange?.(next)
37
39
  }
38
40
  let press = createPress({ onPress: toggle })
41
+ // Widens the glow; hover counts only where a pointer can hover (the
42
+ // tint's rule).
43
+ let active = () => press.pressed() || (press.hovered() && policy.interaction !== "touch")
39
44
 
40
45
  let size = () => Math.round(SIZE * densityScale())
41
46
  // The checkmark in box-relative fractions, so it scales with the density.
@@ -76,11 +81,20 @@ export function Checkbox(props: CheckboxProps) {
76
81
  focusable={!props.disabled}
77
82
  pointerEvents={props.disabled ? "none" : undefined}
78
83
  >
79
- <d-rect
80
- transition={withTransitionDefaults(split().background, colorFade())}
81
- onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
82
- color={style().backgroundColor ?? "transparent"}
84
+ <Surface
85
+ role={checked() ? "accent" : "control"}
86
+ elevation={style().elevation ?? "flat"}
87
+ fill={style().backgroundColor ?? "transparent"}
83
88
  radius={style().borderRadius}
89
+ material={style().material}
90
+ glow={checked() && !props.disabled ? style().glow : null}
91
+ sunken={!checked()}
92
+ active={active()}
93
+ outline={(style().borderWidth ?? 0) > 0 ? { color: style().borderColor ?? "transparent", width: style().borderWidth! } : null}
94
+ fillTransition={split().background}
95
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
96
+ outlineTransition={split().border}
97
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
84
98
  />
85
99
  <Show when={checked()}>
86
100
  <view
@@ -113,16 +127,6 @@ export function Checkbox(props: CheckboxProps) {
113
127
  </Show>
114
128
  </view>
115
129
  </Show>
116
- <Show when={(style().borderWidth ?? 0) > 0}>
117
- <d-rect
118
- drawStyle="stroke"
119
- transition={withTransitionDefaults(split().border, colorFade())}
120
- onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
121
- color={style().borderColor ?? "transparent"}
122
- strokeWidth={style().borderWidth}
123
- radius={style().borderRadius}
124
- />
125
- </Show>
126
130
  </view>
127
131
  )
128
132
  }
@@ -8,6 +8,7 @@ import { typeStyle } from "./typography"
8
8
  import type { TransitionProps } from "./types"
9
9
  import { splitTransition, transitionEndFor } from "./types"
10
10
  import { colorFade, popupFade, popupFadeOut, PressFeedback } from "./motion"
11
+ import { Surface } from "./surface"
11
12
 
12
13
  export interface ContextMenuItem {
13
14
  label: string
@@ -84,6 +85,7 @@ export function ContextMenu(props: ContextMenuProps) {
84
85
  // Anchored at the opening pointer position, flipping up when it would run
85
86
  // off the bottom. Same reflow-free placement as Tooltip/Select: portal at
86
87
  // the window root, measured in onLayout, moved with x/y paint transforms.
88
+ // An overlay face (the sheet sits on a scrim, so it stays flat).
87
89
  let Menu = () => {
88
90
  let menu: { id: number } | undefined
89
91
  let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
@@ -124,17 +126,21 @@ export function ContextMenu(props: ContextMenuProps) {
124
126
  paddingTop={theme.spacing.sm}
125
127
  paddingBottom={theme.spacing.sm}
126
128
  >
127
- <d-rect transition={colorFade()} color={theme.color.surface} radius={theme.radius.sm} />
129
+ <Surface
130
+ role="overlay"
131
+ elevation={theme.components.contextMenu?.elevation ?? "overlay"}
132
+ fill={theme.components.contextMenu?.backgroundColor ?? theme.color.surface}
133
+ radius={theme.components.contextMenu?.borderRadius ?? theme.radius.sm}
134
+ material={theme.components.contextMenu?.material}
135
+ glow={theme.components.contextMenu?.glow}
136
+ outline={{
137
+ color: theme.components.contextMenu?.borderColor ?? theme.color.border,
138
+ width: theme.components.contextMenu?.borderWidth ?? theme.borderWidth.sm,
139
+ }}
140
+ />
128
141
  <For each={props.items}>
129
142
  {(item: ContextMenuItem) => <ItemRow item={item} padY={space("sm")} />}
130
143
  </For>
131
- <d-rect
132
- drawStyle="stroke"
133
- transition={colorFade()}
134
- color={theme.color.border}
135
- strokeWidth={theme.borderWidth.sm}
136
- radius={theme.radius.sm}
137
- />
138
144
  </view>
139
145
  </view>,
140
146
  )
@@ -157,7 +163,13 @@ export function ContextMenu(props: ContextMenuProps) {
157
163
  paddingTop={theme.spacing.md}
158
164
  paddingBottom={theme.spacing.md + env.safeArea.bottom}
159
165
  >
160
- <d-rect transition={colorFade()} color={theme.color.surface} radius={theme.radius.sm} />
166
+ <Surface
167
+ role="overlay"
168
+ fill={theme.components.contextMenu?.backgroundColor ?? theme.color.surface}
169
+ radius={theme.components.contextMenu?.borderRadius ?? theme.radius.sm}
170
+ material={theme.components.contextMenu?.material}
171
+ glow={null}
172
+ />
161
173
  <For each={props.items}>
162
174
  {(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.md * 1.5)} />}
163
175
  </For>
@@ -26,6 +26,7 @@ import { registerNavAction } from "./focus-nav"
26
26
  import type { EditorLayoutProps, StyleProps, TransitionProps } from "./types"
27
27
  import { splitTextLayout, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
28
28
  import { colorFade } from "./motion"
29
+ import { Surface } from "./surface"
29
30
  import { theme } from "./theme"
30
31
  import { policy } from "./policy"
31
32
  import { space } from "./spacing"
@@ -497,14 +498,18 @@ export function EditorField(props: EditorFieldProps) {
497
498
  onKeyDown={handleKeyDown}
498
499
  onTextInput={handleTextInput}
499
500
  >
500
- <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={surfaceColor()} radius={borderRadius()} />
501
- <d-rect
502
- drawStyle="stroke"
503
- transition={withTransitionDefaults(split().border, colorFade())}
504
- onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
505
- color={borderColor()}
506
- strokeWidth={borderWidth()}
501
+ <Surface
502
+ role="control"
503
+ fill={surfaceColor()}
507
504
  radius={borderRadius()}
505
+ material={props.style?.material}
506
+ glow={props.style?.glow}
507
+ sunken
508
+ outline={{ color: borderColor(), width: borderWidth() }}
509
+ fillTransition={split().background}
510
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
511
+ outlineTransition={split().border}
512
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
508
513
  />
509
514
  <view
510
515
  ref={(n: { id: number }) => (viewport = n)}
package/src/focus-nav.ts CHANGED
@@ -129,9 +129,15 @@ function repeating<T>(read: () => T | null, step: (value: T) => void): void {
129
129
 
130
130
  // What "select" means per focusable node, registered by createPress's ref.
131
131
  // Only the controller path consults it - key activation reaches the focused
132
- // node by bubbling and never comes through here. Package-internal.
132
+ // node by bubbling and never comes through here.
133
133
  let navActions = new Map<number, () => void>()
134
134
 
135
+ /**
136
+ * Registers what the focus navigation's `select` action does on a focusable
137
+ * node; returns the unregister. createPress does this for every press
138
+ * control, so a custom control needs it only when it activates without
139
+ * createPress.
140
+ */
135
141
  export function registerNavAction(nodeId: number, action: () => void): () => void {
136
142
  navActions.set(nodeId, action)
137
143
  return () => {
@@ -334,11 +340,16 @@ export function createFocusNav(options?: FocusNavOptions) {
334
340
  () => currentScope(),
335
341
  (scopeNode) => {
336
342
  if (!scopeNode) return
337
- let focused = focusedNode()
338
- if (focused != null && getNodePath(focused).includes(scopeNode.id)) return
339
- let placed = reachable()
340
- if (placed.length > 0) focusFirst(placed)
341
- else if (focused != null) setFocus(null)
343
+ // The pull reads focus and the candidates for itself, a snapshot, not
344
+ // a dependency: untracked so the strict-mode read diagnostic stays
345
+ // quiet (as in `repeating`).
346
+ untrack(() => {
347
+ let focused = focusedNode()
348
+ if (focused != null && getNodePath(focused).includes(scopeNode.id)) return
349
+ let placed = reachable()
350
+ if (placed.length > 0) focusFirst(placed)
351
+ else if (focused != null) setFocus(null)
352
+ })
342
353
  },
343
354
  )
344
355
 
package/src/glow.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { parseColor, withAlpha } from "@solidrt/core"
2
+ import type { Color, Gradient, ShadowProps } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import type { Glow, MaterialRole } from "./types"
5
+
6
+ // Alpha of a glow in its part's own fill color: reads as emitted light
7
+ // against a dark ground without blurring the part's edge away.
8
+ const GLOW_ALPHA = 0.7
9
+ // Growth of the glow radius while a pressable face is hovered or pressed.
10
+ const GLOW_ACTIVE = 1.35
11
+
12
+ /** The glow a part draws: the instance or theme.components override when set (null clears), else the role's material glow. */
13
+ export function partGlow(override: Glow | null | undefined, role: MaterialRole): Glow | null | undefined {
14
+ return override !== undefined ? override : theme.material[role].glow
15
+ }
16
+
17
+ /**
18
+ * The shadow a part draws as its glow, for a rect's `shadow` prop: zero
19
+ * offset, the glow's radius of blur (grown by GLOW_ACTIVE while `active`),
20
+ * in the glow's color or else the fill at GLOW_ALPHA. A fill with no color
21
+ * to take - transparent, or a gradient - casts nothing rather than a black
22
+ * halo. Draw it on an opaque rect (the part's own fill rect, or one in its
23
+ * color hidden under it), never on a transparent one: a transparent
24
+ * caster's blurred shadow composites in a save layer of its own, which a
25
+ * tiled GPU pays for per part (okf/notes/lit-list-cost.md).
26
+ */
27
+ export function glowShadow(glow: Glow | null | undefined, fill: Color | Gradient, active = false): ShadowProps | undefined {
28
+ if (!glow) return undefined
29
+ let color = glow.color
30
+ if (color == null) {
31
+ if (typeof fill !== "string" || ((parseColor(fill) >>> 0) & 0xff) === 0) return undefined
32
+ color = withAlpha(fill, GLOW_ALPHA)
33
+ }
34
+ return { x: 0, y: 0, blur: glow.radius * (active ? GLOW_ACTIVE : 1), color }
35
+ }
package/src/index.ts CHANGED
@@ -10,8 +10,9 @@ export { ScrollView, type ScrollViewProps } from "./scroll-view"
10
10
  export { Dismissible, type DismissibleProps, type DismissDirection } from "./dismissible"
11
11
  export { Carousel, type CarouselProps } from "./carousel"
12
12
  export { Pressable, type PressableProps, type PressState } from "./pressable"
13
+ export { createPress, type PressOptions } from "./press"
13
14
  export { Button, type ButtonProps, type ButtonVariant } from "./button"
14
- export { createFocusNav, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
15
+ export { createFocusNav, registerNavAction, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
15
16
  export { Switch, type SwitchProps } from "./switch"
16
17
  export { Checkbox, type CheckboxProps } from "./checkbox"
17
18
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
@@ -33,15 +34,24 @@ export { NavShell, type NavShellProps, type NavItem } from "./nav-shell"
33
34
  export { SplitView, type SplitViewProps } from "./split-view"
34
35
  export { QrCode, type QrCodeProps } from "./qrcode"
35
36
  export { Icon, type IconProps } from "./icon"
37
+ export { Surface, facePaint, roleFill, roleMaterial, surfaceSinks, type SurfaceProps, type FacePaint } from "./surface"
38
+ export { lightFrom, lightSource, lightAxis, type LightAxis } from "./light"
39
+ export { PressFeedback } from "./motion"
36
40
  export {
37
41
  theme,
38
42
  setTheme,
39
43
  defineTheme,
40
44
  darkTheme,
41
45
  lightTheme,
46
+ litDarkTheme,
47
+ litLightTheme,
48
+ pill,
42
49
  type Theme,
43
50
  type ThemeDefinition,
44
51
  type ThemeColor,
52
+ type ThemeValue,
53
+ type Light,
54
+ type ShadowTone,
45
55
  type ThemedComponent,
46
56
  type TextStyle,
47
57
  type TextVariant,
@@ -64,6 +74,11 @@ export { typeStyle, typeWeight, lightOnDark } from "./typography"
64
74
  export { space } from "./spacing"
65
75
  export type {
66
76
  StyleProps,
77
+ Glow,
78
+ Glass,
79
+ Material,
80
+ MaterialRole,
81
+ ElevationLevel,
67
82
  FontProps,
68
83
  TextLayoutProps,
69
84
  EditorLayoutProps,
package/src/item.tsx CHANGED
@@ -5,9 +5,10 @@ import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { space } from "./spacing"
7
7
  import { typeStyle } from "./typography"
8
- import type { StyleProps, TransitionProps } from "./types"
9
- import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
10
- import { colorFade, PressFeedback } from "./motion"
8
+ import { Surface } from "./surface"
9
+ import type { ElevationLevel, StyleProps, TransitionProps } from "./types"
10
+ import { splitTransition, transitionEndFor } from "./types"
11
+ import { colorFade } from "./motion"
11
12
 
12
13
  export interface ItemProps extends TransitionProps {
13
14
  // Leading content: an icon, avatar, checkbox, ...
@@ -38,9 +39,14 @@ export interface ItemProps extends TransitionProps {
38
39
  // trailing content pushed to the end. The dense-data workhorse - rows compose
39
40
  // with <For> inside a plain column (or a ScrollView); this package ships no
40
41
  // List wrapper because a column view IS the list. Paddings and the gap are
41
- // density-scaled, so a <Density> region compacts rows wholesale. With onPress
42
- // the row presses like a menu entry: overlay tints for hover/pressed (no
43
- // scale - rows sit flush in a list), focus ring under the focusRing policy.
42
+ // density-scaled, so a <Density> region compacts rows wholesale. Its face is
43
+ // a surface drawn by Surface, flat by default whatever the theme: a screen
44
+ // holds dozens of rows, and a blurred shadow per row is what a scrolling list
45
+ // cannot afford on a tablet or a TV (okf/notes/lit-list-cost.md); a raised
46
+ // row is the app's choice through style.elevation. With onPress the row
47
+ // presses like a menu entry: overlay tints for hover/pressed (no scale -
48
+ // rows sit flush in a list; a row with depth sinks), focus ring under the
49
+ // focusRing policy. borderColor/borderWidth box the row.
44
50
  export function Item(props: ItemProps) {
45
51
  // Theme-level per-component overrides merged under the instance style.
46
52
  let styled = () => ({ ...theme.components.item, ...props.style })
@@ -49,6 +55,10 @@ export function Item(props: ItemProps) {
49
55
 
50
56
  let bg = () => styled().backgroundColor ?? (props.selected ? theme.color.surfaceAlt : "transparent")
51
57
  let radius = () => styled().borderRadius ?? theme.radius.sm
58
+ let elevation = (): ElevationLevel => styled().elevation ?? "flat"
59
+ let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
60
+ let pressed = () => interactive() && press.pressed()
61
+ let hovered = () => interactive() && press.hovered() && policy.interaction !== "touch"
52
62
 
53
63
  // Resolved once via children(): the typeof probe and the mount site must
54
64
  // share one build (see Button).
@@ -92,11 +102,26 @@ export function Item(props: ItemProps) {
92
102
  focusable={(props.focusable ?? true) && interactive()}
93
103
  pointerEvents={props.disabled ? "none" : undefined}
94
104
  >
95
- <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
96
- <PressFeedback
97
- pressed={interactive() && press.pressed()}
98
- hovered={interactive() && press.hovered() && policy.interaction !== "touch"}
105
+ <Surface
106
+ role="surface"
107
+ elevation={elevation()}
108
+ fill={bg()}
99
109
  radius={radius()}
110
+ material={styled().material}
111
+ glow={styled().glow}
112
+ pressed={pressed()}
113
+ hovered={hovered()}
114
+ outline={
115
+ press.focused() && policy.focusRing
116
+ ? { color: theme.color.ring, width: theme.borderWidth.focus }
117
+ : hasBorder()
118
+ ? { color: styled().borderColor ?? theme.color.border, width: styled().borderWidth ?? theme.borderWidth.sm }
119
+ : null
120
+ }
121
+ fillTransition={split().background}
122
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
123
+ outlineTransition={split().border}
124
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
100
125
  />
101
126
  {props.startContent}
102
127
  <view flexDirection="column" flexGrow={1} flexShrink={1} gap={Math.round(space("sm") / 2)}>
@@ -114,9 +139,6 @@ export function Item(props: ItemProps) {
114
139
  </Show>
115
140
  </view>
116
141
  {props.endContent}
117
- <Show when={press.focused() && policy.focusRing}>
118
- <d-rect drawStyle="stroke" color={theme.color.ring} strokeWidth={theme.borderWidth.focus} radius={radius()} />
119
- </Show>
120
142
  </view>
121
143
  )
122
144
  }