@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.
@@ -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. This is the
118
- * same in both `disclosure` modes and for every way a host collapses a row.
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) pendingFocusId = null
235
+ if (document.activeElement === row) setPendingFocus(null)
181
236
  })
182
237
  }
183
238
 
184
239
  const focusItem = (id: string) => {
185
- pendingFocusId = id
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 (!disposed && fallbackId !== null && pendingFocusId !== fallbackId)
220
- focusItem(fallbackId)
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. `focusedId` is not cleared when focus leaves the tree, so
243
- // this also reclaims focus from a control outside it; that long-standing
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
- focusItem(activeId)
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
- onCleanup(() => tree.unregister(registeredId ?? id(), row))
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 always carries a 4px (`mb-1`) margin below it, whatever follows: a
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. The unconditional margin is the less
101
- * surprising failure: it is never missing where text follows. The price is the
102
- * title-only callout, which would end 4px taller than its padding says. Pass
103
- * `class="mb-0"` there (`cn` lets the caller's margin win); the `WithTitle` story
104
- * shows it.
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
- if (!local.onClear) return <InputGroup class={local.class}>{input}</InputGroup>
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
- <ActionButton
65
- data-slot="input-group-button"
66
- variant="ghost"
67
- size="xs"
68
- class="-me-1 h-6 min-w-0 shrink-0 px-2"
69
- disabled={local.disabled}
70
- tooltip={
71
- local.disabled
72
- ? (local.disabledReason ?? 'This API key is managed by policy')
73
- : (local.clearLabel ?? 'Clear API key')
74
- }
75
- tooltipSide="top"
76
- aria-label={local.clearLabel ?? 'Clear API key'}
77
- onClick={() => local.onClear?.()}
78
- >
79
- Clear
80
- </ActionButton>
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
  }