@adea-ai/ui 0.85.0 → 0.88.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 (239) hide show
  1. package/README.md +17 -1
  2. package/dist/NOTICE +34 -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/list-row/list-row.d.ts +6 -3
  7. package/dist/components/composites/list-row/list-row.d.ts.map +1 -1
  8. package/dist/components/composites/list-row/list-row.js +69 -7
  9. package/dist/components/composites/list-row/list-row.js.map +1 -1
  10. package/dist/components/composites/settings/settings.d.ts +9 -11
  11. package/dist/components/composites/settings/settings.d.ts.map +1 -1
  12. package/dist/components/composites/settings/settings.js +48 -33
  13. package/dist/components/composites/settings/settings.js.map +1 -1
  14. package/dist/components/conversation/message-composer.js +1 -1
  15. package/dist/components/conversation/scroll-follow.d.ts.map +1 -1
  16. package/dist/components/conversation/scroll-follow.js +95 -15
  17. package/dist/components/conversation/scroll-follow.js.map +1 -1
  18. package/dist/components/layout/app-shell/index.d.ts +1 -0
  19. package/dist/components/layout/app-shell/index.d.ts.map +1 -1
  20. package/dist/components/layout/app-shell/index.js +2 -1
  21. package/dist/components/layout/app-shell/skip-link.d.ts +11 -0
  22. package/dist/components/layout/app-shell/skip-link.d.ts.map +1 -0
  23. package/dist/components/layout/app-shell/skip-link.js +44 -0
  24. package/dist/components/layout/app-shell/skip-link.js.map +1 -0
  25. package/dist/components/layout/floating-preview/floating-preview.d.ts +12 -0
  26. package/dist/components/layout/floating-preview/floating-preview.d.ts.map +1 -0
  27. package/dist/components/layout/floating-preview/floating-preview.js +283 -0
  28. package/dist/components/layout/floating-preview/floating-preview.js.map +1 -0
  29. package/dist/components/layout/floating-preview/index.d.ts +3 -0
  30. package/dist/components/layout/floating-preview/index.d.ts.map +1 -0
  31. package/dist/components/layout/floating-preview/index.js +3 -0
  32. package/dist/components/layout/floating-preview/model.d.ts +74 -0
  33. package/dist/components/layout/floating-preview/model.d.ts.map +1 -0
  34. package/dist/components/layout/floating-preview/model.js +146 -0
  35. package/dist/components/layout/floating-preview/model.js.map +1 -0
  36. package/dist/components/layout/sidebar-nav/index.d.ts +1 -1
  37. package/dist/components/layout/sidebar-nav/index.d.ts.map +1 -1
  38. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts +18 -2
  39. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts.map +1 -1
  40. package/dist/components/layout/sidebar-nav/sidebar-nav.js +42 -11
  41. package/dist/components/layout/sidebar-nav/sidebar-nav.js.map +1 -1
  42. package/dist/components/theme/index.d.ts +1 -0
  43. package/dist/components/theme/index.d.ts.map +1 -1
  44. package/dist/components/theme/index.js +2 -1
  45. package/dist/components/theme/theme-mode-toggle.d.ts +10 -0
  46. package/dist/components/theme/theme-mode-toggle.d.ts.map +1 -0
  47. package/dist/components/theme/theme-mode-toggle.js +82 -0
  48. package/dist/components/theme/theme-mode-toggle.js.map +1 -0
  49. package/dist/components/ui/checkbox/checkbox.d.ts +2 -0
  50. package/dist/components/ui/checkbox/checkbox.d.ts.map +1 -1
  51. package/dist/components/ui/checkbox/checkbox.js +24 -3
  52. package/dist/components/ui/checkbox/checkbox.js.map +1 -1
  53. package/dist/components/ui/combobox/combobox.d.ts.map +1 -1
  54. package/dist/components/ui/combobox/combobox.js +41 -4
  55. package/dist/components/ui/combobox/combobox.js.map +1 -1
  56. package/dist/components/ui/combobox/index.d.ts +1 -0
  57. package/dist/components/ui/combobox/index.d.ts.map +1 -1
  58. package/dist/components/ui/combobox/index.js +2 -1
  59. package/dist/components/ui/combobox/value-combobox.d.ts +19 -0
  60. package/dist/components/ui/combobox/value-combobox.d.ts.map +1 -0
  61. package/dist/components/ui/combobox/value-combobox.js +42 -0
  62. package/dist/components/ui/combobox/value-combobox.js.map +1 -0
  63. package/dist/components/ui/empty/empty-state.d.ts +15 -0
  64. package/dist/components/ui/empty/empty-state.d.ts.map +1 -0
  65. package/dist/components/ui/empty/empty-state.js +96 -0
  66. package/dist/components/ui/empty/empty-state.js.map +1 -0
  67. package/dist/components/ui/empty/index.d.ts +1 -0
  68. package/dist/components/ui/empty/index.d.ts.map +1 -1
  69. package/dist/components/ui/empty/index.js +2 -1
  70. package/dist/components/ui/entity-icon/entity-icon.d.ts +4 -1
  71. package/dist/components/ui/entity-icon/entity-icon.d.ts.map +1 -1
  72. package/dist/components/ui/entity-icon/entity-icon.js +37 -13
  73. package/dist/components/ui/entity-icon/entity-icon.js.map +1 -1
  74. package/dist/components/ui/field/field.d.ts +36 -13
  75. package/dist/components/ui/field/field.d.ts.map +1 -1
  76. package/dist/components/ui/field/field.js +146 -24
  77. package/dist/components/ui/field/field.js.map +1 -1
  78. package/dist/components/ui/field/index.d.ts +3 -1
  79. package/dist/components/ui/field/index.d.ts.map +1 -1
  80. package/dist/components/ui/field/index.js +3 -2
  81. package/dist/components/ui/field/number-field.d.ts +19 -0
  82. package/dist/components/ui/field/number-field.d.ts.map +1 -0
  83. package/dist/components/ui/field/number-field.js +76 -0
  84. package/dist/components/ui/field/number-field.js.map +1 -0
  85. package/dist/components/ui/input/index.d.ts +1 -0
  86. package/dist/components/ui/input/index.d.ts.map +1 -1
  87. package/dist/components/ui/input/index.js +2 -1
  88. package/dist/components/ui/input/input-control.d.ts +11 -0
  89. package/dist/components/ui/input/input-control.d.ts.map +1 -0
  90. package/dist/components/ui/input/input-control.js +24 -0
  91. package/dist/components/ui/input/input-control.js.map +1 -0
  92. package/dist/components/ui/input/input.d.ts.map +1 -1
  93. package/dist/components/ui/input/input.js +56 -28
  94. package/dist/components/ui/input/input.js.map +1 -1
  95. package/dist/components/ui/input-group/index.d.ts +1 -0
  96. package/dist/components/ui/input-group/index.d.ts.map +1 -1
  97. package/dist/components/ui/input-group/index.js +2 -1
  98. package/dist/components/ui/input-group/input-group.d.ts.map +1 -1
  99. package/dist/components/ui/input-group/input-group.js +42 -5
  100. package/dist/components/ui/input-group/input-group.js.map +1 -1
  101. package/dist/components/ui/input-group/secret-input-group.d.ts +11 -0
  102. package/dist/components/ui/input-group/secret-input-group.d.ts.map +1 -0
  103. package/dist/components/ui/input-group/secret-input-group.js +67 -0
  104. package/dist/components/ui/input-group/secret-input-group.js.map +1 -0
  105. package/dist/components/ui/native-select/native-select.d.ts.map +1 -1
  106. package/dist/components/ui/native-select/native-select.js +39 -4
  107. package/dist/components/ui/native-select/native-select.js.map +1 -1
  108. package/dist/components/ui/progress/progress.d.ts.map +1 -1
  109. package/dist/components/ui/progress/progress.js +30 -18
  110. package/dist/components/ui/progress/progress.js.map +1 -1
  111. package/dist/components/ui/radio-group/radio-group.d.ts.map +1 -1
  112. package/dist/components/ui/radio-group/radio-group.js +41 -4
  113. package/dist/components/ui/radio-group/radio-group.js.map +1 -1
  114. package/dist/components/ui/switch/switch.d.ts +2 -0
  115. package/dist/components/ui/switch/switch.d.ts.map +1 -1
  116. package/dist/components/ui/switch/switch.js +24 -3
  117. package/dist/components/ui/switch/switch.js.map +1 -1
  118. package/dist/components/ui/tabs/tabs.js +1 -1
  119. package/dist/components/ui/tabs/tabs.js.map +1 -1
  120. package/dist/components/ui/text-link/index.d.ts +2 -0
  121. package/dist/components/ui/text-link/index.d.ts.map +1 -0
  122. package/dist/components/ui/text-link/index.js +2 -0
  123. package/dist/components/ui/text-link/text-link.d.ts +9 -0
  124. package/dist/components/ui/text-link/text-link.d.ts.map +1 -0
  125. package/dist/components/ui/text-link/text-link.js +30 -0
  126. package/dist/components/ui/text-link/text-link.js.map +1 -0
  127. package/dist/components/ui/textarea/textarea.d.ts.map +1 -1
  128. package/dist/components/ui/textarea/textarea.js +41 -4
  129. package/dist/components/ui/textarea/textarea.js.map +1 -1
  130. package/dist/index.d.ts +3 -0
  131. package/dist/index.d.ts.map +1 -1
  132. package/dist/index.js +18 -7
  133. package/dist/lib/clipboard.d.ts +3 -0
  134. package/dist/lib/clipboard.d.ts.map +1 -0
  135. package/dist/lib/clipboard.js +35 -0
  136. package/dist/lib/clipboard.js.map +1 -0
  137. package/dist/lib/download.d.ts +3 -0
  138. package/dist/lib/download.d.ts.map +1 -0
  139. package/dist/lib/download.js +31 -0
  140. package/dist/lib/download.js.map +1 -0
  141. package/dist/lib/form-field.d.ts +37 -0
  142. package/dist/lib/form-field.d.ts.map +1 -0
  143. package/dist/lib/form-field.js +47 -0
  144. package/dist/lib/form-field.js.map +1 -0
  145. package/dist/r/app-shell.json +5 -0
  146. package/dist/r/combobox.json +5 -0
  147. package/dist/r/empty.json +10 -1
  148. package/dist/r/field.json +7 -0
  149. package/dist/r/floating-preview.json +30 -0
  150. package/dist/r/input-group.json +6 -0
  151. package/dist/r/input.json +5 -0
  152. package/dist/r/lib.json +15 -0
  153. package/dist/r/list-row.json +1 -0
  154. package/dist/r/registry.json +112 -1
  155. package/dist/r/src/components/composites/action-button/action-button.tsx +5 -1
  156. package/dist/r/src/components/composites/list-row/list-row.tsx +81 -8
  157. package/dist/r/src/components/composites/settings/settings.tsx +67 -37
  158. package/dist/r/src/components/conversation/scroll-follow.ts +109 -15
  159. package/dist/r/src/components/layout/app-shell/index.ts +1 -0
  160. package/dist/r/src/components/layout/app-shell/skip-link.tsx +35 -0
  161. package/dist/r/src/components/layout/floating-preview/floating-preview.tsx +260 -0
  162. package/dist/r/src/components/layout/floating-preview/index.ts +2 -0
  163. package/dist/r/src/components/layout/floating-preview/model.ts +285 -0
  164. package/dist/r/src/components/layout/sidebar-nav/index.ts +2 -0
  165. package/dist/r/src/components/layout/sidebar-nav/sidebar-nav.tsx +71 -16
  166. package/dist/r/src/components/theme/index.ts +1 -0
  167. package/dist/r/src/components/theme/theme-mode-toggle.tsx +56 -0
  168. package/dist/r/src/components/ui/checkbox/checkbox.tsx +20 -3
  169. package/dist/r/src/components/ui/combobox/combobox.tsx +24 -1
  170. package/dist/r/src/components/ui/combobox/index.ts +1 -0
  171. package/dist/r/src/components/ui/combobox/value-combobox.tsx +38 -0
  172. package/dist/r/src/components/ui/empty/empty-state.tsx +71 -0
  173. package/dist/r/src/components/ui/empty/index.ts +1 -0
  174. package/dist/r/src/components/ui/entity-icon/entity-icon.tsx +33 -2
  175. package/dist/r/src/components/ui/field/field.tsx +102 -5
  176. package/dist/r/src/components/ui/field/index.ts +5 -0
  177. package/dist/r/src/components/ui/field/number-field.tsx +71 -0
  178. package/dist/r/src/components/ui/input/index.ts +1 -0
  179. package/dist/r/src/components/ui/input/input-control.tsx +38 -0
  180. package/dist/r/src/components/ui/input/input.tsx +32 -20
  181. package/dist/r/src/components/ui/input-group/index.ts +1 -0
  182. package/dist/r/src/components/ui/input-group/input-group.tsx +25 -2
  183. package/dist/r/src/components/ui/input-group/secret-input-group.tsx +58 -0
  184. package/dist/r/src/components/ui/native-select/native-select.tsx +27 -1
  185. package/dist/r/src/components/ui/progress/progress.tsx +12 -8
  186. package/dist/r/src/components/ui/radio-group/radio-group.tsx +30 -2
  187. package/dist/r/src/components/ui/switch/switch.tsx +20 -3
  188. package/dist/r/src/components/ui/tabs/tabs.tsx +1 -1
  189. package/dist/r/src/components/ui/text-link/index.ts +2 -0
  190. package/dist/r/src/components/ui/text-link/text-link.tsx +28 -0
  191. package/dist/r/src/components/ui/textarea/textarea.tsx +24 -1
  192. package/dist/r/src/lib/clipboard.ts +41 -0
  193. package/dist/r/src/lib/download.ts +29 -0
  194. package/dist/r/src/lib/form-field.ts +93 -0
  195. package/dist/r/text-link.json +22 -0
  196. package/dist/r/theming.json +6 -0
  197. package/package.json +1 -1
  198. package/registry.json +112 -1
  199. package/src/components/composites/action-button/action-button.tsx +5 -1
  200. package/src/components/composites/list-row/list-row.tsx +81 -8
  201. package/src/components/composites/settings/settings.tsx +67 -37
  202. package/src/components/conversation/scroll-follow.ts +109 -15
  203. package/src/components/layout/app-shell/index.ts +1 -0
  204. package/src/components/layout/app-shell/skip-link.tsx +35 -0
  205. package/src/components/layout/floating-preview/floating-preview.tsx +260 -0
  206. package/src/components/layout/floating-preview/index.ts +2 -0
  207. package/src/components/layout/floating-preview/model.ts +285 -0
  208. package/src/components/layout/sidebar-nav/index.ts +2 -0
  209. package/src/components/layout/sidebar-nav/sidebar-nav.tsx +71 -16
  210. package/src/components/theme/index.ts +1 -0
  211. package/src/components/theme/theme-mode-toggle.tsx +56 -0
  212. package/src/components/ui/checkbox/checkbox.tsx +20 -3
  213. package/src/components/ui/combobox/combobox.tsx +24 -1
  214. package/src/components/ui/combobox/index.ts +1 -0
  215. package/src/components/ui/combobox/value-combobox.tsx +38 -0
  216. package/src/components/ui/empty/empty-state.tsx +71 -0
  217. package/src/components/ui/empty/index.ts +1 -0
  218. package/src/components/ui/entity-icon/entity-icon.tsx +33 -2
  219. package/src/components/ui/field/field.tsx +102 -5
  220. package/src/components/ui/field/index.ts +5 -0
  221. package/src/components/ui/field/number-field.tsx +71 -0
  222. package/src/components/ui/input/index.ts +1 -0
  223. package/src/components/ui/input/input-control.tsx +38 -0
  224. package/src/components/ui/input/input.tsx +32 -20
  225. package/src/components/ui/input-group/index.ts +1 -0
  226. package/src/components/ui/input-group/input-group.tsx +25 -2
  227. package/src/components/ui/input-group/secret-input-group.tsx +58 -0
  228. package/src/components/ui/native-select/native-select.tsx +27 -1
  229. package/src/components/ui/progress/progress.tsx +12 -8
  230. package/src/components/ui/radio-group/radio-group.tsx +30 -2
  231. package/src/components/ui/switch/switch.tsx +20 -3
  232. package/src/components/ui/tabs/tabs.tsx +1 -1
  233. package/src/components/ui/text-link/index.ts +2 -0
  234. package/src/components/ui/text-link/text-link.tsx +28 -0
  235. package/src/components/ui/textarea/textarea.tsx +24 -1
  236. package/src/index.ts +3 -0
  237. package/src/lib/clipboard.ts +41 -0
  238. package/src/lib/download.ts +29 -0
  239. package/src/lib/form-field.ts +93 -0
