@adea-ai/ui 0.138.0 → 0.139.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.
- package/dist/components/composites/tree/tree.d.ts +8 -2
- package/dist/components/composites/tree/tree.d.ts.map +1 -1
- package/dist/components/composites/tree/tree.js +63 -8
- package/dist/components/composites/tree/tree.js.map +1 -1
- package/dist/components/layout/top-bar/top-bar.d.ts +13 -0
- package/dist/components/layout/top-bar/top-bar.d.ts.map +1 -1
- package/dist/components/layout/top-bar/top-bar.js +3 -2
- package/dist/components/layout/top-bar/top-bar.js.map +1 -1
- package/dist/components/ui/callout/callout.d.ts +7 -6
- package/dist/components/ui/callout/callout.d.ts.map +1 -1
- package/dist/components/ui/callout/callout.js +31 -16
- package/dist/components/ui/callout/callout.js.map +1 -1
- package/dist/components/ui/input-group/secret-input-group.d.ts.map +1 -1
- package/dist/components/ui/input-group/secret-input-group.js +24 -23
- package/dist/components/ui/input-group/secret-input-group.js.map +1 -1
- package/dist/r/src/components/composites/tree/tree.tsx +90 -11
- package/dist/r/src/components/layout/top-bar/top-bar.tsx +16 -0
- package/dist/r/src/components/ui/callout/callout.tsx +31 -10
- package/dist/r/src/components/ui/input-group/secret-input-group.tsx +23 -20
- package/package.json +1 -1
- package/src/components/composites/tree/tree.tsx +90 -11
- package/src/components/layout/top-bar/top-bar.tsx +16 -0
- package/src/components/ui/callout/callout.tsx +31 -10
- package/src/components/ui/input-group/secret-input-group.tsx +23 -20
|
@@ -25,6 +25,7 @@ type TreeContextValue = {
|
|
|
25
25
|
unregister: (id: string, element: HTMLDivElement | undefined) => void
|
|
26
26
|
focusItem: (id: string) => void
|
|
27
27
|
noteFocus: (id: string) => void
|
|
28
|
+
noteBlur: (next: EventTarget | null) => void
|
|
28
29
|
activate: (id: string, event: Event) => void
|
|
29
30
|
expand: (id: string, expanded: boolean) => void
|
|
30
31
|
disclosure: () => TreeDisclosure
|
|
@@ -114,8 +115,14 @@ export type TreeProps = Omit<
|
|
|
114
115
|
* the target row: when the active row leaves `visibleItems`, the first visible
|
|
115
116
|
* row becomes the roving stop and `onActiveIdChange` is called with it. If the
|
|
116
117
|
* removed row held focus, or was the last row the tree saw focused, focus moves
|
|
117
|
-
* to that same row; if it had no focus, only the roving stop moves.
|
|
118
|
-
*
|
|
118
|
+
* to that same row; if it had no focus, only the roving stop moves. Focus that
|
|
119
|
+
* has already gone to a control outside the tree is never pulled back: the tree
|
|
120
|
+
* forgets its last focused row when focus moves to an element outside it, and
|
|
121
|
+
* the roving stop alone moves if that control still has focus when the row is
|
|
122
|
+
* hidden. Focus the tree has asked for but not yet moved (a queued recovery, or a
|
|
123
|
+
* keyboard move to a row the host has not mounted) is withdrawn when focus lands
|
|
124
|
+
* outside the tree, and is checked again when a row mounts. This is the same in
|
|
125
|
+
* both `disclosure` modes and for every way a host collapses a row.
|
|
119
126
|
*/
|
|
120
127
|
export function Tree(props: TreeProps) {
|
|
121
128
|
const [local, rest] = splitProps(props, [
|
|
@@ -143,9 +150,26 @@ export function Tree(props: TreeProps) {
|
|
|
143
150
|
const rows = new Map<string, HTMLDivElement>()
|
|
144
151
|
const rowIdsByElement = new WeakMap<HTMLDivElement, string>()
|
|
145
152
|
let pendingFocusId: string | null = null
|
|
153
|
+
let pendingFocusGuard: ((event: FocusEvent) => void) | undefined
|
|
146
154
|
let requestedRevealId: string | null = null
|
|
147
155
|
let focusedId: string | null = null
|
|
148
156
|
let disposed = false
|
|
157
|
+
const isInsideTreeRow = (element: Element) => {
|
|
158
|
+
const row = element.closest('[role="treeitem"]')
|
|
159
|
+
return row instanceof HTMLDivElement && rowIdsByElement.has(row)
|
|
160
|
+
}
|
|
161
|
+
// True when a focusable control outside this tree owns focus. Nothing focused
|
|
162
|
+
// (`body`) is not outside: that is where focus falls when a focused row is removed.
|
|
163
|
+
const focusIsOutsideTree = () => {
|
|
164
|
+
if (typeof document === 'undefined') return false
|
|
165
|
+
const active = document.activeElement
|
|
166
|
+
return (
|
|
167
|
+
active instanceof Element &&
|
|
168
|
+
active !== document.body &&
|
|
169
|
+
active !== document.documentElement &&
|
|
170
|
+
!isInsideTreeRow(active)
|
|
171
|
+
)
|
|
172
|
+
}
|
|
149
173
|
const rowSizeObserver =
|
|
150
174
|
typeof ResizeObserver === 'undefined'
|
|
151
175
|
? undefined
|
|
@@ -160,8 +184,33 @@ export function Tree(props: TreeProps) {
|
|
|
160
184
|
}
|
|
161
185
|
})
|
|
162
186
|
|
|
187
|
+
// Focus the tree has asked for but not yet moved (a microtask away, or waiting for a
|
|
188
|
+
// virtualised row to mount) is only a request. While one is pending, focus arriving
|
|
189
|
+
// on a control outside the tree withdraws it, so a row mounting later cannot take
|
|
190
|
+
// focus back from a control the user, or the host, has since chosen.
|
|
191
|
+
const setPendingFocus = (id: string | null) => {
|
|
192
|
+
pendingFocusId = id
|
|
193
|
+
if (typeof document === 'undefined') return
|
|
194
|
+
if (id === null) {
|
|
195
|
+
if (pendingFocusGuard) document.removeEventListener('focusin', pendingFocusGuard, true)
|
|
196
|
+
pendingFocusGuard = undefined
|
|
197
|
+
} else if (!pendingFocusGuard) {
|
|
198
|
+
pendingFocusGuard = (event) => {
|
|
199
|
+
if (event.target instanceof Element && !isInsideTreeRow(event.target)) cancelPendingFocus()
|
|
200
|
+
}
|
|
201
|
+
document.addEventListener('focusin', pendingFocusGuard, true)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Withdrawing a request also drops the memory of the row it was recovering for:
|
|
205
|
+
// nothing is left to recover.
|
|
206
|
+
const cancelPendingFocus = () => {
|
|
207
|
+
setPendingFocus(null)
|
|
208
|
+
focusedId = null
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
onCleanup(() => {
|
|
164
212
|
disposed = true
|
|
213
|
+
setPendingFocus(null)
|
|
165
214
|
rowSizeObserver?.disconnect()
|
|
166
215
|
})
|
|
167
216
|
|
|
@@ -176,13 +225,19 @@ export function Tree(props: TreeProps) {
|
|
|
176
225
|
if (pendingFocusId !== id || effectiveActiveId() !== id) return
|
|
177
226
|
const row = rows.get(id)
|
|
178
227
|
if (!row || typeof document === 'undefined') return
|
|
228
|
+
// Ownership is checked when focus would move, not when it was requested: a
|
|
229
|
+
// control outside the tree may have taken focus since, and keeps it.
|
|
230
|
+
if (focusIsOutsideTree()) {
|
|
231
|
+
cancelPendingFocus()
|
|
232
|
+
return
|
|
233
|
+
}
|
|
179
234
|
row.focus()
|
|
180
|
-
if (document.activeElement === row)
|
|
235
|
+
if (document.activeElement === row) setPendingFocus(null)
|
|
181
236
|
})
|
|
182
237
|
}
|
|
183
238
|
|
|
184
239
|
const focusItem = (id: string) => {
|
|
185
|
-
|
|
240
|
+
setPendingFocus(id)
|
|
186
241
|
local.onActiveIdChange(id)
|
|
187
242
|
if (rows.has(id)) requestedRevealId = null
|
|
188
243
|
else requestReveal(id)
|
|
@@ -216,8 +271,14 @@ export function Tree(props: TreeProps) {
|
|
|
216
271
|
if (focusWasInside) {
|
|
217
272
|
queueMicrotask(() => {
|
|
218
273
|
const fallbackId = effectiveActiveId()
|
|
219
|
-
if (
|
|
220
|
-
|
|
274
|
+
if (disposed || fallbackId === null || pendingFocusId === fallbackId) return
|
|
275
|
+
// Focus fell out of the removed row, but it may have been given to a control
|
|
276
|
+
// outside the tree before this ran. Only nothing-focused (body) recovers.
|
|
277
|
+
if (focusIsOutsideTree()) {
|
|
278
|
+
focusedId = null
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
focusItem(fallbackId)
|
|
221
282
|
})
|
|
222
283
|
}
|
|
223
284
|
},
|
|
@@ -225,6 +286,13 @@ export function Tree(props: TreeProps) {
|
|
|
225
286
|
noteFocus: (id) => {
|
|
226
287
|
focusedId = id
|
|
227
288
|
},
|
|
289
|
+
noteBlur: (next) => {
|
|
290
|
+
// Focus moving to an element outside this tree ends the memory of the last
|
|
291
|
+
// focused row and withdraws any focus the tree was still waiting to give a
|
|
292
|
+
// row. A null target (focus to nothing, or the window losing focus) is
|
|
293
|
+
// ambiguous, so both stay for the recovery below.
|
|
294
|
+
if (next instanceof Element && !isInsideTreeRow(next)) cancelPendingFocus()
|
|
295
|
+
},
|
|
228
296
|
activate: (id, event) => local.onActivate?.(id, event),
|
|
229
297
|
expand: (id, expanded) => local.onExpand?.(id, expanded),
|
|
230
298
|
disclosure: () => local.disclosure ?? 'row',
|
|
@@ -239,11 +307,15 @@ export function Tree(props: TreeProps) {
|
|
|
239
307
|
if (activeId === null) return
|
|
240
308
|
// If the projection removes the last row that took focus (a host filter, or
|
|
241
309
|
// an ancestor collapsing), move focus to the same fallback that now owns the
|
|
242
|
-
// roving tab stop
|
|
243
|
-
//
|
|
244
|
-
// behaviour is deliberately left as it was.
|
|
310
|
+
// roving tab stop, unless a control outside the tree has focus: that control
|
|
311
|
+
// keeps it, and only the roving stop moves.
|
|
245
312
|
if (focusedId !== null && !index().byId.has(focusedId) && pendingFocusId !== activeId) {
|
|
246
|
-
|
|
313
|
+
if (focusIsOutsideTree()) {
|
|
314
|
+
focusedId = null
|
|
315
|
+
if (local.activeId !== activeId) local.onActiveIdChange(activeId)
|
|
316
|
+
} else {
|
|
317
|
+
focusItem(activeId)
|
|
318
|
+
}
|
|
247
319
|
} else if (local.activeId !== activeId) {
|
|
248
320
|
local.onActiveIdChange(activeId)
|
|
249
321
|
}
|
|
@@ -354,12 +426,19 @@ export function TreeRow(props: TreeRowProps) {
|
|
|
354
426
|
let registeredId: string | undefined
|
|
355
427
|
const id = () => local.item.id
|
|
356
428
|
|
|
357
|
-
|
|
429
|
+
const handleFocusOut = (event: FocusEvent) => tree.noteBlur(event.relatedTarget)
|
|
430
|
+
|
|
431
|
+
onCleanup(() => {
|
|
432
|
+
row?.removeEventListener('focusout', handleFocusOut)
|
|
433
|
+
tree.unregister(registeredId ?? id(), row)
|
|
434
|
+
})
|
|
358
435
|
|
|
359
436
|
const setRef = (element: HTMLDivElement) => {
|
|
360
437
|
row = element
|
|
361
438
|
registeredId = id()
|
|
362
439
|
tree.register(registeredId, element)
|
|
440
|
+
// A listener, not an `onFocusOut` prop, so a host's own handler is untouched.
|
|
441
|
+
element.addEventListener('focusout', handleFocusOut)
|
|
363
442
|
}
|
|
364
443
|
|
|
365
444
|
createEffect(() => {
|
|
@@ -12,6 +12,12 @@ import { cn } from '#lib/utils'
|
|
|
12
12
|
* narrow screens the middle track yields space to the controls, and each side
|
|
13
13
|
* group scrolls when necessary so keyboard focus can reveal every action.
|
|
14
14
|
*
|
|
15
|
+
* The centre track is sized for a lone search field. A bar whose centre holds
|
|
16
|
+
* more than that, such as a search plus an action beside it, passes
|
|
17
|
+
* `centerWidth="wide"`: the centre then stays fluid below 1280px, never
|
|
18
|
+
* narrower than 7rem so its controls are not squeezed out by a long leading
|
|
19
|
+
* group (which scrolls instead), and takes a wider fixed track above it.
|
|
20
|
+
*
|
|
15
21
|
* On a frameless desktop window this row is also the drag region, so it carries
|
|
16
22
|
* `window-drag` and the controls inside it opt back out. `macos-inset` and
|
|
17
23
|
* `windows-inset` reserve the space the native window controls occupy, which is
|
|
@@ -27,6 +33,13 @@ export type TopBarProps = ComponentProps<'header'> & {
|
|
|
27
33
|
windowsInset?: boolean
|
|
28
34
|
/** Blur the content scrolling under the bar. */
|
|
29
35
|
glass?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* How wide the centre track is. `field` (default) suits a lone search field.
|
|
38
|
+
* `wide` suits a centre that also holds an action: fluid below 1280px and
|
|
39
|
+
* never narrower than 7rem (the side groups scroll to make room), then a
|
|
40
|
+
* clamp of 32vw up to 672px between equal side tracks.
|
|
41
|
+
*/
|
|
42
|
+
centerWidth?: 'field' | 'wide'
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
export function TopBar(props: TopBarProps) {
|
|
@@ -36,6 +49,7 @@ export function TopBar(props: TopBarProps) {
|
|
|
36
49
|
'macosInset',
|
|
37
50
|
'windowsInset',
|
|
38
51
|
'glass',
|
|
52
|
+
'centerWidth',
|
|
39
53
|
])
|
|
40
54
|
|
|
41
55
|
return (
|
|
@@ -43,6 +57,8 @@ export function TopBar(props: TopBarProps) {
|
|
|
43
57
|
data-slot="top-bar"
|
|
44
58
|
class={cn(
|
|
45
59
|
'h-topbar relative z-(--z-sticky) grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-border bg-card text-card-foreground md:grid-cols-[minmax(0,1fr)_clamp(240px,22vw,480px)_minmax(0,1fr)]',
|
|
60
|
+
local.centerWidth === 'wide' &&
|
|
61
|
+
'grid-cols-[auto_minmax(7rem,1fr)_auto] gap-2 md:grid-cols-[auto_minmax(7rem,1fr)_auto] xl:grid-cols-[minmax(0,1fr)_clamp(240px,32vw,672px)_minmax(0,1fr)]',
|
|
46
62
|
local.glass && 'glass-panel',
|
|
47
63
|
local.draggable && 'window-drag',
|
|
48
64
|
/* Each edge gets exactly one inline-padding utility. A window inset
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ComponentProps } from 'solid-js'
|
|
2
|
-
import { splitProps } from 'solid-js'
|
|
2
|
+
import { children, createMemo, splitProps } from 'solid-js'
|
|
3
3
|
import { cva, type VariantProps } from '#lib/variants'
|
|
4
4
|
import { cn } from '#lib/utils'
|
|
5
5
|
|
|
@@ -75,15 +75,35 @@ export const calloutVariants = cva(
|
|
|
75
75
|
|
|
76
76
|
export type CalloutProps = ComponentProps<'div'> & VariantProps<typeof calloutVariants>
|
|
77
77
|
|
|
78
|
+
const isRendered = (node: unknown) =>
|
|
79
|
+
node != null && typeof node !== 'boolean' && (typeof node !== 'string' || node.trim() !== '')
|
|
80
|
+
|
|
81
|
+
const isCalloutTitle = (node: unknown) =>
|
|
82
|
+
typeof node === 'object' &&
|
|
83
|
+
node !== null &&
|
|
84
|
+
'getAttribute' in node &&
|
|
85
|
+
typeof node.getAttribute === 'function' &&
|
|
86
|
+
node.getAttribute('data-slot') === 'callout-title'
|
|
87
|
+
|
|
78
88
|
export function Callout(props: CalloutProps) {
|
|
79
|
-
const [local, rest] = splitProps(props, ['class', 'variant', 'size'])
|
|
89
|
+
const [local, rest] = splitProps(props, ['class', 'variant', 'size', 'children'])
|
|
90
|
+
const resolved = children(() => local.children)
|
|
91
|
+
// CSS cannot see text nodes, so the callout looks at what it renders and marks a
|
|
92
|
+
// title with nothing after it. The title reads the mark to drop its bottom margin.
|
|
93
|
+
const titleOnly = createMemo(() => {
|
|
94
|
+
const nodes = resolved.toArray().filter(isRendered)
|
|
95
|
+
return nodes.length === 1 && isCalloutTitle(nodes[0])
|
|
96
|
+
})
|
|
80
97
|
return (
|
|
81
98
|
<div
|
|
82
99
|
data-slot="callout"
|
|
100
|
+
data-title-only={titleOnly() ? '' : undefined}
|
|
83
101
|
role="note"
|
|
84
102
|
class={cn(calloutVariants({ variant: local.variant, size: local.size }), local.class)}
|
|
85
103
|
{...rest}
|
|
86
|
-
|
|
104
|
+
>
|
|
105
|
+
{resolved()}
|
|
106
|
+
</div>
|
|
87
107
|
)
|
|
88
108
|
}
|
|
89
109
|
|
|
@@ -93,22 +113,23 @@ export function Callout(props: CalloutProps) {
|
|
|
93
113
|
* level would break it. Pass `role="heading"` with `aria-level` where the
|
|
94
114
|
* content does belong in the outline.
|
|
95
115
|
*
|
|
96
|
-
* The title
|
|
116
|
+
* The title carries a 4px (`mb-1`) margin below it, whatever follows: a
|
|
97
117
|
* paragraph, a list, or bare text. CSS cannot see text nodes, so a selector such
|
|
98
118
|
* as `:last-child` or `:only-child` cannot tell `<Title/>text` (needs the gap)
|
|
99
119
|
* from `<Title/>` (needs none), and `not-last:mb-1` silently dropped the gap for
|
|
100
|
-
* bare text, the most common body.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
120
|
+
* bare text, the most common body. So the gap stays the default and the one case
|
|
121
|
+
* that does not want it is detected by `Callout`, which sees its rendered
|
|
122
|
+
* children: a callout whose only child is a title carries `data-title-only`, and
|
|
123
|
+
* the title drops its margin so the block ends at its own padding. A caller never
|
|
124
|
+
* passes `mb-0` for this. Anything else after the title, text included, keeps the
|
|
125
|
+
* gap.
|
|
105
126
|
*/
|
|
106
127
|
export function CalloutTitle(props: ComponentProps<'div'>) {
|
|
107
128
|
const [local, rest] = splitProps(props, ['class'])
|
|
108
129
|
return (
|
|
109
130
|
<div
|
|
110
131
|
data-slot="callout-title"
|
|
111
|
-
class={cn('mb-1 font-medium tracking-tight', local.class)}
|
|
132
|
+
class={cn('mb-1 font-medium tracking-tight in-data-title-only:mb-0', local.class)}
|
|
112
133
|
{...rest}
|
|
113
134
|
/>
|
|
114
135
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { splitProps, useContext, type ComponentProps, type JSX } from 'solid-js'
|
|
1
|
+
import { Show, splitProps, useContext, type ComponentProps, type JSX } from 'solid-js'
|
|
2
2
|
import { ActionButton } from '../../composites/action-button'
|
|
3
3
|
import { FormFieldContext } from '#lib/form-field'
|
|
4
4
|
import { InputGroup, InputGroupInput } from './input-group'
|
|
@@ -56,28 +56,31 @@ export function SecretInputGroup(props: SecretInputGroupProps) {
|
|
|
56
56
|
/>
|
|
57
57
|
)
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// One group for both cases: `onClear` may appear or disappear after setup
|
|
60
|
+
// (a stored secret is cleared, or a configured one arrives), and the input
|
|
61
|
+
// must keep its element, value and focus while the action comes and goes.
|
|
61
62
|
return (
|
|
62
63
|
<InputGroup class={local.class}>
|
|
63
64
|
{input}
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
65
|
+
<Show when={local.onClear}>
|
|
66
|
+
<ActionButton
|
|
67
|
+
data-slot="input-group-button"
|
|
68
|
+
variant="ghost"
|
|
69
|
+
size="xs"
|
|
70
|
+
class="-me-1 h-6 min-w-0 shrink-0 px-2"
|
|
71
|
+
disabled={local.disabled}
|
|
72
|
+
tooltip={
|
|
73
|
+
local.disabled
|
|
74
|
+
? (local.disabledReason ?? 'This API key is managed by policy')
|
|
75
|
+
: (local.clearLabel ?? 'Clear API key')
|
|
76
|
+
}
|
|
77
|
+
tooltipSide="top"
|
|
78
|
+
aria-label={local.clearLabel ?? 'Clear API key'}
|
|
79
|
+
onClick={() => local.onClear?.()}
|
|
80
|
+
>
|
|
81
|
+
Clear
|
|
82
|
+
</ActionButton>
|
|
83
|
+
</Show>
|
|
81
84
|
</InputGroup>
|
|
82
85
|
)
|
|
83
86
|
}
|