@adea-ai/ui 0.84.0 → 0.87.0

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 (257) hide show
  1. package/README.md +90 -1
  2. package/dist/NOTICE +60 -0
  3. package/dist/components/composites/action-button/action-button.d.ts.map +1 -1
  4. package/dist/components/composites/action-button/action-button.js +2 -2
  5. package/dist/components/composites/action-button/action-button.js.map +1 -1
  6. package/dist/components/composites/catalog-browser/catalog-browser-group.d.ts +20 -0
  7. package/dist/components/composites/catalog-browser/catalog-browser-group.d.ts.map +1 -0
  8. package/dist/components/composites/catalog-browser/catalog-browser-group.js +195 -0
  9. package/dist/components/composites/catalog-browser/catalog-browser-group.js.map +1 -0
  10. package/dist/components/composites/catalog-browser/catalog-browser.d.ts +3 -0
  11. package/dist/components/composites/catalog-browser/catalog-browser.d.ts.map +1 -0
  12. package/dist/components/composites/catalog-browser/catalog-browser.js +378 -0
  13. package/dist/components/composites/catalog-browser/catalog-browser.js.map +1 -0
  14. package/dist/components/composites/catalog-browser/catalog-detail.d.ts +21 -0
  15. package/dist/components/composites/catalog-browser/catalog-detail.d.ts.map +1 -0
  16. package/dist/components/composites/catalog-browser/catalog-detail.js +92 -0
  17. package/dist/components/composites/catalog-browser/catalog-detail.js.map +1 -0
  18. package/dist/components/composites/catalog-browser/catalog-types.d.ts +78 -0
  19. package/dist/components/composites/catalog-browser/catalog-types.d.ts.map +1 -0
  20. package/dist/components/composites/catalog-browser/index.d.ts +4 -0
  21. package/dist/components/composites/catalog-browser/index.d.ts.map +1 -0
  22. package/dist/components/composites/catalog-browser/index.js +3 -0
  23. package/dist/components/composites/list-row/list-row.d.ts +6 -3
  24. package/dist/components/composites/list-row/list-row.d.ts.map +1 -1
  25. package/dist/components/composites/list-row/list-row.js +69 -7
  26. package/dist/components/composites/list-row/list-row.js.map +1 -1
  27. package/dist/components/composites/settings/settings.d.ts +9 -11
  28. package/dist/components/composites/settings/settings.d.ts.map +1 -1
  29. package/dist/components/composites/settings/settings.js +48 -33
  30. package/dist/components/composites/settings/settings.js.map +1 -1
  31. package/dist/components/conversation/message-composer.js +1 -1
  32. package/dist/components/conversation/scroll-follow.d.ts.map +1 -1
  33. package/dist/components/conversation/scroll-follow.js +95 -15
  34. package/dist/components/conversation/scroll-follow.js.map +1 -1
  35. package/dist/components/layout/app-shell/index.d.ts +1 -0
  36. package/dist/components/layout/app-shell/index.d.ts.map +1 -1
  37. package/dist/components/layout/app-shell/index.js +2 -1
  38. package/dist/components/layout/app-shell/skip-link.d.ts +11 -0
  39. package/dist/components/layout/app-shell/skip-link.d.ts.map +1 -0
  40. package/dist/components/layout/app-shell/skip-link.js +44 -0
  41. package/dist/components/layout/app-shell/skip-link.js.map +1 -0
  42. package/dist/components/layout/floating-preview/floating-preview.d.ts +12 -0
  43. package/dist/components/layout/floating-preview/floating-preview.d.ts.map +1 -0
  44. package/dist/components/layout/floating-preview/floating-preview.js +283 -0
  45. package/dist/components/layout/floating-preview/floating-preview.js.map +1 -0
  46. package/dist/components/layout/floating-preview/index.d.ts +3 -0
  47. package/dist/components/layout/floating-preview/index.d.ts.map +1 -0
  48. package/dist/components/layout/floating-preview/index.js +3 -0
  49. package/dist/components/layout/floating-preview/model.d.ts +74 -0
  50. package/dist/components/layout/floating-preview/model.d.ts.map +1 -0
  51. package/dist/components/layout/floating-preview/model.js +146 -0
  52. package/dist/components/layout/floating-preview/model.js.map +1 -0
  53. package/dist/components/theme/index.d.ts +1 -0
  54. package/dist/components/theme/index.d.ts.map +1 -1
  55. package/dist/components/theme/index.js +2 -1
  56. package/dist/components/theme/theme-mode-toggle.d.ts +10 -0
  57. package/dist/components/theme/theme-mode-toggle.d.ts.map +1 -0
  58. package/dist/components/theme/theme-mode-toggle.js +82 -0
  59. package/dist/components/theme/theme-mode-toggle.js.map +1 -0
  60. package/dist/components/ui/checkbox/checkbox.d.ts +2 -0
  61. package/dist/components/ui/checkbox/checkbox.d.ts.map +1 -1
  62. package/dist/components/ui/checkbox/checkbox.js +24 -3
  63. package/dist/components/ui/checkbox/checkbox.js.map +1 -1
  64. package/dist/components/ui/combobox/combobox.d.ts.map +1 -1
  65. package/dist/components/ui/combobox/combobox.js +41 -4
  66. package/dist/components/ui/combobox/combobox.js.map +1 -1
  67. package/dist/components/ui/combobox/index.d.ts +1 -0
  68. package/dist/components/ui/combobox/index.d.ts.map +1 -1
  69. package/dist/components/ui/combobox/index.js +2 -1
  70. package/dist/components/ui/combobox/value-combobox.d.ts +19 -0
  71. package/dist/components/ui/combobox/value-combobox.d.ts.map +1 -0
  72. package/dist/components/ui/combobox/value-combobox.js +42 -0
  73. package/dist/components/ui/combobox/value-combobox.js.map +1 -0
  74. package/dist/components/ui/empty/empty-state.d.ts +15 -0
  75. package/dist/components/ui/empty/empty-state.d.ts.map +1 -0
  76. package/dist/components/ui/empty/empty-state.js +96 -0
  77. package/dist/components/ui/empty/empty-state.js.map +1 -0
  78. package/dist/components/ui/empty/index.d.ts +1 -0
  79. package/dist/components/ui/empty/index.d.ts.map +1 -1
  80. package/dist/components/ui/empty/index.js +2 -1
  81. package/dist/components/ui/entity-icon/entity-icon.d.ts +4 -1
  82. package/dist/components/ui/entity-icon/entity-icon.d.ts.map +1 -1
  83. package/dist/components/ui/entity-icon/entity-icon.js +37 -13
  84. package/dist/components/ui/entity-icon/entity-icon.js.map +1 -1
  85. package/dist/components/ui/field/field.d.ts +36 -13
  86. package/dist/components/ui/field/field.d.ts.map +1 -1
  87. package/dist/components/ui/field/field.js +146 -24
  88. package/dist/components/ui/field/field.js.map +1 -1
  89. package/dist/components/ui/field/index.d.ts +3 -1
  90. package/dist/components/ui/field/index.d.ts.map +1 -1
  91. package/dist/components/ui/field/index.js +3 -2
  92. package/dist/components/ui/field/number-field.d.ts +19 -0
  93. package/dist/components/ui/field/number-field.d.ts.map +1 -0
  94. package/dist/components/ui/field/number-field.js +76 -0
  95. package/dist/components/ui/field/number-field.js.map +1 -0
  96. package/dist/components/ui/input/index.d.ts +1 -0
  97. package/dist/components/ui/input/index.d.ts.map +1 -1
  98. package/dist/components/ui/input/index.js +2 -1
  99. package/dist/components/ui/input/input-control.d.ts +11 -0
  100. package/dist/components/ui/input/input-control.d.ts.map +1 -0
  101. package/dist/components/ui/input/input-control.js +24 -0
  102. package/dist/components/ui/input/input-control.js.map +1 -0
  103. package/dist/components/ui/input/input.d.ts.map +1 -1
  104. package/dist/components/ui/input/input.js +56 -28
  105. package/dist/components/ui/input/input.js.map +1 -1
  106. package/dist/components/ui/input-group/index.d.ts +1 -0
  107. package/dist/components/ui/input-group/index.d.ts.map +1 -1
  108. package/dist/components/ui/input-group/index.js +2 -1
  109. package/dist/components/ui/input-group/input-group.d.ts.map +1 -1
  110. package/dist/components/ui/input-group/input-group.js +42 -5
  111. package/dist/components/ui/input-group/input-group.js.map +1 -1
  112. package/dist/components/ui/input-group/secret-input-group.d.ts +11 -0
  113. package/dist/components/ui/input-group/secret-input-group.d.ts.map +1 -0
  114. package/dist/components/ui/input-group/secret-input-group.js +67 -0
  115. package/dist/components/ui/input-group/secret-input-group.js.map +1 -0
  116. package/dist/components/ui/native-select/native-select.d.ts.map +1 -1
  117. package/dist/components/ui/native-select/native-select.js +39 -4
  118. package/dist/components/ui/native-select/native-select.js.map +1 -1
  119. package/dist/components/ui/progress/progress.d.ts.map +1 -1
  120. package/dist/components/ui/progress/progress.js +30 -18
  121. package/dist/components/ui/progress/progress.js.map +1 -1
  122. package/dist/components/ui/radio-group/radio-group.d.ts.map +1 -1
  123. package/dist/components/ui/radio-group/radio-group.js +41 -4
  124. package/dist/components/ui/radio-group/radio-group.js.map +1 -1
  125. package/dist/components/ui/switch/switch.d.ts +2 -0
  126. package/dist/components/ui/switch/switch.d.ts.map +1 -1
  127. package/dist/components/ui/switch/switch.js +24 -3
  128. package/dist/components/ui/switch/switch.js.map +1 -1
  129. package/dist/components/ui/tabs/tabs.js +1 -1
  130. package/dist/components/ui/tabs/tabs.js.map +1 -1
  131. package/dist/components/ui/text-link/index.d.ts +2 -0
  132. package/dist/components/ui/text-link/index.d.ts.map +1 -0
  133. package/dist/components/ui/text-link/index.js +2 -0
  134. package/dist/components/ui/text-link/text-link.d.ts +9 -0
  135. package/dist/components/ui/text-link/text-link.d.ts.map +1 -0
  136. package/dist/components/ui/text-link/text-link.js +30 -0
  137. package/dist/components/ui/text-link/text-link.js.map +1 -0
  138. package/dist/components/ui/textarea/textarea.d.ts.map +1 -1
  139. package/dist/components/ui/textarea/textarea.js +41 -4
  140. package/dist/components/ui/textarea/textarea.js.map +1 -1
  141. package/dist/index.d.ts +4 -0
  142. package/dist/index.d.ts.map +1 -1
  143. package/dist/index.js +21 -8
  144. package/dist/lib/clipboard.d.ts +3 -0
  145. package/dist/lib/clipboard.d.ts.map +1 -0
  146. package/dist/lib/clipboard.js +35 -0
  147. package/dist/lib/clipboard.js.map +1 -0
  148. package/dist/lib/download.d.ts +3 -0
  149. package/dist/lib/download.d.ts.map +1 -0
  150. package/dist/lib/download.js +31 -0
  151. package/dist/lib/download.js.map +1 -0
  152. package/dist/lib/form-field.d.ts +37 -0
  153. package/dist/lib/form-field.d.ts.map +1 -0
  154. package/dist/lib/form-field.js +47 -0
  155. package/dist/lib/form-field.js.map +1 -0
  156. package/dist/r/app-shell.json +5 -0
  157. package/dist/r/catalog-browser.json +47 -0
  158. package/dist/r/combobox.json +5 -0
  159. package/dist/r/empty.json +10 -1
  160. package/dist/r/field.json +7 -0
  161. package/dist/r/floating-preview.json +30 -0
  162. package/dist/r/input-group.json +6 -0
  163. package/dist/r/input.json +5 -0
  164. package/dist/r/lib.json +15 -0
  165. package/dist/r/list-row.json +1 -0
  166. package/dist/r/registry.json +159 -1
  167. package/dist/r/src/components/composites/action-button/action-button.tsx +5 -1
  168. package/dist/r/src/components/composites/catalog-browser/catalog-browser-group.tsx +148 -0
  169. package/dist/r/src/components/composites/catalog-browser/catalog-browser.tsx +258 -0
  170. package/dist/r/src/components/composites/catalog-browser/catalog-detail.tsx +64 -0
  171. package/dist/r/src/components/composites/catalog-browser/catalog-types.ts +83 -0
  172. package/dist/r/src/components/composites/catalog-browser/index.ts +10 -0
  173. package/dist/r/src/components/composites/list-row/list-row.tsx +81 -8
  174. package/dist/r/src/components/composites/settings/settings.tsx +67 -37
  175. package/dist/r/src/components/conversation/scroll-follow.ts +109 -15
  176. package/dist/r/src/components/layout/app-shell/index.ts +1 -0
  177. package/dist/r/src/components/layout/app-shell/skip-link.tsx +35 -0
  178. package/dist/r/src/components/layout/floating-preview/floating-preview.tsx +260 -0
  179. package/dist/r/src/components/layout/floating-preview/index.ts +2 -0
  180. package/dist/r/src/components/layout/floating-preview/model.ts +285 -0
  181. package/dist/r/src/components/theme/index.ts +1 -0
  182. package/dist/r/src/components/theme/theme-mode-toggle.tsx +56 -0
  183. package/dist/r/src/components/ui/checkbox/checkbox.tsx +20 -3
  184. package/dist/r/src/components/ui/combobox/combobox.tsx +24 -1
  185. package/dist/r/src/components/ui/combobox/index.ts +1 -0
  186. package/dist/r/src/components/ui/combobox/value-combobox.tsx +38 -0
  187. package/dist/r/src/components/ui/empty/empty-state.tsx +71 -0
  188. package/dist/r/src/components/ui/empty/index.ts +1 -0
  189. package/dist/r/src/components/ui/entity-icon/entity-icon.tsx +33 -2
  190. package/dist/r/src/components/ui/field/field.tsx +102 -5
  191. package/dist/r/src/components/ui/field/index.ts +5 -0
  192. package/dist/r/src/components/ui/field/number-field.tsx +71 -0
  193. package/dist/r/src/components/ui/input/index.ts +1 -0
  194. package/dist/r/src/components/ui/input/input-control.tsx +38 -0
  195. package/dist/r/src/components/ui/input/input.tsx +32 -20
  196. package/dist/r/src/components/ui/input-group/index.ts +1 -0
  197. package/dist/r/src/components/ui/input-group/input-group.tsx +25 -2
  198. package/dist/r/src/components/ui/input-group/secret-input-group.tsx +58 -0
  199. package/dist/r/src/components/ui/native-select/native-select.tsx +27 -1
  200. package/dist/r/src/components/ui/progress/progress.tsx +12 -8
  201. package/dist/r/src/components/ui/radio-group/radio-group.tsx +30 -2
  202. package/dist/r/src/components/ui/switch/switch.tsx +20 -3
  203. package/dist/r/src/components/ui/tabs/tabs.tsx +1 -1
  204. package/dist/r/src/components/ui/text-link/index.ts +2 -0
  205. package/dist/r/src/components/ui/text-link/text-link.tsx +28 -0
  206. package/dist/r/src/components/ui/textarea/textarea.tsx +24 -1
  207. package/dist/r/src/lib/clipboard.ts +41 -0
  208. package/dist/r/src/lib/download.ts +29 -0
  209. package/dist/r/src/lib/form-field.ts +93 -0
  210. package/dist/r/text-link.json +22 -0
  211. package/dist/r/theming.json +6 -0
  212. package/package.json +1 -1
  213. package/registry.json +159 -1
  214. package/src/components/composites/action-button/action-button.tsx +5 -1
  215. package/src/components/composites/catalog-browser/catalog-browser-group.tsx +148 -0
  216. package/src/components/composites/catalog-browser/catalog-browser.tsx +258 -0
  217. package/src/components/composites/catalog-browser/catalog-detail.tsx +64 -0
  218. package/src/components/composites/catalog-browser/catalog-types.ts +83 -0
  219. package/src/components/composites/catalog-browser/index.ts +10 -0
  220. package/src/components/composites/list-row/list-row.tsx +81 -8
  221. package/src/components/composites/settings/settings.tsx +67 -37
  222. package/src/components/conversation/scroll-follow.ts +109 -15
  223. package/src/components/layout/app-shell/index.ts +1 -0
  224. package/src/components/layout/app-shell/skip-link.tsx +35 -0
  225. package/src/components/layout/floating-preview/floating-preview.tsx +260 -0
  226. package/src/components/layout/floating-preview/index.ts +2 -0
  227. package/src/components/layout/floating-preview/model.ts +285 -0
  228. package/src/components/theme/index.ts +1 -0
  229. package/src/components/theme/theme-mode-toggle.tsx +56 -0
  230. package/src/components/ui/checkbox/checkbox.tsx +20 -3
  231. package/src/components/ui/combobox/combobox.tsx +24 -1
  232. package/src/components/ui/combobox/index.ts +1 -0
  233. package/src/components/ui/combobox/value-combobox.tsx +38 -0
  234. package/src/components/ui/empty/empty-state.tsx +71 -0
  235. package/src/components/ui/empty/index.ts +1 -0
  236. package/src/components/ui/entity-icon/entity-icon.tsx +33 -2
  237. package/src/components/ui/field/field.tsx +102 -5
  238. package/src/components/ui/field/index.ts +5 -0
  239. package/src/components/ui/field/number-field.tsx +71 -0
  240. package/src/components/ui/input/index.ts +1 -0
  241. package/src/components/ui/input/input-control.tsx +38 -0
  242. package/src/components/ui/input/input.tsx +32 -20
  243. package/src/components/ui/input-group/index.ts +1 -0
  244. package/src/components/ui/input-group/input-group.tsx +25 -2
  245. package/src/components/ui/input-group/secret-input-group.tsx +58 -0
  246. package/src/components/ui/native-select/native-select.tsx +27 -1
  247. package/src/components/ui/progress/progress.tsx +12 -8
  248. package/src/components/ui/radio-group/radio-group.tsx +30 -2
  249. package/src/components/ui/switch/switch.tsx +20 -3
  250. package/src/components/ui/tabs/tabs.tsx +1 -1
  251. package/src/components/ui/text-link/index.ts +2 -0
  252. package/src/components/ui/text-link/text-link.tsx +28 -0
  253. package/src/components/ui/textarea/textarea.tsx +24 -1
  254. package/src/index.ts +4 -0
  255. package/src/lib/clipboard.ts +41 -0
  256. package/src/lib/download.ts +29 -0
  257. package/src/lib/form-field.ts +93 -0
