@adea-ai/ui 0.89.2 → 0.94.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 (114) hide show
  1. package/README.md +22 -5
  2. package/dist/NOTICE +19 -0
  3. package/dist/components/composites/list-row/index.d.ts +1 -0
  4. package/dist/components/composites/list-row/index.d.ts.map +1 -1
  5. package/dist/components/composites/list-row/index.js +2 -1
  6. package/dist/components/composites/list-row/list-row-control.d.ts +20 -0
  7. package/dist/components/composites/list-row/list-row-control.d.ts.map +1 -0
  8. package/dist/components/composites/list-row/list-row-control.js +96 -0
  9. package/dist/components/composites/list-row/list-row-control.js.map +1 -0
  10. package/dist/components/composites/list-row/list-row.d.ts +15 -35
  11. package/dist/components/composites/list-row/list-row.d.ts.map +1 -1
  12. package/dist/components/composites/list-row/list-row.js +88 -135
  13. package/dist/components/composites/list-row/list-row.js.map +1 -1
  14. package/dist/components/composites/tree/index.d.ts +4 -0
  15. package/dist/components/composites/tree/index.d.ts.map +1 -0
  16. package/dist/components/composites/tree/index.js +2 -0
  17. package/dist/components/composites/tree/tree-navigation.d.ts +46 -0
  18. package/dist/components/composites/tree/tree-navigation.d.ts.map +1 -0
  19. package/dist/components/composites/tree/tree-navigation.js +95 -0
  20. package/dist/components/composites/tree/tree-navigation.js.map +1 -0
  21. package/dist/components/composites/tree/tree.d.ts +64 -0
  22. package/dist/components/composites/tree/tree.d.ts.map +1 -0
  23. package/dist/components/composites/tree/tree.js +311 -0
  24. package/dist/components/composites/tree/tree.js.map +1 -0
  25. package/dist/components/conversation/message-composer.d.ts +18 -3
  26. package/dist/components/conversation/message-composer.d.ts.map +1 -1
  27. package/dist/components/conversation/message-composer.js +93 -27
  28. package/dist/components/conversation/message-composer.js.map +1 -1
  29. package/dist/components/layout/side-rail/side-rail.d.ts +1 -1
  30. package/dist/components/layout/side-rail/side-rail.d.ts.map +1 -1
  31. package/dist/components/layout/side-rail/side-rail.js +8 -8
  32. package/dist/components/layout/side-rail/side-rail.js.map +1 -1
  33. package/dist/components/layout/sidebar-nav/index.d.ts +1 -1
  34. package/dist/components/layout/sidebar-nav/index.d.ts.map +1 -1
  35. package/dist/components/layout/sidebar-nav/index.js +2 -2
  36. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts +19 -1
  37. package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts.map +1 -1
  38. package/dist/components/layout/sidebar-nav/sidebar-nav.js +83 -17
  39. package/dist/components/layout/sidebar-nav/sidebar-nav.js.map +1 -1
  40. package/dist/components/layout/top-bar/top-bar.d.ts +6 -5
  41. package/dist/components/layout/top-bar/top-bar.d.ts.map +1 -1
  42. package/dist/components/layout/top-bar/top-bar.js +30 -7
  43. package/dist/components/layout/top-bar/top-bar.js.map +1 -1
  44. package/dist/components/ui/dialog/dialog.d.ts +2 -0
  45. package/dist/components/ui/dialog/dialog.d.ts.map +1 -1
  46. package/dist/components/ui/dialog/dialog.js +4 -3
  47. package/dist/components/ui/dialog/dialog.js.map +1 -1
  48. package/dist/components/ui/modal-dialog/modal-dialog.d.ts +5 -2
  49. package/dist/components/ui/modal-dialog/modal-dialog.d.ts.map +1 -1
  50. package/dist/components/ui/modal-dialog/modal-dialog.js +33 -24
  51. package/dist/components/ui/modal-dialog/modal-dialog.js.map +1 -1
  52. package/dist/components/ui/textarea/index.d.ts +1 -1
  53. package/dist/components/ui/textarea/index.d.ts.map +1 -1
  54. package/dist/components/ui/textarea/index.js +2 -2
  55. package/dist/components/ui/textarea/textarea.d.ts +15 -2
  56. package/dist/components/ui/textarea/textarea.d.ts.map +1 -1
  57. package/dist/components/ui/textarea/textarea.js +47 -3
  58. package/dist/components/ui/textarea/textarea.js.map +1 -1
  59. package/dist/components/ui/tooltip/tooltip.d.ts.map +1 -1
  60. package/dist/components/ui/tooltip/tooltip.js +239 -17
  61. package/dist/components/ui/tooltip/tooltip.js.map +1 -1
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +5 -3
  65. package/dist/lib/tokens.js +1 -1
  66. package/dist/lib/tokens.js.map +1 -1
  67. package/dist/lib/variants.js +2 -2
  68. package/dist/r/conversation.json +1 -0
  69. package/dist/r/list-row.json +5 -0
  70. package/dist/r/registry.json +41 -2
  71. package/dist/r/src/components/composites/list-row/index.ts +1 -0
  72. package/dist/r/src/components/composites/list-row/list-row-control.tsx +108 -0
  73. package/dist/r/src/components/composites/list-row/list-row.tsx +60 -127
  74. package/dist/r/src/components/composites/tree/index.ts +3 -0
  75. package/dist/r/src/components/composites/tree/tree-navigation.ts +114 -0
  76. package/dist/r/src/components/composites/tree/tree.tsx +405 -0
  77. package/dist/r/src/components/conversation/message-composer.tsx +103 -22
  78. package/dist/r/src/components/layout/side-rail/side-rail.tsx +18 -10
  79. package/dist/r/src/components/layout/sidebar-nav/index.ts +3 -0
  80. package/dist/r/src/components/layout/sidebar-nav/sidebar-nav.tsx +75 -6
  81. package/dist/r/src/components/layout/top-bar/top-bar.tsx +34 -11
  82. package/dist/r/src/components/ui/dialog/dialog.tsx +4 -2
  83. package/dist/r/src/components/ui/modal-dialog/modal-dialog.tsx +20 -4
  84. package/dist/r/src/components/ui/textarea/index.ts +1 -1
  85. package/dist/r/src/components/ui/textarea/textarea.tsx +52 -10
  86. package/dist/r/src/components/ui/tooltip/tooltip.tsx +305 -10
  87. package/dist/r/src/lib/tokens.ts +1 -1
  88. package/dist/r/src/styles/base.css +39 -0
  89. package/dist/r/src/styles/theme.css +5 -4
  90. package/dist/r/textarea.json +3 -1
  91. package/dist/r/top-bar.json +3 -1
  92. package/dist/r/tree.json +29 -0
  93. package/package.json +4 -1
  94. package/registry.json +41 -2
  95. package/src/components/composites/list-row/index.ts +1 -0
  96. package/src/components/composites/list-row/list-row-control.tsx +108 -0
  97. package/src/components/composites/list-row/list-row.tsx +60 -127
  98. package/src/components/composites/tree/index.ts +3 -0
  99. package/src/components/composites/tree/tree-navigation.ts +114 -0
  100. package/src/components/composites/tree/tree.tsx +405 -0
  101. package/src/components/conversation/message-composer.tsx +103 -22
  102. package/src/components/layout/side-rail/side-rail.tsx +18 -10
  103. package/src/components/layout/sidebar-nav/index.ts +3 -0
  104. package/src/components/layout/sidebar-nav/sidebar-nav.tsx +75 -6
  105. package/src/components/layout/top-bar/top-bar.tsx +34 -11
  106. package/src/components/ui/dialog/dialog.tsx +4 -2
  107. package/src/components/ui/modal-dialog/modal-dialog.tsx +20 -4
  108. package/src/components/ui/textarea/index.ts +1 -1
  109. package/src/components/ui/textarea/textarea.tsx +52 -10
  110. package/src/components/ui/tooltip/tooltip.tsx +305 -10
  111. package/src/index.ts +1 -0
  112. package/src/lib/tokens.ts +1 -1
  113. package/src/styles/base.css +39 -0
  114. package/src/styles/theme.css +5 -4
