@adea-ai/ui 0.69.1 → 0.70.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 (22) hide show
  1. package/README.md +14 -9
  2. package/dist/components/composites/appearance-editor/appearance-editor.d.ts +1 -1
  3. package/dist/components/composites/appearance-editor/appearance-editor.js +1 -1
  4. package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
  5. package/dist/components/composites/appearance-editor/appearance-rows.d.ts +7 -0
  6. package/dist/components/composites/appearance-editor/appearance-rows.d.ts.map +1 -1
  7. package/dist/components/composites/appearance-editor/appearance-rows.js +187 -41
  8. package/dist/components/composites/appearance-editor/appearance-rows.js.map +1 -1
  9. package/dist/components/ui/dropdown-menu/dropdown-menu.d.ts +6 -4
  10. package/dist/components/ui/dropdown-menu/dropdown-menu.d.ts.map +1 -1
  11. package/dist/components/ui/dropdown-menu/dropdown-menu.js +21 -15
  12. package/dist/components/ui/dropdown-menu/dropdown-menu.js.map +1 -1
  13. package/dist/r/appearance-editor.json +1 -1
  14. package/dist/r/registry.json +1 -1
  15. package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +1 -1
  16. package/dist/r/src/components/composites/appearance-editor/appearance-rows.tsx +231 -34
  17. package/dist/r/src/components/ui/dropdown-menu/dropdown-menu.tsx +8 -6
  18. package/package.json +1 -1
  19. package/registry.json +1 -1
  20. package/src/components/composites/appearance-editor/appearance-editor.tsx +1 -1
  21. package/src/components/composites/appearance-editor/appearance-rows.tsx +231 -34
  22. package/src/components/ui/dropdown-menu/dropdown-menu.tsx +8 -6
@@ -24,18 +24,33 @@
24
24
  // Retains the compact divided row and palette-preview selector from the pinned
25
25
  // Zeron/Adea composition; Kobalte owns option navigation and nested dismissal.
26
26
  import type { AdeaTheme, AdeaThemeRecord } from '@adea-ai/themes'
27
- import { Show, createMemo, createSignal, type JSX } from 'solid-js'
28
- import { Palette } from 'lucide-solid'
27
+ import { For, Show, createMemo, createSignal, onCleanup, type JSX } from 'solid-js'
28
+ import { ChevronDown, Palette } from 'lucide-solid'
29
29
  import { cn } from '#lib/utils'
30
+ import { Button } from '../../ui/button/button'
30
31
  import {
31
- Select,
32
- SelectContent,
33
- SelectItem,
34
- SelectTrigger,
35
- SelectValue,
36
- } from '../../ui/select/select'
32
+ DropdownMenu,
33
+ DropdownMenuContent,
34
+ DropdownMenuRadioGroup,
35
+ DropdownMenuRadioItem,
36
+ DropdownMenuTrigger,
37
+ } from '../../ui/dropdown-menu/dropdown-menu'
37
38
  import { PalettePreview } from './theme-preview'
38
39
 
