@nexxtmove/ui 1.33.0 → 1.35.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.
@@ -0,0 +1,649 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, ref, useId, useTemplateRef, watch } from 'vue'
3
+ import { usePreferredReducedMotion } from '@vueuse/core'
4
+ import NexxtButton from '../../atoms/Button/Button.vue'
5
+ import NexxtIcon, { type NexxtIconName } from '../../atoms/Icon/Icon.vue'
6
+ import NexxtPickerItem from '../../atoms/PickerItem/PickerItem.vue'
7
+ import NexxtPickerGroup from '../PickerGroup/PickerGroup.vue'
8
+
9
+ export interface NexxtPickerItemData {
10
+ /** Stable id; also the base of the option's DOM id that `aria-activedescendant` points at. */
11
+ id: string
12
+ /** Visible label and accessible name of the option; searched and matched by type-ahead. */
13
+ label: string
14
+ /** Icon in front of the label, also passed to the `preview` slot. */
15
+ icon: NexxtIconName
16
+ /** Id of a group in `groups`. Missing, or not in `groups`: ungrouped, rendered at the top. */
17
+ group?: string
18
+ /** Extra search terms (synonyms, old names). Matched, never shown. */
19
+ keywords?: string[]
20
+ /** One line, shown in the preview and read out as the option's description. */
21
+ description?: string
22
+ /**
23
+ * Reachable with the arrows, type-ahead and the pointer, but never selected. Give a
24
+ * `description` that names the reason.
25
+ */
26
+ disabled?: boolean
27
+ }
28
+
29
+ export interface NexxtPickerGroupData {
30
+ /** Stable id; items join the group through their `group` field. */
31
+ id: string
32
+ /** Heading of the group, and the accessible name of its `role="group"`. */
33
+ label: string
34
+ /** Initial state, default `true`. After that the list keeps its own open/closed state. */
35
+ defaultOpen?: boolean
36
+ }
37
+
38
+ interface NexxtPickerListProps {
39
+ /** All items; `search` filters them. */
40
+ items: NexxtPickerItemData[]
41
+ /** Groups in display order. Ungrouped items come first. */
42
+ groups?: NexxtPickerGroupData[]
43
+ /** Search term from outside; the list renders no field of its own. Trimmed before matching. */
44
+ search?: string
45
+ /** Whether group headings fold their group. While searching every group is open regardless. */
46
+ collapsible?: boolean
47
+ /** Accessible name of the listbox. Required for an accessible name. */
48
+ ariaLabel?: string
49
+ /** Heading of the empty state, shown when the search has no results. */
50
+ emptyTitle?: string
51
+ /** Line under the empty heading. A function receives the trimmed search term. */
52
+ emptyDescription?: string | ((search: string) => string)
53
+ /** Label of the button in the empty state that clears the search. Without it there is no button. */
54
+ clearLabel?: string
55
+ /**
56
+ * Text announced in a polite live region 500ms after the search changes, e.g.
57
+ * `(n) => \`${n} resultaten\``. Without it the region stays empty.
58
+ */
59
+ resultsAnnouncement?: (count: number) => string
60
+ }
61
+
62
+ interface Section {
63
+ group: NexxtPickerGroupData
64
+ items: NexxtPickerItemData[]
65
+ open: boolean
66
+ }
67
+
68
+ /** `fold`: moved by a keyboard fold that animates; scrolled by `anchorFold` instead. */
69
+ type HighlightSource = 'keyboard' | 'pointer' | 'fold' | 'other'
70
+
71
+ defineOptions({ name: 'NexxtPickerList' })
72
+
73
+ const props = withDefaults(defineProps<NexxtPickerListProps>(), {
74
+ groups: () => [],
75
+ search: '',
76
+ collapsible: true,
77
+ })
78
+
79
+ /**
80
+ * Id of the highlighted item. The list owns the effective value: an id that is not visible, or
81
+ * `null` while items are visible, is replaced by the first enabled visible item right away.
82
+ */
83
+ const highlighted = defineModel<string | null>('highlighted', { default: null })
84
+
85
+ const emit = defineEmits<{
86
+ /** Enter, Space or a click on an enabled item. */
87
+ select: [item: NexxtPickerItemData]
88
+ /** The highlighted item changed; `null` when nothing is visible. */
89
+ highlight: [item: NexxtPickerItemData | null]
90
+ /** Escape while the listbox has focus. */
91
+ close: []
92
+ /**
93
+ * The clear button of the empty state was pressed. Clear `search` in response; the button leaves
94
+ * the DOM with the empty state, so move focus yourself, e.g. back to the search field.
95
+ */
96
+ clear: []
97
+ }>()
98
+
99
+ defineSlots<{
100
+ /** Content of an option; the option element and its aria stay the list's own. */
101
+ item?(props: { item: NexxtPickerItemData; highlighted: boolean }): unknown
102
+ /** Replaces the content of the empty state. */
103
+ empty?(props: { search: string; clear: () => void }): unknown
104
+ }>()
105
+
106
+ const PAGE_SIZE = 8
107
+ const TYPEAHEAD_RESET_MS = 800
108
+ const ANNOUNCE_DELAY_MS = 500
109
+
110
+ const listboxId = `${useId()}-listbox`
111
+ // Encoded so an id with a space in it never reads as two id references
112
+ const optionId = (id: string) => `${listboxId}-opt-${encodeURIComponent(id)}`
113
+
114
+ const listbox = useTemplateRef<HTMLElement>('listbox')
115
+
116
+ const preferredMotion = usePreferredReducedMotion()
117
+ const reduced = computed(() => preferredMotion.value === 'reduce')
118
+
119
+ // ─── Filtering ────────────────────────────────────────────────────────────────
120
+
121
+ const normalize = (value: string) =>
122
+ value.normalize('NFD').replace(/\p{M}/gu, '').toLocaleLowerCase()
123
+
124
+ const trimmedSearch = computed(() => props.search.trim())
125
+ const query = computed(() => normalize(trimmedSearch.value))
126
+ const searching = computed(() => query.value !== '')
127
+
128
+ const matches = (item: NexxtPickerItemData) =>
129
+ !searching.value ||
130
+ normalize(item.label).includes(query.value) ||
131
+ !!item.keywords?.some((keyword) => normalize(keyword).includes(query.value))
132
+
133
+ const matched = computed(() => props.items.filter(matches))
134
+
135
+ const groupIds = computed(() => new Set(props.groups.map((group) => group.id)))
136
+
137
+ const ungrouped = computed(() =>
138
+ matched.value.filter((item) => !item.group || !groupIds.value.has(item.group)),
139
+ )
140
+
141
+ // Only the groups a user toggled are recorded; the others follow their `defaultOpen`. New groups
142
+ // therefore start at their default and existing ones keep their state.
143
+ const openState = ref<Record<string, boolean>>({})
144
+
145
+ const isOpen = (group: NexxtPickerGroupData) =>
146
+ openState.value[group.id] ?? group.defaultOpen ?? true
147
+
148
+ // Searching renders every group open without touching `openState`, so clearing the search
149
+ // brings the previous state back
150
+ const canFold = computed(() => props.collapsible && !searching.value)
151
+
152
+ /** Groups with at least one match; a group without any does not render at all. */
153
+ const sections = computed<Section[]>(() =>
154
+ props.groups
155
+ .map((group) => ({
156
+ group,
157
+ items: matched.value.filter((item) => item.group === group.id),
158
+ open: !canFold.value || isOpen(group),
159
+ }))
160
+ .filter((section) => section.items.length > 0),
161
+ )
162
+
163
+ /** What arrows, type-ahead and the fallback work on, in display order. Never read off the DOM. */
164
+ const visibleItems = computed(() => [
165
+ ...ungrouped.value,
166
+ ...sections.value.flatMap((section) => (section.open ? section.items : [])),
167
+ ])
168
+
169
+ /** Anything rendered inside the listbox, closed group headings included. */
170
+ const hasContent = computed(() => ungrouped.value.length > 0 || sections.value.length > 0)
171
+ const showEmpty = computed(() => searching.value && visibleItems.value.length === 0)
172
+
173
+ // ─── Highlight ────────────────────────────────────────────────────────────────
174
+
175
+ const itemById = (id: string | null) =>
176
+ id === null ? null : (visibleItems.value.find((item) => item.id === id) ?? null)
177
+
178
+ const fallbackId = computed(
179
+ () => (visibleItems.value.find((item) => !item.disabled) ?? visibleItems.value[0])?.id ?? null,
180
+ )
181
+
182
+ const effectiveId = computed(() =>
183
+ itemById(highlighted.value) ? highlighted.value : fallbackId.value,
184
+ )
185
+
186
+ const activeDescendantId = computed(() =>
187
+ effectiveId.value === null ? undefined : optionId(effectiveId.value),
188
+ )
189
+
190
+ // What moved the highlight last. A hover must not scroll the list away under the pointer.
191
+ let source: HighlightSource = 'other'
192
+
193
+ const setHighlight = (id: string | null, from: HighlightSource) => {
194
+ // any other highlight request supersedes a scroll still waiting for a fold to settle
195
+ if (from !== 'fold') foldTarget = null
196
+ if (id === effectiveId.value) return
197
+ source = from
198
+ highlighted.value = id
199
+ }
200
+
201
+ // The model always shows what is highlighted: a consumer setting `null` on a non-empty list
202
+ // gets the fallback back
203
+ watch(
204
+ [highlighted, effectiveId],
205
+ ([value, effective]) => {
206
+ if (value !== effective) highlighted.value = effective
207
+ },
208
+ { immediate: true },
209
+ )
210
+
211
+ watch(
212
+ effectiveId,
213
+ async (id) => {
214
+ emit('highlight', itemById(id))
215
+
216
+ const from = source
217
+ source = 'other'
218
+ if (from === 'pointer' || from === 'fold') return
219
+
220
+ await nextTick()
221
+ scrollToItem(id)
222
+ },
223
+ { immediate: true },
224
+ )
225
+
226
+ // A new search moves every row even when the highlight stays the same item: clearing a search
227
+ // that found an item far down puts it back at its full-list position, out of view. So every
228
+ // search change brings the highlight into view once the rows are rendered, whatever moved it
229
+ // last: this scroll follows the search, not the pointer.
230
+ watch(
231
+ () => props.search,
232
+ async () => {
233
+ await nextTick()
234
+ scrollToItem(effectiveId.value)
235
+ },
236
+ )
237
+
238
+ // ─── Scrolling ────────────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Brings an option into view inside the listbox only, `block: 'nearest'`. By hand rather than
242
+ * with `scrollIntoView`, which also scrolls every scrollable ancestor, the page included. The
243
+ * first option of a group brings its heading along.
244
+ */
245
+ const scrollToItem = (id: string | null) => {
246
+ const option = id === null ? null : listbox.value?.ownerDocument.getElementById(optionId(id))
247
+ if (!option) return
248
+
249
+ const firstOfGroup = sections.value.some((section) => section.items[0]?.id === id)
250
+ scrollIntoRange((firstOfGroup && option.closest('[role="group"]')) || option, option)
251
+ }
252
+
253
+ /** Scrolls the listbox just enough to show from the top of `top` to the bottom of `bottom`. */
254
+ const scrollIntoRange = (top: Element, bottom: Element) => {
255
+ const box = listbox.value
256
+ if (!box) return
257
+
258
+ // `FloatingPanel` scales its content in; rects are scaled, `scrollTop` is not
259
+ const boxRect = box.getBoundingClientRect()
260
+ const scale = box.offsetHeight ? boxRect.height / box.offsetHeight : 1
261
+ const offset = (rect: DOMRect, edge: 'top' | 'bottom') =>
262
+ (rect[edge] - boxRect.top) / scale + box.scrollTop
263
+
264
+ const style = getComputedStyle(box)
265
+ const start = offset(top.getBoundingClientRect(), 'top') - (parseFloat(style.paddingTop) || 0)
266
+ const end =
267
+ offset(bottom.getBoundingClientRect(), 'bottom') + (parseFloat(style.paddingBottom) || 0)
268
+
269
+ if (start < box.scrollTop) box.scrollTop = start
270
+ else if (end > box.scrollTop + box.clientHeight) box.scrollTop = end - box.clientHeight
271
+ }
272
+
273
+ /**
274
+ * An animated fold moves everything below the group for 120–160ms, so a highlight past it cannot
275
+ * be scrolled to until the rows have settled: measured earlier, it ends up above the view. The
276
+ * group's heading does not move, and the highlight lands right under it. So the heading is
277
+ * brought into view first, the content folds under it, and once the grid rows have finished the
278
+ * highlight itself is checked. Under reduced motion nothing animates and the normal scroll runs.
279
+ */
280
+ let foldTarget: string | null = null
281
+
282
+ const groupElement = (groupId: string) => {
283
+ const index = sections.value.findIndex((section) => section.group.id === groupId)
284
+ return listbox.value?.querySelectorAll(':scope > [role="group"]')[index] ?? null
285
+ }
286
+
287
+ const anchorFold = async (groupId: string, targetId: string) => {
288
+ foldTarget = targetId
289
+ await nextTick()
290
+
291
+ const group = groupElement(groupId)
292
+ const heading = group?.querySelector(':scope > [role="presentation"]')
293
+ if (group && heading) scrollIntoRange(group, heading)
294
+ }
295
+
296
+ // The fold wrapper is the only element under a group that animates its grid rows
297
+ const onTransitionEnd = (event: TransitionEvent) => {
298
+ if (!foldTarget || event.propertyName !== 'grid-template-rows') return
299
+ if ((event.target as Element).parentElement?.getAttribute('role') !== 'group') return
300
+
301
+ scrollToItem(foldTarget)
302
+ foldTarget = null
303
+ }
304
+
305
+ // ─── Pointer ──────────────────────────────────────────────────────────────────
306
+
307
+ // Chromium sends a synthetic mouse move after a scroll. Without this guard, the option under a
308
+ // resting cursor would take the highlight from the keyboard after every scrolled arrow press.
309
+ let lastPointer: { x: number; y: number } | null = null
310
+ let pointerMoved = false
311
+
312
+ const onPointerMove = (event: PointerEvent) => {
313
+ pointerMoved =
314
+ lastPointer !== null && (lastPointer.x !== event.clientX || lastPointer.y !== event.clientY)
315
+ lastPointer = { x: event.clientX, y: event.clientY }
316
+ }
317
+
318
+ const onItemPointer = (item: NexxtPickerItemData) => {
319
+ if (pointerMoved) setHighlight(item.id, 'pointer')
320
+ }
321
+
322
+ // ─── Folding ──────────────────────────────────────────────────────────────────
323
+
324
+ const setOpen = (groupId: string, open: boolean) => {
325
+ openState.value = { ...openState.value, [groupId]: open }
326
+ }
327
+
328
+ /**
329
+ * Closes a group. When it holds the highlight, the highlight moves first — to the first item of
330
+ * the next open group, else the last item before it — or the fallback would jump to the top.
331
+ */
332
+ const collapse = (groupId: string, from: HighlightSource) => {
333
+ const index = sections.value.findIndex((section) => section.group.id === groupId)
334
+ const current = itemById(effectiveId.value)
335
+
336
+ if (index >= 0 && current?.group === groupId) {
337
+ const after = sections.value.slice(index + 1).find((section) => section.open)
338
+ const before = sections.value
339
+ .slice(0, index)
340
+ .reverse()
341
+ .find((section) => section.open)
342
+ const target = after?.items[0] ?? before?.items.at(-1) ?? ungrouped.value.at(-1) ?? null
343
+ // Rows above the folding group keep their place, so only a target after it has to wait
344
+ const settles = !!target && target === after?.items[0] && from === 'keyboard' && !reduced.value
345
+
346
+ setHighlight(target?.id ?? null, settles ? 'fold' : from)
347
+ setOpen(groupId, false)
348
+ if (settles && target) anchorFold(groupId, target.id)
349
+ return
350
+ }
351
+
352
+ setOpen(groupId, false)
353
+ }
354
+
355
+ /** Opens a group and highlights its first item. */
356
+ const expand = (section: Section, from: HighlightSource) => {
357
+ setOpen(section.group.id, true)
358
+ const first = section.items[0]
359
+ if (!first) return
360
+
361
+ const settles = from === 'keyboard' && !reduced.value
362
+ setHighlight(first.id, settles ? 'fold' : from)
363
+ if (settles) anchorFold(section.group.id, first.id)
364
+ }
365
+
366
+ const onGroupToggle = (section: Section, open: boolean) => {
367
+ // a click never scrolls, not even one that was still pending from an earlier keyboard fold
368
+ foldTarget = null
369
+ if (open) setOpen(section.group.id, true)
370
+ else collapse(section.group.id, 'pointer')
371
+ }
372
+
373
+ /**
374
+ * The next closed group after the highlighted item's group, else the closest one before it.
375
+ * Without this a folded group could not be reached from the keyboard at all.
376
+ */
377
+ const nextClosedSection = () => {
378
+ const current = itemById(effectiveId.value)
379
+ const index = current?.group
380
+ ? sections.value.findIndex((section) => section.group.id === current.group)
381
+ : -1
382
+
383
+ return (
384
+ sections.value.slice(index + 1).find((section) => !section.open) ??
385
+ sections.value
386
+ .slice(0, Math.max(index, 0))
387
+ .reverse()
388
+ .find((section) => !section.open)
389
+ )
390
+ }
391
+
392
+ // ─── Keyboard ─────────────────────────────────────────────────────────────────
393
+
394
+ /** Moves the highlight by `delta` visible items; stops at either end, never wraps. */
395
+ const move = (delta: number) => {
396
+ const list = visibleItems.value
397
+
398
+ if (list.length === 0) {
399
+ // Every group is folded: ↓ opens the first one
400
+ const closed = canFold.value && delta > 0 ? sections.value.find((s) => !s.open) : undefined
401
+ if (closed) expand(closed, 'keyboard')
402
+ return
403
+ }
404
+
405
+ const index = list.findIndex((item) => item.id === effectiveId.value)
406
+ const next = Math.min(Math.max(index + delta, 0), list.length - 1)
407
+ setHighlight(list[next].id, 'keyboard')
408
+ }
409
+
410
+ /** Highlights the first visible item. */
411
+ const highlightFirst = () => {
412
+ const first = visibleItems.value[0]
413
+ if (first) setHighlight(first.id, 'keyboard')
414
+ }
415
+
416
+ /** Highlights the last visible item. */
417
+ const highlightLast = () => {
418
+ const last = visibleItems.value.at(-1)
419
+ if (last) setHighlight(last.id, 'keyboard')
420
+ }
421
+
422
+ /** Emits `select` for the highlighted item; does nothing when there is none or it is disabled. */
423
+ const selectHighlighted = () => {
424
+ const item = itemById(effectiveId.value)
425
+ if (item && !item.disabled) emit('select', item)
426
+ }
427
+
428
+ let typeahead = ''
429
+ let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
430
+
431
+ const onTypeahead = (key: string) => {
432
+ clearTimeout(typeaheadTimer)
433
+ typeahead += normalize(key)
434
+ typeaheadTimer = setTimeout(() => (typeahead = ''), TYPEAHEAD_RESET_MS)
435
+
436
+ const match = visibleItems.value.find((item) => normalize(item.label).startsWith(typeahead))
437
+ if (match) setHighlight(match.id, 'keyboard')
438
+ }
439
+
440
+ const isPrintable = (event: KeyboardEvent) =>
441
+ [...event.key].length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey
442
+
443
+ const onKeydown = (event: KeyboardEvent) => {
444
+ if (event.isComposing) return
445
+
446
+ switch (event.key) {
447
+ case 'ArrowDown':
448
+ move(1)
449
+ break
450
+ case 'ArrowUp':
451
+ move(-1)
452
+ break
453
+ case 'PageDown':
454
+ move(PAGE_SIZE)
455
+ break
456
+ case 'PageUp':
457
+ move(-PAGE_SIZE)
458
+ break
459
+ case 'Home':
460
+ highlightFirst()
461
+ break
462
+ case 'End':
463
+ highlightLast()
464
+ break
465
+ case 'Enter':
466
+ case ' ':
467
+ selectHighlighted()
468
+ break
469
+ case 'ArrowLeft': {
470
+ const group = itemById(effectiveId.value)?.group
471
+ if (!canFold.value || !group || !groupIds.value.has(group)) return
472
+ collapse(group, 'keyboard')
473
+ break
474
+ }
475
+ case 'ArrowRight': {
476
+ if (!canFold.value) return
477
+ const closed = nextClosedSection()
478
+ if (!closed) return
479
+ expand(closed, 'keyboard')
480
+ break
481
+ }
482
+ case 'Escape':
483
+ // No stopPropagation: a surrounding FloatingPanel closes on the same key
484
+ emit('close')
485
+ break
486
+ default:
487
+ if (!isPrintable(event)) return
488
+ onTypeahead(event.key)
489
+ }
490
+
491
+ event.preventDefault()
492
+ }
493
+
494
+ /** Focuses the listbox without scrolling any ancestor. */
495
+ const focus = () => {
496
+ listbox.value?.focus({ preventScroll: true })
497
+ }
498
+
499
+ // ─── Empty state and announcement ─────────────────────────────────────────────
500
+
501
+ const emptyDescriptionText = computed(() =>
502
+ typeof props.emptyDescription === 'function'
503
+ ? props.emptyDescription(trimmedSearch.value)
504
+ : props.emptyDescription,
505
+ )
506
+
507
+ const clear = () => emit('clear')
508
+
509
+ const announcement = ref('')
510
+ let announceTimer: ReturnType<typeof setTimeout> | undefined
511
+
512
+ // Only after the search changed, debounced, so intermediate states while typing stay silent
513
+ watch(
514
+ () => props.search,
515
+ () => {
516
+ clearTimeout(announceTimer)
517
+ announceTimer = setTimeout(() => {
518
+ announcement.value = props.resultsAnnouncement?.(visibleItems.value.length) ?? ''
519
+ }, ANNOUNCE_DELAY_MS)
520
+ },
521
+ )
522
+
523
+ onBeforeUnmount(() => {
524
+ clearTimeout(announceTimer)
525
+ clearTimeout(typeaheadTimer)
526
+ })
527
+
528
+ const leaveNow = (_el: Element, done: () => void) => done()
529
+
530
+ const itemProps = (item: NexxtPickerItemData) => ({
531
+ id: optionId(item.id),
532
+ label: item.label,
533
+ icon: item.icon,
534
+ description: item.description,
535
+ disabled: item.disabled,
536
+ highlighted: item.id === effectiveId.value,
537
+ })
538
+
539
+ const LISTBOX_CLASSES = {
540
+ base: 'group/listbox min-h-0 overflow-y-auto overscroll-contain scrollbar-thin focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cornflower-blue-500',
541
+ filled: 'flex flex-1 flex-col gap-0.5 p-2',
542
+ }
543
+
544
+ const EMPTY_CLASSES = {
545
+ default: 'flex flex-1 flex-col items-center justify-center gap-1 px-4 py-8 text-center',
546
+ custom: 'flex flex-1 flex-col',
547
+ }
548
+
549
+ const listboxClass = computed(() => [
550
+ LISTBOX_CLASSES.base,
551
+ hasContent.value && LISTBOX_CLASSES.filled,
552
+ ])
553
+
554
+ defineExpose({
555
+ focus,
556
+ move,
557
+ highlightFirst,
558
+ highlightLast,
559
+ selectHighlighted,
560
+ /** DOM id of the listbox, for `aria-controls` on a field that drives it. */
561
+ listboxId,
562
+ /** DOM id of the highlighted option, for `aria-activedescendant` on a field that drives it. */
563
+ activeDescendantId,
564
+ })
565
+ </script>
566
+
567
+ <template>
568
+ <div class="flex min-h-0 flex-col">
569
+ <!-- A tab stop while there is something to walk through; with zero results the tab order
570
+ goes straight to the clear button. It stays in the DOM, so `aria-controls` stays valid. -->
571
+ <div
572
+ :id="listboxId"
573
+ ref="listbox"
574
+ role="listbox"
575
+ :tabindex="hasContent ? 0 : -1"
576
+ :aria-label="ariaLabel"
577
+ :aria-activedescendant="activeDescendantId"
578
+ :class="listboxClass"
579
+ @keydown="onKeydown"
580
+ @focus="scrollToItem(effectiveId)"
581
+ @pointermove.capture="onPointerMove"
582
+ @transitionend="onTransitionEnd"
583
+ >
584
+ <NexxtPickerItem
585
+ v-for="item in ungrouped"
586
+ :key="item.id"
587
+ v-bind="itemProps(item)"
588
+ @select="emit('select', item)"
589
+ @highlight="onItemPointer(item)"
590
+ >
591
+ <template v-if="$slots.item" #default>
592
+ <slot name="item" :item="item" :highlighted="item.id === effectiveId" />
593
+ </template>
594
+ </NexxtPickerItem>
595
+
596
+ <!-- Keyed apart while searching: entering or leaving a search remounts the groups instead of
597
+ changing their `open`, so the fold does not animate. Filtering never animates; only a
598
+ click or ←/→ does. -->
599
+ <NexxtPickerGroup
600
+ v-for="section in sections"
601
+ :key="searching ? `search:${section.group.id}` : section.group.id"
602
+ :label="section.group.label"
603
+ :open="section.open"
604
+ :collapsible="canFold"
605
+ @update:open="onGroupToggle(section, $event)"
606
+ >
607
+ <NexxtPickerItem
608
+ v-for="item in section.items"
609
+ :key="item.id"
610
+ v-bind="itemProps(item)"
611
+ @select="emit('select', item)"
612
+ @highlight="onItemPointer(item)"
613
+ >
614
+ <template v-if="$slots.item" #default>
615
+ <slot name="item" :item="item" :highlighted="item.id === effectiveId" />
616
+ </template>
617
+ </NexxtPickerItem>
618
+ </NexxtPickerGroup>
619
+ </div>
620
+
621
+ <Transition
622
+ :css="!reduced"
623
+ enter-active-class="transition-opacity duration-120 ease-out motion-reduce:transition-none"
624
+ enter-from-class="opacity-0"
625
+ @leave="leaveNow"
626
+ >
627
+ <div v-if="showEmpty" :class="$slots.empty ? EMPTY_CLASSES.custom : EMPTY_CLASSES.default">
628
+ <slot name="empty" :search="trimmedSearch" :clear="clear">
629
+ <NexxtIcon
630
+ name="magnifying-glass"
631
+ aria-hidden="true"
632
+ class="mb-1 text-2xl text-gray-400"
633
+ />
634
+ <p v-if="emptyTitle" class="small-semibold text-gray-900">{{ emptyTitle }}</p>
635
+ <p v-if="emptyDescriptionText" class="extra-small-normal text-gray-700">
636
+ {{ emptyDescriptionText }}
637
+ </p>
638
+ <div v-if="clearLabel" class="mt-3">
639
+ <NexxtButton variant="link" @click="clear">{{ clearLabel }}</NexxtButton>
640
+ </div>
641
+ </slot>
642
+ </div>
643
+ </Transition>
644
+
645
+ <div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
646
+ {{ announcement }}
647
+ </div>
648
+ </div>
649
+ </template>