@@ -0,0 +1,405 @@
1
+ import type { ComponentProps, JSX } from 'solid-js'
2
+ import { ChevronRight } from 'lucide-solid'
3
+ import {
4
+ createContext,
5
+ createEffect,
6
+ createMemo,
7
+ createUniqueId,
8
+ onCleanup,
9
+ splitProps,
10
+ useContext,
11
+ } from 'solid-js'
12
+ import { cn } from '#lib/utils'
13
+ import {
14
+ indexTreeItems,
15
+ treeNavigationAction,
16
+ type TreeItemDescriptor,
17
+ type TreeNavigationIndex,
18
+ } from './tree-navigation'
19
+
20
+ type TreeContextValue = {
21
+ idPrefix: string
22
+ activeId: () => string | null
23
+ index: () => TreeNavigationIndex
24
+ register: (id: string, element: HTMLDivElement) => void
25
+ unregister: (id: string, element: HTMLDivElement | undefined) => void
26
+ focusItem: (id: string) => void
27
+ noteFocus: (id: string) => void
28
+ activate: (id: string, event: Event) => void
29
+ onActiveIdChange: (id: string) => void
30
+ }
31
+
32
+ const TreeContext = createContext<TreeContextValue>()
33
+
34
+ function useTreeContext() {
35
+ const tree = useContext(TreeContext)
36
+ if (!tree) throw new Error('[adea-ui]: TreeRow must be used inside Tree.')
37
+ return tree
38
+ }
39
+
40
+ type TreeAccessibleName =
41
+ | { 'aria-label': string; 'aria-labelledby'?: string }
42
+ | { 'aria-label'?: string; 'aria-labelledby': string }
43
+
44
+ export type TreeProps = Omit<
45
+ ComponentProps<'div'>,
46
+ 'children' | 'role' | 'tabIndex' | 'onKeyDown' | 'aria-label' | 'aria-labelledby'
47
+ > &
48
+ TreeAccessibleName & {
49
+ /** Complete, ordered projection of currently visible (expanded) items. */
50
+ visibleItems: readonly TreeItemDescriptor[]
51
+ /** The host-owned identity of the row that currently owns roving focus. */
52
+ activeId: string | null
53
+ /** Called when keyboard or pointer navigation changes the active row. */
54
+ onActiveIdChange: (id: string) => void
55
+ /** Called for Left/Right expand and collapse requests. */
56
+ onExpand?: (id: string, expanded: boolean) => void
57
+ /** Called for Enter or pointer activation. Domain action stays host-owned. */
58
+ onActivate?: (id: string, event: Event) => void
59
+ /** Called for Space selection. Selection state stays host-owned. */
60
+ onSelectItem?: (id: string, event: Event) => void
61
+ /** Ask the host window to mount/reveal an item before focus moves to it. */
62
+ onRequestReveal?: (id: string) => void
63
+ /** Report a mounted row's border-box block size in CSS pixels for host range math. */
64
+ onRowSizeChange?: (id: string, blockSize: number) => void
65
+ /** Declares a multi-select tree to assistive technology. */
66
+ selectionMode?: 'single' | 'multiple'
67
+ children?: JSX.Element
68
+ }
69
+
70
+ /**
71
+ * Tree.
72
+ *
73
+ * Supplies semantic and keyboard behavior for a tree whose host owns the
74
+ * visible projection, filesystem state, row window and domain actions. The
75
+ * mounted children can be a small virtualized window; `visibleItems` remains
76
+ * the complete ordered projection so keyboard movement and sibling positions
77
+ * do not depend on which rows currently exist in the DOM.
78
+ *
79
+ * Kobalte 0.13.14 has no Tree primitive, so this component implements the
80
+ * WAI-ARIA tree row navigation contract and leaves product state to callbacks.
81
+ */
82
+ export function Tree(props: TreeProps) {
83
+ const [local, rest] = splitProps(props, [
84
+ 'class',
85
+ 'visibleItems',
86
+ 'activeId',
87
+ 'onActiveIdChange',
88
+ 'onExpand',
89
+ 'onActivate',
90
+ 'onSelectItem',
91
+ 'onRequestReveal',
92
+ 'onRowSizeChange',
93
+ 'selectionMode',
94
+ 'children',
95
+ ])
96
+
97
+ const index = createMemo(() => indexTreeItems(local.visibleItems))
98
+ const effectiveActiveId = createMemo(() => {
99
+ const candidate = local.activeId
100
+ const visible = index()
101
+ return candidate !== null && visible.byId.has(candidate) ? candidate : (visible.ids[0] ?? null)
102
+ })
103
+ const idPrefix = `tree-${createUniqueId()}`
104
+ const rows = new Map<string, HTMLDivElement>()
105
+ const rowIdsByElement = new WeakMap<HTMLDivElement, string>()
106
+ let pendingFocusId: string | null = null
107
+ let requestedRevealId: string | null = null
108
+ let focusedId: string | null = null
109
+ let disposed = false
110
+ const rowSizeObserver =
111
+ typeof ResizeObserver === 'undefined'
112
+ ? undefined
113
+ : new ResizeObserver((entries) => {
114
+ if (disposed) return
115
+ for (const entry of entries) {
116
+ const id = rowIdsByElement.get(entry.target as HTMLDivElement)
117
+ const blockSize =
118
+ entry.borderBoxSize[0]?.blockSize ?? (entry.target as HTMLElement).offsetHeight
119
+ if (id !== undefined && Number.isFinite(blockSize) && blockSize > 0)
120
+ local.onRowSizeChange?.(id, blockSize)
121
+ }
122
+ })
123
+
124
+ onCleanup(() => {
125
+ disposed = true
126
+ rowSizeObserver?.disconnect()
127
+ })
128
+
129
+ const requestReveal = (id: string) => {
130
+ if (requestedRevealId === id) return
131
+ requestedRevealId = id
132
+ local.onRequestReveal?.(id)
133
+ }
134
+
135
+ const focusWhenMounted = (id: string) => {
136
+ queueMicrotask(() => {
137
+ if (pendingFocusId !== id || effectiveActiveId() !== id) return
138
+ const row = rows.get(id)
139
+ if (!row || typeof document === 'undefined') return
140
+ row.focus()
141
+ if (document.activeElement === row) pendingFocusId = null
142
+ })
143
+ }
144
+
145
+ const focusItem = (id: string) => {
146
+ pendingFocusId = id
147
+ local.onActiveIdChange(id)
148
+ if (rows.has(id)) requestedRevealId = null
149
+ else requestReveal(id)
150
+ focusWhenMounted(id)
151
+ }
152
+
153
+ const context: TreeContextValue = {
154
+ idPrefix,
155
+ activeId: effectiveActiveId,
156
+ index,
157
+ register: (id, element) => {
158
+ rows.set(id, element)
159
+ rowIdsByElement.set(element, id)
160
+ rowSizeObserver?.observe(element)
161
+ if (requestedRevealId === id) requestedRevealId = null
162
+ if (pendingFocusId === id) focusWhenMounted(id)
163
+ },
164
+ unregister: (id, element) => {
165
+ if (rows.get(id) !== element) return
166
+ const focusWasInside =
167
+ !disposed &&
168
+ Boolean(
169
+ element && typeof document !== 'undefined' && element.contains(document.activeElement)
170
+ )
171
+ rows.delete(id)
172
+ if (element) {
173
+ rowIdsByElement.delete(element)
174
+ rowSizeObserver?.unobserve(element)
175
+ }
176
+ if (requestedRevealId === id) requestedRevealId = null
177
+ if (focusWasInside) {
178
+ queueMicrotask(() => {
179
+ const fallbackId = effectiveActiveId()
180
+ if (!disposed && fallbackId !== null && pendingFocusId !== fallbackId)
181
+ focusItem(fallbackId)
182
+ })
183
+ }
184
+ },
185
+ focusItem,
186
+ noteFocus: (id) => {
187
+ focusedId = id
188
+ },
189
+ activate: (id, event) => local.onActivate?.(id, event),
190
+ onActiveIdChange: local.onActiveIdChange,
191
+ }
192
+
193
+ // A filtered or removed active ID falls back to the first visible item. This
194
+ // keeps one treeitem in the sequential tab order while notifying the host.
195
+ createEffect(() => {
196
+ const activeId = effectiveActiveId()
197
+ if (activeId === null) return
198
+ // If a host filter removes the row that had DOM focus, move focus to the
199
+ // same projection fallback that now owns the roving tab stop.
200
+ if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) {
201
+ focusItem(activeId)
202
+ } else if (local.activeId !== activeId) {
203
+ local.onActiveIdChange(activeId)
204
+ }
205
+ // Preserve the active row in a caller-owned virtual window, including when
206
+ // the pane mounts with its saved active row outside the initial render range.
207
+ if (!rows.has(activeId)) requestReveal(activeId)
208
+ if (pendingFocusId === activeId) focusWhenMounted(activeId)
209
+ })
210
+
211
+ const handleKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (event) => {
212
+ if (event.defaultPrevented || isActionTarget(event.target, event.currentTarget)) return
213
+
214
+ const action = treeNavigationAction(index(), effectiveActiveId(), event.key)
215
+ if (!action) return
216
+ event.preventDefault()
217
+
218
+ switch (action.type) {
219
+ case 'focus':
220
+ focusItem(action.id)
221
+ break
222
+ case 'expand':
223
+ local.onExpand?.(action.id, action.expanded)
224
+ break
225
+ case 'activate':
226
+ local.onActivate?.(action.id, event)
227
+ break
228
+ case 'select':
229
+ local.onSelectItem?.(action.id, event)
230
+ break
231
+ }
232
+ }
233
+
234
+ return (
235
+ <TreeContext.Provider value={context}>
236
+ <div
237
+ {...rest}
238
+ role="tree"
239
+ aria-multiselectable={local.selectionMode === 'multiple' ? 'true' : undefined}
240
+ data-slot="tree"
241
+ class={cn('flex min-w-0 flex-col', local.class)}
242
+ onKeyDown={handleKeyDown}
243
+ >
244
+ {local.children}
245
+ </div>
246
+ </TreeContext.Provider>
247
+ )
248
+ }
249
+
250
+ export type TreeRowProps = Omit<
251
+ ComponentProps<'div'>,
252
+ | 'children'
253
+ | 'role'
254
+ | 'tabIndex'
255
+ | 'onClick'
256
+ | 'onFocus'
257
+ | 'onDoubleClick'
258
+ | 'style'
259
+ | 'aria-level'
260
+ | 'aria-expanded'
261
+ | 'aria-selected'
262
+ | 'aria-posinset'
263
+ | 'aria-setsize'
264
+ > & {
265
+ item: TreeItemDescriptor
266
+ /** Host-owned selection state; omitted for non-selectable trees. */
267
+ selected?: boolean
268
+ leading?: JSX.Element
269
+ trailing?: JSX.Element
270
+ children: JSX.Element
271
+ onDoubleClick?: (id: string, event: MouseEvent & { currentTarget: HTMLDivElement }) => void
272
+ }
273
+
274
+ /**
275
+ * TreeRow.
276
+ *
277
+ * Keeps the donor's disclosure marker, leading file/folder affordance, primary
278
+ * label and trailing-action hierarchy while replacing the donor's button and
279
+ * runtime callbacks with a shared treeitem and host-owned slots/actions.
280
+ */
281
+ export function TreeRow(props: TreeRowProps) {
282
+ const tree = useTreeContext()
283
+ const [local, rest] = splitProps(props, [
284
+ 'class',
285
+ 'item',
286
+ 'selected',
287
+ 'leading',
288
+ 'trailing',
289
+ 'children',
290
+ 'onDoubleClick',
291
+ 'ref',
292
+ ])
293
+
294
+ let row: HTMLDivElement | undefined
295
+ let registeredId: string | undefined
296
+ const id = () => local.item.id
297
+
298
+ onCleanup(() => tree.unregister(registeredId ?? id(), row))
299
+
300
+ const setRef = (element: HTMLDivElement) => {
301
+ row = element
302
+ registeredId = id()
303
+ tree.register(registeredId, element)
304
+ }
305
+
306
+ createEffect(() => {
307
+ const nextId = id()
308
+ if (!row || registeredId === nextId) return
309
+ if (registeredId !== undefined) tree.unregister(registeredId, row)
310
+ registeredId = nextId
311
+ tree.register(nextId, row)
312
+ })
313
+
314
+ const handleFocus = () => {
315
+ tree.noteFocus(id())
316
+ if (tree.activeId() !== id()) tree.onActiveIdChange(id())
317
+ }
318
+
319
+ const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
320
+ if (isActionTarget(event.target, event.currentTarget)) return
321
+ tree.focusItem(id())
322
+ tree.activate(id(), event)
323
+ }
324
+
325
+ const handleDoubleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
326
+ if (isActionTarget(event.target, event.currentTarget)) return
327
+ local.onDoubleClick?.(id(), event)
328
+ }
329
+
330
+ const position = () => tree.index().positionById.get(id())
331
+
332
+ return (
333
+ <div
334
+ {...rest}
335
+ ref={composeRefs(setRef, () => local.ref)}
336
+ id={rest.id ?? `${tree.idPrefix}-item-${encodeURIComponent(id())}`}
337
+ role="treeitem"
338
+ aria-level={local.item.level}
339
+ aria-expanded={local.item.expandable ? (local.item.expanded ? 'true' : 'false') : undefined}
340
+ aria-selected={local.selected === undefined ? undefined : local.selected ? 'true' : 'false'}
341
+ aria-posinset={position()?.position}
342
+ aria-setsize={position()?.setSize}
343
+ tabIndex={tree.activeId() === id() ? 0 : -1}
344
+ data-tree-id={id()}
345
+ data-active={tree.activeId() === id() ? '' : undefined}
346
+ data-selected={local.selected ? '' : undefined}
347
+ data-expanded={local.item.expandable ? String(local.item.expanded) : undefined}
348
+ class={cn(
349
+ 'group/tree-row flex min-h-row-sm w-full min-w-0 items-center gap-1.5 rounded-md pe-1.5 text-left text-sm outline-none transition-colors',
350
+ 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
351
+ {
352
+ 'bg-primary-subtle text-foreground': local.selected,
353
+ 'hover:bg-surface-hover': !local.selected,
354
+ },
355
+ local.class
356
+ )}
357
+ style={{ 'padding-inline-start': `${6 + Math.max(0, local.item.level - 1) * 12}px` }}
358
+ onFocus={handleFocus}
359
+ onClick={handleClick}
360
+ onDblClick={handleDoubleClick}
361
+ >
362
+ <span class="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
363
+ {local.item.expandable ? (
364
+ <ChevronRight
365
+ aria-hidden="true"
366
+ class={cn('size-3.5 transition-transform', local.item.expanded && 'rotate-90')}
367
+ />
368
+ ) : null}
369
+ </span>
370
+ {local.leading ? (
371
+ <span class="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
372
+ {local.leading}
373
+ </span>
374
+ ) : null}
375
+ <span class="min-w-0 flex-1 truncate">{local.children}</span>
376
+ {local.trailing ? (
377
+ <span class="ms-auto flex shrink-0 items-center gap-1 text-muted-foreground">
378
+ {local.trailing}
379
+ </span>
380
+ ) : null}
381
+ </div>
382
+ )
383
+ }
384
+
385
+ function isActionTarget(target: EventTarget | null, currentTarget: HTMLElement): boolean {
386
+ if (!(target instanceof Element) || target === currentTarget) return false
387
+ return Boolean(
388
+ target.closest(
389
+ 'a, button, input, textarea, select, [contenteditable="true"], [role="button"], [data-tree-keyboard-stop]'
390
+ )
391
+ )
392
+ }
393
+
394
+ function composeRefs(
395
+ internal: (element: HTMLDivElement) => void,
396
+ external: () => ComponentProps<'div'>['ref']
397
+ ): (element: HTMLDivElement) => void {
398
+ return (element) => {
399
+ internal(element)
400
+ const ref = external()
401
+ if (typeof ref === 'function') ref(element)
402
+ }
403
+ }
404
+
405
+ export type { TreeItemDescriptor } from './tree-navigation'
@@ -1,18 +1,30 @@
1
1
  import { CornerDownLeft, Paperclip, Send, X } from 'lucide-solid'
