@nexxtmove/ui 1.32.0 → 1.34.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/atoms/FloatingPanel/FloatingPanel.vue.d.ts +48 -7
- package/dist/components/atoms/PickerItem/PickerItem.vue.d.ts +40 -0
- package/dist/components/atoms/PickerPreview/PickerPreview.vue.d.ts +29 -0
- package/dist/components/editor/molecules/EditorRadius/EditorRadius.vue.d.ts +4 -4
- package/dist/components/molecules/PickerGroup/PickerGroup.vue.d.ts +32 -0
- package/dist/components/molecules/PickerList/PickerList.vue.d.ts +107 -0
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +1 -1
- package/dist/components/organisms/PickerPanel/PickerPanel.vue.d.ts +93 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2368 -1643
- package/dist/nuxt.js +5 -0
- package/package.json +1 -1
- package/src/components/atoms/FloatingPanel/FloatingPanel.vue +280 -20
- package/src/components/atoms/PickerItem/PickerItem.vue +113 -0
- package/src/components/atoms/PickerPreview/PickerPreview.vue +99 -0
- package/src/components/editor/molecules/EditorRadius/EditorRadius.vue +6 -6
- package/src/components/molecules/PickerGroup/PickerGroup.vue +107 -0
- package/src/components/molecules/PickerList/PickerList.vue +649 -0
- package/src/components/organisms/PickerPanel/PickerPanel.vue +267 -0
- package/src/components.json +5 -0
- package/src/index.ts +10 -0
|
@@ -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>
|