40
+ const POINTER_FOCUS_TARGET_SELECTOR =
41
+ 'a[href], area[href], button, input, select, textarea, iframe, [tabindex], [contenteditable], details > summary:first-of-type'
42
+
43
+ function isTabStop(element: HTMLElement) {
44
+ return (
45
+ (element.tabIndex >= 0 ||
46
+ (element.matches('details > summary:first-of-type') && !element.hasAttribute('tabindex'))) &&
47
+ !element.matches(':disabled') &&
48
+ element.getAttribute('aria-disabled') !== 'true' &&
49
+ element.closest('[hidden], [inert], [aria-hidden="true"]') === null &&
50
+ element.getClientRects().length > 0
51
+ )
52
+ }
53
+
39
54
  export function SettingsRow(props: {
40
55
  title: string
41
56
  icon: JSX.Element
@@ -66,6 +81,13 @@ export function SettingsRow(props: {
66
81
  )
67
82
  }
68
83
 
84
+ /**
85
+ * A compact persistent theme choice. Kobalte handles menu navigation and
86
+ * dismissal; this row moves Tab to the adjacent browser-reported stop because
87
+ * menus consume Tab by default. Enumerating elements and checking their native
88
+ * tabIndex keeps details summaries in the order while preserving explicit
89
+ * tabindex overrides.
90
+ */
69
91
  export function ThemeRow(props: {
70
92
  appearance: 'light' | 'dark'
71
93
  selectedId: string
@@ -75,6 +97,15 @@ export function ThemeRow(props: {
75
97
  onSelect: (id: string) => void
76
98
  }) {
77
99
  const [portalMount, setPortalMount] = createSignal<HTMLDivElement>()
100
+ const [triggerElement, setTriggerElement] = createSignal<HTMLButtonElement>()
101
+ const [menuOpen, setMenuOpen] = createSignal(false)
102
+ const [focusOutsideTarget, setFocusOutsideTarget] = createSignal<HTMLElement>()
103
+ const [restoreTriggerFocus, setRestoreTriggerFocus] = createSignal(false)
104
+ let focusTargetAfterTab: HTMLElement | undefined
105
+ let focusLifecycleVersion = 0
106
+ onCleanup(() => {
107
+ focusLifecycleVersion += 1
108
+ })
78
109
  const options = createMemo(() =>
79
110
  props.themes.filter((theme) => theme.appearance === props.appearance)
80
111
  )
@@ -84,38 +115,204 @@ export function ThemeRow(props: {
84
115
  options().find((theme) => theme.id === props.preview.id)
85
116
  )
86
117
  const label = () => (props.appearance === 'light' ? 'Light theme' : 'Dark theme')
118
+ const closeMenu = () => {
119
+ if (menuOpen()) focusLifecycleVersion += 1
120
+ setMenuOpen(false)
121
+ }
122
+ const moveFocusOnTab = (event: KeyboardEvent) => {
123
+ if (event.key !== 'Tab' || event.isComposing) return
124
+
125
+ const trigger = triggerElement()
126
+ if (!trigger) return
127
+ const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
128
+ const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
129
+ if (!focusScope) return
130
+
131
+ const focusStops = Array.from(focusScope.querySelectorAll<HTMLElement>('*')).filter(
132
+ (element) =>
133
+ isTabStop(element) &&
134
+ element.closest('[role="menu"], [hidden], [inert], [aria-hidden="true"]') === null
135
+ )
136
+ const orderedFocusStops = [
137
+ ...focusStops
138
+ .filter((element) => element.tabIndex > 0)
139
+ .toSorted((a, b) => a.tabIndex - b.tabIndex),
140
+ ...focusStops.filter((element) => element.tabIndex <= 0),
141
+ ]
142
+ const triggerIndex = orderedFocusStops.indexOf(trigger)
143
+ const direction = event.shiftKey ? -1 : 1
144
+ const adjacent =
145
+ orderedFocusStops[triggerIndex + direction] ??
146
+ (parentDialog ? (direction > 0 ? orderedFocusStops[0] : orderedFocusStops.at(-1)) : undefined)
147
+
148
+ event.stopPropagation()
149
+ setRestoreTriggerFocus(false)
150
+ if (adjacent) {
151
+ event.preventDefault()
152
+ focusTargetAfterTab = adjacent
153
+ setFocusOutsideTarget(adjacent)
154
+ closeMenu()
155
+ adjacent.focus({ preventScroll: true })
156
+ } else {
157
+ focusTargetAfterTab = undefined
158
+ setFocusOutsideTarget(undefined)
159
+ closeMenu()
160
+ }
161
+ }
87
162
  return (
88
163
  <SettingsRow title={label()} icon={<Palette />}>
89
164
  <div ref={setPortalMount}>
90
- <Select
165
+ <DropdownMenu
91
166
  modal={false}
92
- options={options()}
93
- value={selected()}
94
- optionValue="id"
95
- optionTextValue="name"
96
- disabled={props.disabled || options().length === 0}
97
- onChange={(theme) => {
98
- if (theme) props.onSelect(theme.id)
167
+ open={menuOpen()}
168
+ onOpenChange={(open) => {
169
+ if (open !== menuOpen()) focusLifecycleVersion += 1
170
+ setMenuOpen(open)
171
+ if (open) {
172
+ focusTargetAfterTab = undefined
173
+ setFocusOutsideTarget(undefined)
174
+ setRestoreTriggerFocus(false)
175
+ }
99
176
  }}
100
- itemComponent={(item) => (
101
- <SelectItem item={item.item}>
102
- <PalettePreview theme={item.item.rawValue} />
103
- {item.item.rawValue.name}
104
- </SelectItem>
105
- )}
106
177
  >
107
- <SelectTrigger size="sm" aria-label={label()} class="w-full sm:w-52">
108
- <SelectValue>
109
- {() => (
110
- <>
111
- <PalettePreview theme={props.preview} />
112
- <span>{selected()?.name ?? props.preview.name}</span>
113
- </>
114
- )}
115
- </SelectValue>
116
- </SelectTrigger>
117
- <SelectContent portalMount={portalMount()} />
118
- </Select>
178
+ <DropdownMenuTrigger
179
+ ref={setTriggerElement}
180
+ as={Button}
181
+ variant="outline"
182
+ size="sm"
183
+ aria-label={label()}
184
+ disabled={props.disabled || options().length === 0}
185
+ class="w-full justify-between sm:w-52"
186
+ >
187
+ <span class="flex min-w-0 flex-1 items-center gap-2">
188
+ <PalettePreview theme={props.preview} />
189
+ <span class="min-w-0 flex-1 truncate">{selected()?.name ?? props.preview.name}</span>
190
+ </span>
191
+ <ChevronDown aria-hidden="true" class="shrink-0 text-muted-foreground" />
192
+ </DropdownMenuTrigger>
193
+ <DropdownMenuContent
194
+ portalMount={portalMount()}
195
+ class="max-h-(--kb-popper-content-available-height) w-(--kb-popper-anchor-width) min-w-32 overflow-x-hidden overflow-y-auto"
196
+ onInteractOutside={(event) => {
197
+ if (event.detail.originalEvent.type !== 'pointerdown') return
198
+ const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
199
+ const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
200
+ const target = event.detail.originalEvent.target
201
+ if (!(target instanceof Element) || !focusScope?.contains(target)) return
202
+
203
+ const focusTarget = target.closest<HTMLElement>(POINTER_FOCUS_TARGET_SELECTOR)
204
+ if (focusTarget && isTabStop(focusTarget)) {
205
+ setRestoreTriggerFocus(false)
206
+ setFocusOutsideTarget(focusTarget)
207
+ } else {
208
+ setRestoreTriggerFocus(true)
209
+ }
210
+ }}
211
+ onFocusOutside={(event) => {
212
+ // The containing dialog or document body can briefly reclaim
213
+ // focus when the menu opens. Keep that handoff from dismissing
214
+ // it; focus moving to another descendant still closes the menu.
215
+ const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
216
+ const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
217
+ const target = event.detail.originalEvent.target
218
+ if (target === focusScope) {
219
+ event.preventDefault()
220
+ } else if (target instanceof HTMLElement && focusScope?.contains(target)) {
221
+ setRestoreTriggerFocus(false)
222
+ setFocusOutsideTarget(target)
223
+ }
224
+ }}
225
+ onEscapeKeyDown={() => setRestoreTriggerFocus(true)}
226
+ onCloseAutoFocus={(event) => {
227
+ // A prior menu instance can finish its focus cleanup after this
228
+ // row has already reopened. Do not let that cleanup steal focus.
229
+ if (menuOpen()) {
230
+ event.preventDefault()
231
+ return
232
+ }
233
+
234
+ const lifecycleVersion = focusLifecycleVersion
235
+ const shouldRestore = restoreTriggerFocus()
236
+ const focusTarget = focusTargetAfterTab ?? focusOutsideTarget()
237
+ focusTargetAfterTab = undefined
238
+ setFocusOutsideTarget(undefined)
239
+ setRestoreTriggerFocus(false)
240
+ if (shouldRestore) {
241
+ // The saved focus may be the containing dialog rather than
242
+ // the trigger when WebKit opens this menu from a pointer click.
243
+ event.preventDefault()
244
+ triggerElement()?.focus({ preventScroll: true })
245
+ return
246
+ }
247
+
248
+ const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
249
+ const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
250
+ if (focusTarget?.isConnected && focusScope?.contains(focusTarget)) {
251
+ // Preserve the intended in-scope destination across
252
+ // Kobalte's deferred stale-focus restore.
253
+ event.preventDefault()
254
+ queueMicrotask(() => {
255
+ if (lifecycleVersion !== focusLifecycleVersion || menuOpen()) return
256
+
257
+ const activeElement = document.activeElement
258
+ const isMenuFocus =
259
+ activeElement instanceof HTMLElement &&
260
+ activeElement.closest('[role="menu"]') !== null
261
+ const isTriggerFocus = activeElement === triggerElement()
262
+ if (
263
+ activeElement !== focusTarget &&
264
+ (activeElement === document.body ||
265
+ activeElement === focusScope ||
266
+ isMenuFocus ||
267
+ isTriggerFocus)
268
+ ) {
269
+ focusTarget.focus({ preventScroll: true })
270
+ }
271
+ })
272
+ return
273
+ }
274
+
275
+ const activeElement = document.activeElement
276
+ if (
277
+ focusScope &&
278
+ activeElement instanceof HTMLElement &&
279
+ activeElement !== focusScope &&
280
+ focusScope.contains(activeElement) &&
281
+ activeElement.closest('[role="menu"]') === null
282
+ ) {
283
+ // Preserve a real focus target in this scope instead of
284
+ // restoring the stale element captured when the menu mounted. A
285
+ // focused menu item is about to unmount, so Kobalte must restore
286
+ // focus to the trigger instead.
287
+ event.preventDefault()
288
+ }
289
+ }}
290
+ >
291
+ <DropdownMenuRadioGroup
292
+ value={selected()?.id ?? ''}
293
+ aria-label={`${label()} options`}
294
+ on:keydown={moveFocusOnTab}
295
+ onChange={(id) => {
296
+ if (!props.disabled && typeof id === 'string') props.onSelect(id)
297
+ }}
298
+ >
299
+ <For each={options()}>
300
+ {(theme) => (
301
+ <DropdownMenuRadioItem
302
+ value={theme.id}
303
+ textValue={theme.name}
304
+ closeOnSelect={true}
305
+ disabled={props.disabled}
306
+ onSelect={() => setRestoreTriggerFocus(true)}
307
+ >
308
+ <PalettePreview theme={theme} />
309
+ <span class="min-w-0 flex-1 truncate">{theme.name}</span>
310
+ </DropdownMenuRadioItem>
311
+ )}
312
+ </For>
313
+ </DropdownMenuRadioGroup>
314
+ </DropdownMenuContent>
315
+ </DropdownMenu>
119
316
  </div>
120
317
  </SettingsRow>
121
318
  )
@@ -16,10 +16,10 @@ import { cn } from '#lib/utils'
16
16
  /**
17
17
  * DropdownMenu.
18
18
  *
19
- * A list of actions on a trigger. This is the right home for "what can I do
20
- * with this thing": rename, duplicate, export, delete. It is not a Select
21
- * (which holds a *value* the user is choosing) and not a Popover (which holds
22
- * arbitrary content).
19
+ * A list of actions on a trigger, with radio groups for persistent choices
20
+ * that fit naturally in a compact menu. Use Select for form inputs whose
21
+ * selected value needs the field-style control, and Popover for arbitrary
22
+ * content.
23
23
  *
24
24
  * Kobalte supplies the full menu contract — arrow keys, typeahead, the roving
25
25
  * highlight, Escape to close and return focus — which is exactly what a
@@ -42,13 +42,15 @@ export function DropdownMenuPortal(props: ComponentProps<typeof KobalteDropdownM
42
42
 
43
43
  export type DropdownMenuContentProps = ComponentProps<typeof KobalteDropdownMenu.Content> & {
44
44
  hideArrow?: boolean
45
+ /** Mount under an owner element such as a modal, keeping the menu in its accessible subtree. */
46
+ portalMount?: HTMLElement
45
47
  }
46
48
 
47
49
  export function DropdownMenuContent(props: DropdownMenuContentProps) {
48
- const [local, rest] = splitProps(props, ['class', 'hideArrow', 'children'])
50
+ const [local, rest] = splitProps(props, ['class', 'hideArrow', 'children', 'portalMount'])
49
51
 
50
52
  return (
51
- <KobalteDropdownMenu.Portal>
53
+ <KobalteDropdownMenu.Portal mount={local.portalMount}>
52
54
  <KobalteDropdownMenu.Content
53
55
  class={cn(
54
56
  overlaySurface,