2
- import type { ComponentProps, JSX } from 'solid-js'
3
- import { Show, createSignal, splitProps } from 'solid-js'
2
+ import type { ComponentProps, JSX, Ref } from 'solid-js'
3
+ import { Show, createSignal, createUniqueId, splitProps } from 'solid-js'
4
4
  import { cn } from '#lib/utils'
5
+ import { ActionButton } from '../composites/action-button/action-button'
5
6
  import { Button } from '../ui/button/button'
6
7
  import { Kbd } from '../ui/kbd/kbd'
7
8
  import { Spinner } from '../ui/spinner/spinner'
8
9
  import { Textarea } from '../ui/textarea/textarea'
9
10
  import { createComposerImeGuard } from './ime-guard'
10
11
 
12
+ function invokeEventHandler(handler: unknown, event: Event) {
13
+ if (Array.isArray(handler)) {
14
+ const [callback, data] = handler as [(data: unknown, event: Event) => void, unknown]
15
+ callback(data, event)
16
+ } else if (typeof handler === 'function') {
17
+ const callback = handler as (event: Event) => void
18
+ callback(event)
19
+ }
20
+ }
21
+
11
22
  /**
12
23
  * MessageComposer.
13
24
  *
14
25
  * The field a message is written in. It is a form, and it behaves like one: Enter
15
- * sends, Shift+Enter breaks the line, Escape clears the error.
26
+ * sends, Shift+Enter breaks the line, Escape clears the error from anywhere in
27
+ * the composer unless a nested control or an active IME already owns the key.
16
28
  *
17
29
  * ## What it does not know
18
30
  *
@@ -49,7 +61,7 @@ export type MessageComposerProps = Omit<ComponentProps<'form'>, 'onSubmit'> & {
49
61
  placeholder?: string
50
62
  /** Disable the whole composer, e.g. while the channel is read-only. */