@@ -2,6 +2,7 @@ import type { PolymorphicProps } from '@kobalte/core/polymorphic'
2
2
  import { Polymorphic } from '@kobalte/core/polymorphic'
3
3
  import type { ComponentProps, JSX, ValidComponent } from 'solid-js'
4
4
  import { Show, splitProps } from 'solid-js'
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip'
5
6
  import { cn } from '#lib/utils'
6
7
 
7
8
  /**
@@ -18,9 +19,8 @@ import { cn } from '#lib/utils'
18
19
  * arithmetic.
19
20
  *
20
21
  * A row is only interactive when it is given an `as` that can be activated
21
- * (an anchor, a button) or an `onClick`. Without either it renders as a
22
- * `div` with no hover treatment, because a hover state on something that does
23
- * nothing is a lie.
22
+ * (an anchor, a button) or an `onClick`. Click handlers default to a native
23
+ * button; a row without either remains a `div` with no hover treatment.
24
24
  */
25
25
  export type ListRowProps<T extends ValidComponent = 'div'> = PolymorphicProps<
26
26
  T,
@@ -36,30 +36,50 @@ export type ListRowProps<T extends ValidComponent = 'div'> = PolymorphicProps<
36
36
  trailing?: JSX.Element
37
37
  /** A dimmed second line under the main content. */
