@adea-ai/ui 0.89.2 → 0.93.2
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.
- package/README.md +22 -5
- package/dist/NOTICE +19 -0
- package/dist/components/composites/list-row/index.d.ts +1 -0
- package/dist/components/composites/list-row/index.d.ts.map +1 -1
- package/dist/components/composites/list-row/index.js +2 -1
- package/dist/components/composites/list-row/list-row-control.d.ts +20 -0
- package/dist/components/composites/list-row/list-row-control.d.ts.map +1 -0
- package/dist/components/composites/list-row/list-row-control.js +96 -0
- package/dist/components/composites/list-row/list-row-control.js.map +1 -0
- package/dist/components/composites/list-row/list-row.d.ts +15 -35
- package/dist/components/composites/list-row/list-row.d.ts.map +1 -1
- package/dist/components/composites/list-row/list-row.js +88 -135
- package/dist/components/composites/list-row/list-row.js.map +1 -1
- package/dist/components/composites/tree/index.d.ts +4 -0
- package/dist/components/composites/tree/index.d.ts.map +1 -0
- package/dist/components/composites/tree/index.js +2 -0
- package/dist/components/composites/tree/tree-navigation.d.ts +46 -0
- package/dist/components/composites/tree/tree-navigation.d.ts.map +1 -0
- package/dist/components/composites/tree/tree-navigation.js +95 -0
- package/dist/components/composites/tree/tree-navigation.js.map +1 -0
- package/dist/components/composites/tree/tree.d.ts +64 -0
- package/dist/components/composites/tree/tree.d.ts.map +1 -0
- package/dist/components/composites/tree/tree.js +311 -0
- package/dist/components/composites/tree/tree.js.map +1 -0
- package/dist/components/conversation/message-composer.d.ts +18 -3
- package/dist/components/conversation/message-composer.d.ts.map +1 -1
- package/dist/components/conversation/message-composer.js +93 -27
- package/dist/components/conversation/message-composer.js.map +1 -1
- package/dist/components/layout/sidebar-nav/index.d.ts +1 -1
- package/dist/components/layout/sidebar-nav/index.d.ts.map +1 -1
- package/dist/components/layout/sidebar-nav/index.js +2 -2
- package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts +19 -1
- package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts.map +1 -1
- package/dist/components/layout/sidebar-nav/sidebar-nav.js +83 -17
- package/dist/components/layout/sidebar-nav/sidebar-nav.js.map +1 -1
- package/dist/components/layout/top-bar/top-bar.d.ts +6 -5
- package/dist/components/layout/top-bar/top-bar.d.ts.map +1 -1
- package/dist/components/layout/top-bar/top-bar.js +30 -7
- package/dist/components/layout/top-bar/top-bar.js.map +1 -1
- package/dist/components/ui/dialog/dialog.d.ts +2 -0
- package/dist/components/ui/dialog/dialog.d.ts.map +1 -1
- package/dist/components/ui/dialog/dialog.js +4 -3
- package/dist/components/ui/dialog/dialog.js.map +1 -1
- package/dist/components/ui/modal-dialog/modal-dialog.d.ts +5 -2
- package/dist/components/ui/modal-dialog/modal-dialog.d.ts.map +1 -1
- package/dist/components/ui/modal-dialog/modal-dialog.js +33 -24
- package/dist/components/ui/modal-dialog/modal-dialog.js.map +1 -1
- package/dist/components/ui/textarea/index.d.ts +1 -1
- package/dist/components/ui/textarea/index.d.ts.map +1 -1
- package/dist/components/ui/textarea/index.js +2 -2
- package/dist/components/ui/textarea/textarea.d.ts +15 -2
- package/dist/components/ui/textarea/textarea.d.ts.map +1 -1
- package/dist/components/ui/textarea/textarea.js +47 -3
- package/dist/components/ui/textarea/textarea.js.map +1 -1
- package/dist/components/ui/tooltip/tooltip.d.ts.map +1 -1
- package/dist/components/ui/tooltip/tooltip.js +239 -17
- package/dist/components/ui/tooltip/tooltip.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/lib/variants.js +2 -2
- package/dist/r/conversation.json +1 -0
- package/dist/r/list-row.json +5 -0
- package/dist/r/registry.json +41 -2
- package/dist/r/src/components/composites/list-row/index.ts +1 -0
- package/dist/r/src/components/composites/list-row/list-row-control.tsx +108 -0
- package/dist/r/src/components/composites/list-row/list-row.tsx +60 -127
- package/dist/r/src/components/composites/tree/index.ts +3 -0
- package/dist/r/src/components/composites/tree/tree-navigation.ts +114 -0
- package/dist/r/src/components/composites/tree/tree.tsx +405 -0
- package/dist/r/src/components/conversation/message-composer.tsx +103 -22
- package/dist/r/src/components/layout/sidebar-nav/index.ts +3 -0
- package/dist/r/src/components/layout/sidebar-nav/sidebar-nav.tsx +75 -6
- package/dist/r/src/components/layout/top-bar/top-bar.tsx +34 -11
- package/dist/r/src/components/ui/dialog/dialog.tsx +4 -2
- package/dist/r/src/components/ui/modal-dialog/modal-dialog.tsx +20 -4
- package/dist/r/src/components/ui/textarea/index.ts +1 -1
- package/dist/r/src/components/ui/textarea/textarea.tsx +52 -10
- package/dist/r/src/components/ui/tooltip/tooltip.tsx +305 -10
- package/dist/r/src/styles/base.css +39 -0
- package/dist/r/textarea.json +3 -1
- package/dist/r/top-bar.json +3 -1
- package/dist/r/tree.json +29 -0
- package/package.json +4 -1
- package/registry.json +41 -2
- package/src/components/composites/list-row/index.ts +1 -0
- package/src/components/composites/list-row/list-row-control.tsx +108 -0
- package/src/components/composites/list-row/list-row.tsx +60 -127
- package/src/components/composites/tree/index.ts +3 -0
- package/src/components/composites/tree/tree-navigation.ts +114 -0
- package/src/components/composites/tree/tree.tsx +405 -0
- package/src/components/conversation/message-composer.tsx +103 -22
- package/src/components/layout/sidebar-nav/index.ts +3 -0
- package/src/components/layout/sidebar-nav/sidebar-nav.tsx +75 -6
- package/src/components/layout/top-bar/top-bar.tsx +34 -11
- package/src/components/ui/dialog/dialog.tsx +4 -2
- package/src/components/ui/modal-dialog/modal-dialog.tsx +20 -4
- package/src/components/ui/textarea/index.ts +1 -1
- package/src/components/ui/textarea/textarea.tsx +52 -10
- package/src/components/ui/tooltip/tooltip.tsx +305 -10
- package/src/index.ts +1 -0
- package/src/styles/base.css +39 -0
|
@@ -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
|
-
/**
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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={
|
|
167
|
-
|
|
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
|
-
<
|
|
244
|
+
<ActionButton
|
|
175
245
|
type="submit"
|
|
176
246
|
size="icon-sm"
|
|
177
247
|
disabled={!canSend()}
|
|
178
|
-
aria-label={sending() ?
|
|
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
|
-
</
|
|
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
|
|
200
|
-
{(
|
|
201
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
314
|
+
</ActionButton>
|
|
234
315
|
)
|
|
235
316
|
}
|
|
236
317
|
|
|
@@ -5,12 +5,15 @@ export {
|
|
|
5
5
|
SidebarNavFooter,
|
|
6
6
|
SidebarNavHeader,
|
|
7
7
|
SidebarNavItem,
|
|
8
|
+
SidebarNavLabel,
|
|
9
|
+
SidebarNavRow,
|
|
8
10
|
SidebarNavSection,
|
|
9
11
|
SidebarNavTitle,
|
|
10
12
|
sidebarNavItemClass,
|
|
11
13
|
sidebarNavItemStateClass,
|
|
12
14
|
type SidebarNavItemProps,
|
|
13
15
|
type SidebarNavProps,
|
|
16
|
+
type SidebarNavRowProps,
|
|
14
17
|
type SidebarNavSectionProps,
|
|
15
18
|
type SidebarNavSectionTriggerProps,
|
|
16
19
|
type SidebarNavTitleProps,
|