51
63
  disabled?: boolean
52
- /** Label the send action for a context other than a message, e.g. a reply. */
64
+ /** Accessible name and tooltip for the action, e.g. "Reply". */
53
65
  submitLabel?: string
54
66
  /** Show the keyboard hint under the field. */
55
67
  showHint?: boolean
@@ -61,6 +73,20 @@ export type MessageComposerProps = Omit<ComponentProps<'form'>, 'onSubmit'> & {
61
73
  menu?: JSX.Element
62
74
  /** The reply target, drawn as a strip above the field. */
63
75
  replyTo?: { label: string; onDismiss: () => void }
76
+ /** Ref to the actual message textarea, for host focus and selection behavior. */
77
+ inputRef?: Ref<HTMLTextAreaElement>
78
+ /** Stable host-owned id for the actual textarea. */
79
+ inputId?: string
80
+ /** Accessible name for the actual textarea. Defaults to "Message" or "Reply message". */
81
+ inputLabel?: string
82
+ /** Additional help text associated with the actual textarea. */
83
+ inputDescription?: string
84
+ /** Existing host description id, combined with `inputDescription` when supplied. */
85
+ inputDescribedBy?: string
86
+ /** Comfortable uses the shared 4rem minimum and 12rem maximum field heights. */
87
+ inputSize?: 'default' | 'comfortable'
88
+ /** The shared resize behavior for the actual textarea. */
89
+ inputResize?: 'vertical' | 'none' | 'both'
64
90
  }