38
38
  description?: string
39
+ /** A short explanation shown on pointer hover and keyboard focus. */
40
+ tooltip?: string
41
+ /** Native button type; defaults to `button` for button rows. */
42
+ type?: ComponentProps<'button'>['type']
39
43
  }
40
44
  >
41
45
 
42
- export function ListRow<T extends ValidComponent = 'div'>(props: ListRowProps<T>) {
46
+ type ListRowBaseProps<T extends ValidComponent = 'div'> = ListRowProps<T> & {
47
+ as?: T
48
+ type?: ComponentProps<'button'>['type']
49
+ }
50
+
51
+ function ListRowBase<T extends ValidComponent = 'div'>(props: ListRowBaseProps<T>) {
43
52
  const [local, rest] = splitProps(props as ListRowProps, [
53
+ 'as',
44
54
  'class',
45
55
  'selected',
46
56
  'dense',
47
57
  'leading',
48
58
  'trailing',
49
59
  'description',
60
+ 'tooltip',
61
+ 'type',
62
+ 'tabIndex',
50
63
  'children',
51
64
  ])
52
65
 
53
66
  return (
54
67
  <Polymorphic
55
- as="div"
68
+ as={(local.as ?? 'div') as T}
69
+ type={local.as === 'button' ? (local.type ?? 'button') : local.type}
70
+ tabIndex={local.tabIndex ?? (local.as === 'button' || local.as === 'a' ? 0 : undefined)}
56
71
  aria-current={local.selected ? 'true' : undefined}
57
72
  data-selected={local.selected ? '' : undefined}
58
73
  class={cn(
59
74
  'group/row flex min-w-0 items-center gap-2.5 rounded-md px-2 text-sm',
60
75
  'transition-colors ease-out outline-none',
61
76
  'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
62
- { 'h-row-sm': local.dense, 'h-row-md': !local.dense },
77
+ {
78
+ 'h-row-sm': local.dense && !local.description,
79
+ 'h-row-md': !local.dense && !local.description,
80
+ 'min-h-row-sm py-1': local.dense && local.description,
81
+ 'min-h-row-md py-1.5': !local.dense && local.description,
82
+ },
63
83
  {
64
84
  'bg-primary-subtle text-foreground': local.selected,
65
85
  'hover:bg-surface-hover': !local.selected,
@@ -76,11 +96,25 @@ export function ListRow<T extends ValidComponent = 'div'>(props: ListRowProps<T>
76
96
  <span class="flex min-w-0 flex-1 flex-col">
77
97
  <span class="truncate">{local.children}</span>
78
98
  <Show when={local.description}>
79
- <span class="text-muted-foreground truncate text-xs">{local.description}</span>
99
+ <span
100
+ data-slot="list-row-description"
101
+ class={cn(
102
+ 'truncate text-xs',
103
+ local.selected ? 'text-foreground' : 'text-muted-foreground'
104
+ )}
105
+ >
106
+ {local.description}
107
+ </span>
80
108
  </Show>
81
109
  </span>
82
110
  <Show when={local.trailing}>
83
- <span class="ms-auto flex shrink-0 items-center gap-1 text-muted-foreground">
111
+ <span
112
+ data-slot="list-row-trailing"
113
+ class={cn(
114
+ 'ms-auto flex shrink-0 items-center gap-1',
115
+ local.selected ? 'text-foreground' : 'text-muted-foreground'
116
+ )}
117
+ >
84
118
  {local.trailing}
85
119
  </span>
86
120
  </Show>
@@ -88,6 +122,45 @@ export function ListRow<T extends ValidComponent = 'div'>(props: ListRowProps<T>
88
122
  )
89
123
  }
90
124
 
125
+ type ListRowTooltipTargetProps<T extends ValidComponent = 'div'> = Omit<
126
+ ListRowBaseProps<T>,
127
+ 'as'
128
+ > & { rowAs?: T }
129
+
130
+ /** Adapts TooltipTrigger's polymorphic target to ListRow's semantic element. */
131
+ function ListRowTooltipTarget<T extends ValidComponent = 'div'>(
132
+ props: ListRowTooltipTargetProps<T>
133
+ ) {
134
+ const [local, rest] = splitProps(props as ListRowTooltipTargetProps, ['rowAs'])
135
+ return <ListRowBase as={local.rowAs} {...(rest as ListRowBaseProps<T>)} />
136
+ }
137
+
138
+ export function ListRow<T extends ValidComponent = 'div'>(props: ListRowProps<T>) {
139
+ const [local, rest] = splitProps(props as ListRowProps, ['as', 'tooltip'])
140
+ const hasClickHandler = () => Boolean(rest.onClick || rest['on:click'])
141
+ const rowAs = () => (local.as ?? (hasClickHandler() ? 'button' : 'div')) as T
142
+ const rowType = () => (rowAs() === 'button' ? (rest.type ?? 'button') : rest.type)
143
+
144
+ return (
145
+ <Show
146
+ when={local.tooltip}
147
+ fallback={<ListRowBase as={rowAs()} type={rowType()} {...(rest as ListRowBaseProps<T>)} />}
148
+ >
149
+ <Tooltip>
150
+ <TooltipTrigger
151
+ as={ListRowTooltipTarget}
152
+ rowAs={rowAs()}
153
+ type={rowType()}
154
+ {...(rest as ListRowTooltipTargetProps<T>)}
155
+ >
156
+ {rest.children}
157
+ </TooltipTrigger>
158
+ <TooltipContent>{local.tooltip}</TooltipContent>
159
+ </Tooltip>
160
+ </Show>
161
+ )
162
+ }
163
+
91
164
  /**
92
165
  * A group of rows with an optional heading and a scrolling body. The heading is
93
166
  * a plain label rather than a control: grouping is for reading, and a
@@ -1,5 +1,6 @@
1
1
  import type { ComponentProps, JSX } from 'solid-js'
2
- import { Show, splitProps } from 'solid-js'
2
+ import { createUniqueId, Show, splitProps } from 'solid-js'
3
+ import { createFormFieldContext, FormFieldContext } from '#lib/form-field'
3
4
  import { cn } from '#lib/utils'
4
5
 
5
6
  /**
@@ -10,13 +11,10 @@ import { cn } from '#lib/utils'
10
11
  * placement all wander unless something fixes them.
11
12
  *
12
13
  * The row is a two-column layout at a fixed label width, so controls line up
13
- * down the page regardless of how long the labels are. That alignment is the
14
- * difference between a settings page that reads as designed and one that reads
15
- * as a form.
16
- *
17
- * `SettingsRow` puts the control on the trailing side and the label leading;
18
- * `SettingsField` stacks them for a control too wide for a row (a textarea, a
19
- * list). Both share the same label and description treatment.
14
+ * down the page regardless of how long the labels are. `SettingsRow` also
15
+ * exposes its label and description through the shared form-field context to
16
+ * the first control inside it. `SettingsField` is the visual stacked layout;
17
+ * use `FormField` when the control needs label, help, or validation wiring.
20
18
  */
21
19
  export function SettingsSection(
22
20
  props: ComponentProps<'section'> & {
@@ -24,9 +22,18 @@ export function SettingsSection(
24
22
  description?: string
25
23
  /** A control for the section as a whole, e.g. a master toggle. */
26
24
  action?: JSX.Element
25
+ /** Use a spaced content stack for fields, alerts, and cards instead of divided rows. */
26
+ bodyLayout?: 'rows' | 'content'
27
27
  }
28
28
  ) {
29
- const [local, rest] = splitProps(props, ['class', 'title', 'description', 'action', 'children'])
29
+ const [local, rest] = splitProps(props, [
30
+ 'class',
31
+ 'title',
32
+ 'description',
33
+ 'action',
34
+ 'bodyLayout',
35
+ 'children',
36
+ ])
30
37
 
31
38
  return (
32
39
  <section
@@ -45,7 +52,16 @@ export function SettingsSection(
45
52
  <div class="flex shrink-0 items-center gap-2">{local.action}</div>
46
53
  </Show>
47
54
  </div>
48
- <div class="divide-y divide-border rounded-xl border border-border">{local.children}</div>
55
+ <div
56
+ data-slot="settings-section-body"
57
+ class={cn(
58
+ local.bodyLayout === 'content'
59
+ ? 'flex flex-col gap-4'
60
+ : 'divide-y divide-border rounded-xl border border-border'
61
+ )}
62
+ >
63
+ {local.children}
64
+ </div>
49
65
  </section>
50
66
  )
51
67
  }
@@ -54,10 +70,9 @@ export function SettingsSection(
54
70
  * One setting: a label and description on the leading side, a control on the
55
71
  * trailing side.
56
72
  *
57
- * `label` is wired to the control through `aria-labelledby` when the control is
58
- * not already labelled, so a caller can pass a bare `Switch` and still get an
59
- * accessible name. The row does not invent an id per render — it derives one
60
- * from the label, which is stable across renders and readable in a snapshot.
73
+ * The label and description are linked to a shared control through field
74
+ * context. An explicit `aria-label` or `aria-labelledby` on the control keeps
75
+ * precedence; otherwise the visible row label names it.
61
76
  */
62
77
  export function SettingsRow(
63
78
  props: ComponentProps<'div'> & {
@@ -77,34 +92,49 @@ export function SettingsRow(
77
92
  'bare',
78
93
  'children',
79
94
  ])
95
+ const rowId = createUniqueId()
96
+ const labelId = `${rowId}-label`
97
+ const descriptionId = `${rowId}-description`
98
+ const field = createFormFieldContext({
99
+ defaultControlId: rowId,
100
+ labelId,
101
+ descriptionId,
102
+ hint: () => local.description,
103
+ error: () => undefined,
104
+ group: () => false,
105
+ })
80
106
 
81
107
  return (
82
- <div
83
- data-slot="settings-row"
84
- class={cn(
85
- 'flex gap-4 p-4',
86
- local.orientation === 'vertical'
87
- ? 'flex-col'
88
- : 'flex-col sm:flex-row sm:items-center sm:justify-between',
89
- local.class
90
- )}
91
- {...rest}
92
- >
93
- <div class="flex min-w-0 flex-col gap-0.5">
94
- <div data-slot="settings-row-label" class="text-sm font-medium">
95
- {local.label}
96
- </div>
97
- <Show when={local.description}>
98
- <p class="max-w-prose text-sm text-muted-foreground text-pretty">{local.description}</p>
99
- </Show>
100
- </div>
108
+ <FormFieldContext.Provider value={field}>
101
109
  <div
102
- data-slot="settings-row-control"
103
- class={cn('shrink-0', local.orientation === 'vertical' && 'w-full')}
110
+ data-slot="settings-row"
111
+ class={cn(
112
+ 'flex gap-4 p-4',
113
+ local.orientation === 'vertical'
114
+ ? 'flex-col'
115
+ : 'flex-col sm:flex-row sm:items-center sm:justify-between',
116
+ local.class
117
+ )}
118
+ {...rest}
104
119
  >
105
- {local.children}
120
+ <div class="flex min-w-0 flex-col gap-0.5">
121
+ <div id={labelId} data-slot="settings-row-label" class="text-sm font-medium">
122
+ {local.label}
123
+ </div>
124
+ <Show when={local.description}>
125
+ <p id={descriptionId} class="max-w-prose text-sm text-muted-foreground text-pretty">
126
+ {local.description}
127
+ </p>
128
+ </Show>
129
+ </div>
130
+ <div
131
+ data-slot="settings-row-control"
132
+ class={cn('shrink-0', local.orientation === 'vertical' && 'w-full')}
133
+ >
134
+ {local.children}
135
+ </div>
106
136
  </div>
107
- </div>
137
+ </FormFieldContext.Provider>
108
138
  )
109
139
  }
110
140
 
@@ -28,6 +28,7 @@ const geometry = (element: HTMLDivElement) => ({
28
28
  scrollHeight: element.scrollHeight,
29
29
  clientHeight: element.clientHeight,
30
30
  })
31
+ const noop = () => {}
31
32
 
32
33
  /** Internal plain-scroller binding; host-owned restoration can disable follow. */
33
34
  export function createScrollFollow(options: {
@@ -45,6 +46,9 @@ export function createScrollFollow(options: {
45
46
  let lastWriteClientHeight = -1
46
47
  let previousTop = -1
47
48
  let lastScrollClientHeight = 0
49
+ let pendingRestore: { frame: number; fromTop: number; target: number } | undefined
50
+ let pendingRestoreUserIntent = false
51
+ let removeRestoreIntentListeners = noop
48
52
  const reportPosition = () => {
49
53
  if (scroller)
50
54
  untrack(() =>
@@ -60,12 +64,12 @@ export function createScrollFollow(options: {
60
64
  // Instant writes keep the self-scroll reference synchronized. A smooth
61
65
  // animation would produce intermediate positions that resemble user input.
62
66
  element.scrollTop = target
63
- lastWriteTop = target
67
+ lastWriteTop = element.scrollTop
64
68
  lastWriteClientHeight = element.clientHeight
65
- previousTop = target
69
+ previousTop = element.scrollTop
66
70
  }
67
71
  const pinAuto = () => {
68
- if (!scroller || !options.enabled()) return
72
+ if (!scroller || !options.enabled() || pendingRestore) return
69
73
  const geom = geometry(scroller)
70
74
  const result = evaluateAutoPin({
71
75
  stick,
@@ -84,6 +88,26 @@ export function createScrollFollow(options: {
84
88
  }
85
89
  const onScroll = () => {
86
90
  if (!scroller) return
91
+ if (pendingRestore) {
92
+ // A reset can clamp the outgoing offset while the incoming rows are
93
+ // shorter. That notification is layout, not reader intent, and must not
94
+ // cancel the snapshot waiting for the new rows to commit.
95
+ const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
96
+ const clampedFromTop = Math.min(pendingRestore.fromTop, maxTop)
97
+ if (
98
+ pendingRestore.fromTop - clampedFromTop > 1 &&
99
+ Math.abs(scroller.scrollTop - clampedFromTop) <= 1
100
+ )
101
+ return
102
+ // An unexplained offset change can also be a browser clamp. Only actual
103
+ // user scroll intent followed by a non-clamp movement cancels restoration.
104
+ if (!pendingRestoreUserIntent) return
105
+ cancelAnimationFrame(pendingRestore.frame)
106
+ pendingRestore = undefined
107
+ pendingRestoreUserIntent = false
108
+ removeRestoreIntentListeners()
109
+ removeRestoreIntentListeners = noop
110
+ }
87
111
  if (!options.enabled()) {
88
112
  reportPosition()
89
113
  return
@@ -125,7 +149,21 @@ export function createScrollFollow(options: {
125
149
  options.resetKey()
126
150
  const enabled = options.enabled()
127
151
  // Capture before any reporting callback can replace the host snapshot.
128
- const initial = untrack(() => options.initialPosition?.())
152
+ const candidate = untrack(() => options.initialPosition?.())
153
+ const initial =
154
+ candidate && Number.isFinite(candidate.top) && candidate.top >= 0 ? candidate : undefined
155
+ let scheduledRestore: typeof pendingRestore
156
+ let observer: ResizeObserver | undefined
157
+ onCleanup(() => {
158
+ if (scheduledRestore) {
159
+ cancelAnimationFrame(scheduledRestore.frame)
160
+ if (pendingRestore === scheduledRestore) pendingRestore = undefined
161
+ }
162
+ pendingRestoreUserIntent = false
163
+ removeRestoreIntentListeners()
164
+ removeRestoreIntentListeners = noop
165
+ observer?.disconnect()
166
+ })
129
167
  stick = true
130
168
  lastWriteTop = -1
131
169
  lastWriteClientHeight = -1
@@ -135,19 +173,75 @@ export function createScrollFollow(options: {
135
173
  if (!enabled || !scroller) return
136
174
  // Snapshot updates must not become another positioning owner. Consume the
137
175
  // host's snapshot only at entry/reset, then native reader intent owns follow.
138
- if (initial && Number.isFinite(initial.top) && initial.top >= 0) {
139
- stick = initial.following
140
- writePin(scroller, stick ? bottomTarget(geometry(scroller)) : initial.top)
141
- previousTop = scroller.scrollTop
142
- lastScrollClientHeight = scroller.clientHeight
143
- } else writePin(scroller, bottomTarget(geometry(scroller)))
176
+ if (initial) {
177
+ stick = enabled && initial.following
178
+ }
179
+
180
+ const observe = () => {
181
+ if (!enabled || typeof ResizeObserver === 'undefined') return
182
+ observer = new ResizeObserver(pinAuto)
183
+ observer.observe(scroller!)
184
+ if (content) observer.observe(content)
185
+ }
186
+
187
+ const watchRestoreIntent = () => {
188
+ const element = scroller
189
+ if (!element) return
190
+ const markIntent = (event: Event) => {
191
+ if (!pendingRestore) return
192
+ if (event.type === 'pointerdown' && event.target !== element) return
193
+ if (event.type === 'keydown') {
194
+ if (event.target !== element) return
195
+ const key = (event as KeyboardEvent).key
196
+ if (!['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Home', 'End', ' '].includes(key))
197
+ return
198
+ }
199
+ pendingRestoreUserIntent = true
200
+ }
201
+ element.addEventListener('wheel', markIntent, { passive: true })
202
+ element.addEventListener('pointerdown', markIntent)
203
+ element.addEventListener('keydown', markIntent)
204
+ removeRestoreIntentListeners = () => {
205
+ element.removeEventListener('wheel', markIntent)
206
+ element.removeEventListener('pointerdown', markIntent)
207
+ element.removeEventListener('keydown', markIntent)
208
+ }
209
+ }
210
+
211
+ if (initial && !stick) {
212
+ // The identity effect can run before Solid has inserted the new rows. A
213
+ // synchronous write is clamped by the outgoing short transcript, so defer
214
+ // restoration until the browser has committed the new content and layout.
215
+ const restore = {
216
+ frame: 0,
217
+ fromTop: scroller.scrollTop,
218
+ target: initial.top,
219
+ }
220
+ scheduledRestore = restore
221
+ pendingRestore = restore
222
+ pendingRestoreUserIntent = false
223
+ watchRestoreIntent()
224
+ // Keep resize observation alive during the pending frame. Its callback is
225
+ // guarded above, and remains active if user input cancels restoration.
226
+ observe()
227
+ restore.frame = requestAnimationFrame(() => {
228
+ if (pendingRestore !== restore || !scroller) return
229
+ pendingRestore = undefined
230
+ pendingRestoreUserIntent = false
231
+ removeRestoreIntentListeners()
232
+ removeRestoreIntentListeners = noop
233
+ writePin(scroller, restore.target)
234
+ setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))
235
+ reportPosition()
236
+ })
237
+ return
238
+ }
239
+
240
+ if (!initial && enabled) writePin(scroller, bottomTarget(geometry(scroller)))
241
+ else if (initial && stick) writePin(scroller, bottomTarget(geometry(scroller)))
144
242
  setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))
145
243
  reportPosition()
146
- if (typeof ResizeObserver === 'undefined') return
147
- const observer = new ResizeObserver(pinAuto)
148
- observer.observe(scroller)
149
- if (content) observer.observe(content)
150
- onCleanup(() => observer.disconnect())
244
+ observe()
151
245
  })
152
246
 
153
247
  return {
@@ -1 +1,2 @@
1
1
  export { AppShell, AppShellBody, AppShellContent, AppShellMain } from './app-shell'
2
+ export { SkipLink } from './skip-link'
@@ -0,0 +1,35 @@
1
+ import type { ComponentProps } from 'solid-js'
2
+ import { splitProps } from 'solid-js'
3
+ import { cn } from '#lib/utils'
4
+
5
+ /**
6
+ * A keyboard-first link to the main content region.
7
+ *
8
+ * Place it before the shell's navigation and set the destination element's
9
+ * `id` to match `href`. The link stays translated above the viewport until it
10
+ * receives focus, then moves into view. Its text remains available to assistive
11
+ * technology while it is visually hidden.
12
+ */
13
+ export function SkipLink(props: ComponentProps<'a'>) {
14
+ const [local, rest] = splitProps(props, ['class', 'children', 'href', 'tabIndex'])
15
+
16
+ return (
17
+ <a
18
+ data-slot="skip-link"
19
+ class={cn(
20
+ 'fixed top-0 start-2 z-(--z-toast) -translate-y-full',
21
+ 'inline-flex items-center rounded-md border border-border bg-background',
22
+ 'px-control-md py-2 text-sm font-medium text-foreground shadow-lg',
23
+ 'underline decoration-2 underline-offset-2 outline-none transition-transform duration-150',
24
+ 'focus:translate-y-0 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
25
+ 'motion-reduce:transition-none',
26
+ local.class
27
+ )}
28
+ href={local.href ?? '#main-content'}
29
+ tabIndex={local.tabIndex ?? 0}
30
+ {...rest}
31
+ >
32
+ {local.children === undefined ? 'Skip to main content' : local.children}
33
+ </a>
34
+ )
35
+ }