@@ -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
+ }
@@ -0,0 +1,260 @@
1
+ import { createEffect, createSignal, onCleanup, onMount, on, type JSX } from 'solid-js'
2
+ import { Move, X } from 'lucide-solid'
3
+ import { ActionButton } from '../../composites/action-button'
4
+ import { ScrollArea } from '../../ui/scroll-area'
5
+ import {
6
+ type FloatingPreviewFrame,
7
+ type FloatingPreviewResizeDirection,
8
+ type FloatingPreviewSize,
9
+ resolveFloatingPreviewFrame,
10
+ resizeFloatingPreview,
11
+ } from './model'
12
+
13
+ const directions: readonly {
14
+ direction: FloatingPreviewResizeDirection
15
+ class: string
16
+ glyph: string
17
+ }[] = [
18
+ {
19
+ direction: 'north',
20
+ class: 'absolute -top-3 right-6 left-6 h-control-xs w-auto cursor-ns-resize touch-none',
21
+ glyph: '↕',
22
+ },
23
+ {
24
+ direction: 'south',
25
+ class: 'absolute -bottom-3 right-6 left-6 h-control-xs w-auto cursor-ns-resize touch-none',
26
+ glyph: '↕',
27
+ },
28
+ {
29
+ direction: 'east',
30
+ class: 'absolute -right-3 top-6 bottom-6 w-6 h-auto cursor-ew-resize touch-none',
31
+ glyph: '↔',
32
+ },
33
+ {
34
+ direction: 'west',
35
+ class: 'absolute -left-3 top-6 bottom-6 w-6 h-auto cursor-ew-resize touch-none',
36
+ glyph: '↔',
37
+ },
38
+ {
39
+ direction: 'northwest',
40
+ class: 'absolute -top-3 -left-3 size-control-xs cursor-nwse-resize touch-none',
41
+ glyph: '↖',
42
+ },
43
+ {
44
+ direction: 'northeast',
45
+ class: 'absolute -top-3 -right-3 size-control-xs cursor-nesw-resize touch-none',
46
+ glyph: '↗',
47
+ },
48
+ {
49
+ direction: 'southwest',
50
+ class: 'absolute -bottom-3 -left-3 size-control-xs cursor-nesw-resize touch-none',
51
+ glyph: '↙',
52
+ },
53
+ {
54
+ direction: 'southeast',
55
+ class: 'absolute -bottom-3 -right-3 size-control-xs cursor-nwse-resize touch-none',
56
+ glyph: '↘',
57
+ },
58
+ ]
59
+
60
+ export type FloatingPreviewProps = {
61
+ label: string
62
+ source: FloatingPreviewSize
63
+ onClose?: () => void
64
+ actions?: JSX.Element
65
+ children: JSX.Element
66
+ }
67
+
68
+ /** A host-bounded movable preview frame. Content and actions remain caller-owned. */
69
+ export function FloatingPreview(props: FloatingPreviewProps) {
70
+ const [frame, setFrame] = createSignal<FloatingPreviewFrame>({ x: 0, y: 0, width: 1, height: 1 })
71
+ // oxlint-disable-next-line no-unassigned-vars -- assigned through Solid's ref callback.
72
+ let bounds: HTMLDivElement | undefined
73
+ let observer: ResizeObserver | undefined
74
+ let gesture:
75
+ | {
76
+ id: number
77
+ x: number
78
+ y: number
79
+ start: FloatingPreviewFrame
80
+ direction: FloatingPreviewResizeDirection | null
81
+ }
82
+ | undefined
83
+
84
+ const measure = () => {
85
+ const rect = bounds?.getBoundingClientRect()
86
+ if (!rect) return
87
+ setFrame((current) =>
88
+ resolveFloatingPreviewFrame({
89
+ width: current.width > 1 ? current.width : null,
90
+ position: current.width > 1 ? { x: current.x, y: current.y } : null,
91
+ source: props.source,
92
+ container: { width: rect.width, height: rect.height },
93
+ })
94
+ )
95
+ }
96
+ createEffect(
97
+ on(
98
+ () => [props.source.width, props.source.height] as const,
99
+ () => measure(),
100
+ { defer: true }
101
+ )
102
+ )
103
+ onMount(() => {
104
+ if (bounds) {
105
+ observer = new ResizeObserver(measure)
106
+ observer.observe(bounds)
107
+ measure()
108
+ }
109
+ })
110
+ onCleanup(() => observer?.disconnect())
111
+
112
+ function start(event: PointerEvent, direction: FloatingPreviewResizeDirection | null) {
113
+ if (event.button !== 0 || gesture) return
114
+ event.preventDefault()
115
+ event.stopPropagation()
116
+ gesture = { id: event.pointerId, x: event.clientX, y: event.clientY, start: frame(), direction }
117
+ ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
118
+ }
119
+ function move(event: PointerEvent) {
120
+ if (!gesture || gesture.id !== event.pointerId) return
121
+ const rect = bounds?.getBoundingClientRect()
122
+ if (!rect) return
123
+ const container = { width: rect.width, height: rect.height }
124
+ const delta = { x: event.clientX - gesture.x, y: event.clientY - gesture.y }
125
+ setFrame(
126
+ gesture.direction
127
+ ? resizeFloatingPreview({
128
+ start: gesture.start,
129
+ direction: gesture.direction,
130
+ delta,
131
+ source: props.source,
132
+ container,
133
+ })
134
+ : resolveFloatingPreviewFrame({
135
+ width: gesture.start.width,
136
+ position: { x: gesture.start.x + delta.x, y: gesture.start.y + delta.y },
137
+ source: props.source,
138
+ container,
139
+ })
140
+ )
141
+ }
142
+ function end(event: PointerEvent) {
143
+ if (gesture?.id === event.pointerId) gesture = undefined
144
+ }
145
+ function keyboardMove(event: KeyboardEvent) {
146
+ const delta = { x: 0, y: 0 }
147
+ if (event.key === 'ArrowLeft') delta.x = -10
148
+ else if (event.key === 'ArrowRight') delta.x = 10
149
+ else if (event.key === 'ArrowUp') delta.y = -10
150
+ else if (event.key === 'ArrowDown') delta.y = 10
151
+ else return
152
+ event.preventDefault()
153
+ const rect = bounds?.getBoundingClientRect()
154
+ if (!rect) return
155
+ const current = frame()
156
+ setFrame(
157
+ resolveFloatingPreviewFrame({
158
+ width: current.width,
159
+ position: { x: current.x + delta.x, y: current.y + delta.y },
160
+ source: props.source,
161
+ container: { width: rect.width, height: rect.height },
162
+ })
163
+ )
164
+ }
165
+ function keyboardResize(event: KeyboardEvent, direction: FloatingPreviewResizeDirection) {
166
+ if (!event.key.startsWith('Arrow')) return
167
+ event.preventDefault()
168
+ const rect = bounds?.getBoundingClientRect()
169
+ if (!rect) return
170
+ const delta = {
171
+ x: event.key === 'ArrowRight' ? 10 : event.key === 'ArrowLeft' ? -10 : 0,
172
+ y: event.key === 'ArrowDown' ? 10 : event.key === 'ArrowUp' ? -10 : 0,
173
+ }
174
+ setFrame(
175
+ resizeFloatingPreview({
176
+ start: frame(),
177
+ direction,
178
+ delta,
179
+ source: props.source,
180
+ container: { width: rect.width, height: rect.height },
181
+ })
182
+ )
183
+ }
184
+
185
+ return (
186
+ <div
187
+ ref={bounds}
188
+ class="pointer-events-none absolute inset-0 z-(--z-sticky) overflow-hidden"
189
+ aria-label={props.label}
190
+ >
191
+ <section
192
+ class="pointer-events-auto absolute flex flex-col overflow-visible rounded-lg border border-border bg-card text-card-foreground shadow-lg"
193
+ aria-label={props.label}
194
+ style={{
195
+ left: `${frame().x}px`,
196
+ top: `${frame().y}px`,
197
+ width: `${frame().width}px`,
198
+ height: `${frame().height}px`,
199
+ }}
200
+ >
201
+ <header class="flex h-control-sm items-center gap-1 border-b border-border p-1">
202
+ <ActionButton
203
+ type="button"
204
+ variant="ghost"
205
+ size="sm"
206
+ class="flex-1 cursor-move touch-none"
207
+ aria-label={`Move ${props.label}`}
208
+ tooltip={`Move ${props.label}. Use arrow keys to adjust its position.`}
209
+ onPointerDown={(event) => start(event, null)}
210
+ onPointerMove={move}
211
+ onPointerUp={end}
212
+ onPointerCancel={end}
213
+ onLostPointerCapture={end}
214
+ onKeyDown={keyboardMove}
215
+ >
216
+ <>
217
+ <Move aria-hidden="true" />
218
+ Move
219
+ </>
220
+ </ActionButton>
221
+ <div class="flex items-center gap-1">{props.actions}</div>
222
+ {props.onClose && (
223
+ <ActionButton
224
+ type="button"
225
+ variant="ghost"
226
+ size="icon-sm"
227
+ aria-label={`Close ${props.label}`}
228
+ tooltip={`Close ${props.label}.`}
229
+ onClick={props.onClose}
230
+ >
231
+ <X aria-hidden="true" />
232
+ </ActionButton>
233
+ )}
234
+ </header>
235
+ <ScrollArea role="region" aria-label={`${props.label} content`} class="flex-1" tabIndex={0}>
236
+ {props.children}
237
+ </ScrollArea>
238
+ {directions.map(({ direction, class: className, glyph }) => (
239
+ <ActionButton
240
+ type="button"
241
+ variant="ghost"
242
+ size="icon-sm"
243
+ class={className}
244
+ data-direction={direction}
245
+ aria-label={`Resize ${props.label} ${direction}`}
246
+ tooltip={`Resize ${props.label} from the ${direction} edge. Use arrow keys to resize.`}
247
+ onPointerDown={(event) => start(event, direction)}
248
+ onPointerMove={move}
249
+ onPointerUp={end}
250
+ onPointerCancel={end}
251
+ onLostPointerCapture={end}
252
+ onKeyDown={(event) => keyboardResize(event, direction)}
253
+ >
254
+ <span aria-hidden="true">{glyph}</span>
255
+ </ActionButton>
256
+ ))}
257
+ </section>
258
+ </div>
259
+ )
260
+ }
@@ -0,0 +1,2 @@
1
+ export * from './model'
2
+ export * from './floating-preview'