65
91
 
66
92
  export function MessageComposer(props: MessageComposerProps) {
@@ -77,11 +103,27 @@ export function MessageComposer(props: MessageComposerProps) {
77
103
  'trailing',
78
104
  'menu',
79
105
  'replyTo',
106
+ 'inputRef',
107
+ 'inputId',
108
+ 'inputLabel',
109
+ 'inputDescription',
110
+ 'inputDescribedBy',
111
+ 'inputSize',
112
+ 'inputResize',
113
+ 'onKeyDown',
80
114
  ])
81
115
 
82
116
  const [sending, setSending] = createSignal(false)
83
117
  const [error, setError] = createSignal<string | null>(null)
84
118
  const ime = createComposerImeGuard()
119
+ const descriptionId = `message-composer-help-${createUniqueId()}`
120
+ const describedBy = () =>
121
+ [local.inputDescribedBy, local.inputDescription ? descriptionId : undefined]
122
+ .filter(Boolean)
123
+ .join(' ') || undefined
124
+ const actionLabel = () => local.submitLabel ?? 'Send message'
125
+ const pendingLabel = () =>
126
+ `Sending ${local.submitLabel ? local.submitLabel.toLowerCase() : 'message'}`
85
127
 
86
128
  const canSend = () => local.value.trim().length > 0 && !local.disabled && !sending()
87
129
 
@@ -102,10 +144,6 @@ export function MessageComposer(props: MessageComposerProps) {
102
144
 
103
145
  const onKeyDown: JSX.EventHandlerUnion<HTMLTextAreaElement, KeyboardEvent> = (event) => {
104
146
  if (event.defaultPrevented) return
105
- if (event.key === 'Escape' && error()) {
106
- setError(null)
107
- return
108
- }
109
147
  if (event.key !== 'Enter') return
110
148
  // Shift breaks the line; a bare Enter, or a modified one, sends.
111
149
  if (event.shiftKey && !event.metaKey && !event.ctrlKey) return
@@ -113,6 +151,22 @@ export function MessageComposer(props: MessageComposerProps) {
113
151
  void send()
114
152
  }
115
153
 
154
+ const onFormKeyDown: JSX.EventHandlerUnion<HTMLFormElement, KeyboardEvent> = (event) => {
155
+ invokeEventHandler(local.onKeyDown, event)
156
+ if (
157
+ event.defaultPrevented ||
158
+ event.key !== 'Escape' ||
159
+ !error() ||
160
+ event.isComposing ||
161
+ event.keyCode === 229
162
+ ) {
163
+ return
164
+ }
165
+ setError(null)
166
+ event.preventDefault()
167
+ event.stopPropagation()
168
+ }
169
+
116
170
  return (
117
171
  <form
118
172
  data-slot="message-composer"
@@ -121,6 +175,7 @@ export function MessageComposer(props: MessageComposerProps) {
121
175
  event.preventDefault()
122
176
  void send()
123
177
  }}
178
+ onKeyDown={onFormKeyDown}
124
179
  {...rest}
125
180
  >
126
181
  <Show when={local.menu}>
@@ -131,15 +186,16 @@ export function MessageComposer(props: MessageComposerProps) {
131
186
  {(reply) => (
132
187
  <div class="bg-muted text-muted-foreground flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs">
133
188
  <span class="min-w-0 flex-1 truncate">Replying to {reply().label}</span>
134
- <Button
189
+ <ActionButton
135
190
  type="button"
136
191
  variant="ghost"
137
192
  size="icon-2xs"
138
193
  aria-label="Cancel reply"
194
+ tooltip="Cancel reply"
139
195
  onClick={() => reply().onDismiss()}
140
196
  >
141
197
  <X />
142
- </Button>
198
+ </ActionButton>
143
199
  </div>
144
200
  )}
145
201
  </Show>
@@ -153,6 +209,8 @@ export function MessageComposer(props: MessageComposerProps) {
153
209
  )}
154
210
  >
155
211
  <Textarea
212
+ ref={local.inputRef}
213
+ id={local.inputId}
156
214
  onCompositionStart={ime.onCompositionStart}
157
215
  onCompositionEnd={ime.onCompositionEnd}
158
216
  onFocus={ime.onFocus}
@@ -163,24 +221,37 @@ export function MessageComposer(props: MessageComposerProps) {
163
221
  placeholder={local.placeholder ?? 'Write a message…'}
164
222
  disabled={local.disabled}
165
223
  rows={1}
166
- aria-label={local.submitLabel ? `${local.submitLabel} message` : 'Message'}
167
- class="min-h-9 resize-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
224
+ aria-label={
225
+ local.inputLabel ?? (local.submitLabel ? `${local.submitLabel} message` : 'Message')
226
+ }
227
+ aria-describedby={describedBy()}
228
+ variant="composer"
229
+ size={local.inputSize ?? 'comfortable'}
230
+ resize={local.inputResize ?? 'vertical'}
168
231
  />
232
+ <Show when={local.inputDescription}>
233
+ {(description) => (
234
+ <span id={descriptionId} class="sr-only">
235
+ {description()}
236
+ </span>
237
+ )}
238
+ </Show>
169
239
 
170
240
  <div class="flex items-center gap-1">
171
241
  <Show when={local.leading}>{local.leading}</Show>
172
242
  <div class="ms-auto flex items-center gap-1">
173
243
  <Show when={local.trailing}>{local.trailing}</Show>
174
- <Button
244
+ <ActionButton
175
245
  type="submit"
176
246
  size="icon-sm"
177
247
  disabled={!canSend()}
178
- aria-label={sending() ? 'Sending message' : 'Send message'}
248
+ aria-label={sending() ? pendingLabel() : actionLabel()}
249
+ tooltip={actionLabel()}
179
250
  >
180
251
  <Show when={sending()} fallback={<Send />}>
181
252
  <Spinner size="sm" label={false} class="text-primary-foreground" />
182
253
  </Show>
183
- </Button>
254
+ </ActionButton>
184
255
  </div>
185
256
  </div>
186
257
  </div>
@@ -195,11 +266,20 @@ export function MessageComposer(props: MessageComposerProps) {
195
266
 
196
267
  {/* A live region, so the outcome is announced without moving focus out of the
197
268
  field the user is typing in. */}
198
- <div aria-live="polite" class="min-h-0">
199
- <Show when={error()}>
200
- {(message) => (
201
- <p class="text-foreground border-s-2 border-destructive ps-2 text-xs">{message()}</p>
202
- )}
269
+ <div role="status" aria-live="polite" class="min-h-0">
270
+ <Show
271
+ when={sending()}
272
+ fallback={
273
+ <Show when={error()}>
274
+ {(message) => (
275
+ <p class="text-foreground border-s-2 border-destructive ps-2 text-xs">
276
+ {message()}
277
+ </p>
278
+ )}
279
+ </Show>
280
+ }
281
+ >
282
+ {pendingLabel()}…
203
283
  </Show>
204
284
  </div>
205
285
  </form>
@@ -220,17 +300,18 @@ export function ComposerAttachmentButton(
220
300
  const [local, rest] = splitProps(props, ['count', 'open', 'children'])
221
301
 
222
302
  return (
223
- <Button
303
+ <ActionButton
224
304
  type="button"
225
305
  variant="ghost"
226
306
  size="icon-sm"
227
307
  aria-label={local.count ? `${local.count} attached, add an attachment` : 'Add an attachment'}
228
308
  aria-expanded={local.open}
309
+ tooltip="Add an attachment"
229
310
  class="text-muted-foreground"
230
311
  {...rest}
231
312
  >
232
313
  {local.children ?? <Paperclip />}
233
- </Button>
314
+ </ActionButton>
234
315
  )
235
316
  }
236
317