@nexxtmove/ui 1.46.0 → 1.48.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 (29) hide show
  1. package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +72 -0
  2. package/dist/components/organisms/IconPicker/IconPicker.vue.d.ts +142 -0
  3. package/dist/components/organisms/IconPicker/_IconPickerGrid.vue.d.ts +40 -0
  4. package/dist/components/organisms/IconPicker/_IconPickerRail.vue.d.ts +22 -0
  5. package/dist/components/organisms/IconPicker/iconPickerHelpers.d.ts +39 -0
  6. package/dist/components/organisms/IconPicker/useIconPickerCatalogue.d.ts +72 -0
  7. package/dist/components/organisms/IconPicker/useIconPickerNavigation.d.ts +60 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +2254 -1386
  10. package/dist/nuxt.js +2 -0
  11. package/package.json +3 -3
  12. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.vue +1 -1
  13. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue +1 -1
  14. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue +1 -1
  15. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue +1 -1
  16. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.vue +3 -3
  17. package/src/components/editor/molecules/EditorShadow/EditorShadow.vue +106 -0
  18. package/src/components/editor/molecules/EditorSize/EditorSize.vue +1 -1
  19. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.vue +1 -1
  20. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +75 -3
  21. package/src/components/organisms/IconPicker/IconPicker.vue +621 -0
  22. package/src/components/organisms/IconPicker/_IconPickerGrid.vue +282 -0
  23. package/src/components/organisms/IconPicker/_IconPickerRail.vue +112 -0
  24. package/src/components/organisms/IconPicker/iconPickerHelpers.ts +111 -0
  25. package/src/components/organisms/IconPicker/useIconPickerCatalogue.ts +238 -0
  26. package/src/components/organisms/IconPicker/useIconPickerNavigation.ts +206 -0
  27. package/src/components.json +2 -0
  28. package/src/index.ts +25 -0
  29. package/tailwind/nexxtmove.tailwind.custom.css +3 -4
@@ -0,0 +1,621 @@
1
+ <script lang="ts">
2
+ /** One icon in the catalogue the consumer hands in. */
3
+ export interface NexxtIconPickerIcon {
4
+ /** Identity: stored on the model and handed to `iconClass`. */
5
+ name: string
6
+ /** Shown in the trigger and the footer, read out as the cell's name, searched. */
7
+ label: string
8
+ /** Extra search words, e.g. `home` for a house. */
9
+ terms: string[]
10
+ /** The ids of the styles the icon exists in; only these are ever drawn or written. */
11
+ styles: string[]
12
+ }
13
+
14
+ /** A category in the rail. */
15
+ export interface NexxtIconPickerCategory {
16
+ /** Identity of the category, unique within `categories`. */
17
+ id: string
18
+ /** Text of the rail item; the rail sorts on it. */
19
+ label: string
20
+ /** Names of the icons in the category; a name the catalogue does not know is skipped. */
21
+ icons: string[]
22
+ }
23
+
24
+ /** The section above "all icons", e.g. the icons already on the page. */
25
+ export interface NexxtIconPickerPinned {
26
+ /** Heading of the section, e.g. `'Op deze pagina'`. */
27
+ label: string
28
+ /** Icon names. The current icon always leads the section; two rows at most. */
29
+ icons: string[]
30
+ }
31
+
32
+ /** A style the icons can be drawn in, with the classes that draw it. */
33
+ export interface NexxtIconPickerStyle {
34
+ /** Identity: what `NexxtIconPickerIcon.styles` lists and the model stores. */
35
+ id: string
36
+ /** Shown behind the icon's label in the trigger, e.g. `'Light'`. */
37
+ label: string
38
+ /** The classes that draw a glyph in this style, next to the class from `iconClass`. */
39
+ classes: readonly string[]
40
+ }
41
+
42
+ /** What the picker reads and writes. */
43
+ export interface NexxtIconPickerValue {
44
+ /** An icon `name`. */
45
+ icon: string
46
+ /** A style `id`. */
47
+ style: string
48
+ }
49
+
50
+ /** Overridable copy. Every key is optional and merged per key with the Dutch defaults. */
51
+ export interface NexxtIconPickerMessages {
52
+ /** In the trigger while there is no value. Defaults to `'Kies een icoon'`. */
53
+ placeholder?: string
54
+ /** Placeholder of the search field. Defaults to `'Zoek in het Engels, bijv. house'`. */
55
+ searchPlaceholder?: string
56
+ /** Accessible name of the search field. Defaults to `'Icoon zoeken'`. */
57
+ searchLabel?: string
58
+ /** Accessible name of the category rail. Defaults to `'Categorieën'`. */
59
+ categoriesLabel?: string
60
+ /** The first rail item, and the heading of the full list under a pinned section. Defaults to `'Alle iconen'`. */
61
+ allIcons?: string
62
+ /** Read out as the description of the current icon. Defaults to `'Huidige keuze'`. */
63
+ current?: string
64
+ /** Heading of the empty search state. Defaults to `'Geen iconen gevonden'`. */
65
+ emptyTitle?: string
66
+ /** Line under the empty heading; receives the trimmed search term. */
67
+ emptyDescription?: (search: string) => string
68
+ /** Button in the empty state that clears the search. Defaults to `'Zoekopdracht wissen'`. */
69
+ clearSearch?: string
70
+ /** Notice under the grid when the catalogue could not be loaded. */
71
+ catalogueError?: string
72
+ /** Button in that notice. Defaults to `'Opnieuw proberen'`. */
73
+ retry?: string
74
+ /** Announced 500ms after a search or a category change; receives the number of icons. */
75
+ results?: (count: number) => string
76
+ }
77
+
78
+ export interface NexxtIconPickerProps {
79
+ /** The catalogue. The component fetches nothing itself: load it on `@open`. */
80
+ icons: NexxtIconPickerIcon[]
81
+ /** The categories of the rail, after "Alle iconen"; sorted on label in `sortLocale`. */
82
+ categories: NexxtIconPickerCategory[]
83
+ /**
84
+ * A section above "Alle iconen" while there is no search and no category, e.g. the icons
85
+ * already on the page. The current icon always leads it; two rows at most. Not shown when it
86
+ * would be empty.
87
+ */
88
+ pinned?: NexxtIconPickerPinned
89
+ /**
90
+ * The styles, in the order of preference: a cell shows its icon in the current style when the
91
+ * icon has it, otherwise in the first of these it has, and choosing it writes that style.
92
+ */
93
+ styles: NexxtIconPickerStyle[]
94
+ /**
95
+ * State of fetching the catalogue. `loading` shows skeletons while `icons` is empty; `error`
96
+ * shows a notice with a retry button. Without a catalogue the current icon stays the one
97
+ * choosable cell; a search typed meanwhile applies once the catalogue is in.
98
+ */
99
+ catalogueStatus?: 'loading' | 'ready' | 'error'
100
+ /**
101
+ * The class that picks an icon's glyph, next to the classes of its style. The component knows no
102
+ * icon set: for Font Awesome it returns the class of the named glyph.
103
+ */
104
+ iconClass: (name: string) => string
105
+ /**
106
+ * Visible label above the trigger, for a form. Give this or `ariaLabel`: without either the
107
+ * trigger has no name for the field, and a development build warns in the console.
108
+ */
109
+ label?: string
110
+ /**
111
+ * Accessible name when there is no visible label, as in the editor. The trigger reads it
112
+ * followed by the icon and its style: "Symbool, Star, Light". Give this or `label`.
113
+ */
114
+ ariaLabel?: string
115
+ /**
116
+ * Copy of the trigger and the panel. Merged per key with the Dutch defaults, so overriding one
117
+ * key keeps the others. Copy is never translated inside the component.
118
+ */
119
+ messages?: NexxtIconPickerMessages
120
+ /**
121
+ * Where the panel opens. `left` is for a picker in a side panel: the panel opens beside it, top
122
+ * aligned, and below it (right aligned) when the room on the left is less than the panel plus
123
+ * an 8px viewport margin.
124
+ */
125
+ side?: 'bottom' | 'left'
126
+ /** Distance between trigger and panel when it opens to the left, e.g. to clear a panel edge. */
127
+ sideOffset?: number
128
+ /** Locale the rail and the grid sort their labels in. */
129
+ sortLocale?: string
130
+ }
131
+
132
+ const DEFAULT_MESSAGES = {
133
+ placeholder: 'Kies een icoon',
134
+ searchPlaceholder: 'Zoek in het Engels, bijv. house',
135
+ searchLabel: 'Icoon zoeken',
136
+ categoriesLabel: 'Categorieën',
137
+ allIcons: 'Alle iconen',
138
+ current: 'Huidige keuze',
139
+ emptyTitle: 'Geen iconen gevonden',
140
+ emptyDescription: (search: string) =>
141
+ `Niets voor ‘${search}’. Zoeken werkt alleen met Engelse woorden.`,
142
+ clearSearch: 'Zoekopdracht wissen',
143
+ catalogueError: 'De iconen konden niet worden geladen. Je huidige icoon blijft kiesbaar.',
144
+ retry: 'Opnieuw proberen',
145
+ results: (count: number) => (count === 1 ? '1 icoon' : `${count} iconen`),
146
+ } satisfies Required<NexxtIconPickerMessages>
147
+
148
+ /** The rail's 192px plus a grid that gives six cells of about 51px. */
149
+ const PANEL_WIDTH = 576
150
+ const PANEL_HEIGHT = 520
151
+ const VIEWPORT_MARGIN = 8
152
+ /** The surface's 1px border, both sides. */
153
+ const SURFACE_BORDER = 2
154
+ /** Below the trigger, as when there is no room beside it. */
155
+ const BOTTOM_OFFSET = 8
156
+
157
+ /** No-break spaces: the label and the style are separate flex items, a plain space would collapse. */
158
+ const DETAIL_PREFIX = ' · '
159
+
160
+ const TRIGGER_CLASSES =
161
+ 'group flex h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg bg-white px-3 text-left font-sans ring ring-gray-200 transition-colors ease-out focus:ring-cornflower-blue-500 focus:outline-none motion-reduce:transition-none'
162
+
163
+ const CHEVRON_CLASSES = {
164
+ base: 'shrink-0 text-gray-700 transition-transform duration-200 motion-reduce:transition-none',
165
+ open: 'rotate-180',
166
+ }
167
+ </script>
168
+
169
+ <script lang="ts" setup>
170
+ import { computed, ref, useId, useTemplateRef, watch, watchEffect } from 'vue'
171
+ import { useElementBounding, useEventListener } from '@vueuse/core'
172
+ import NexxtButton from '../../atoms/Button/Button.vue'
173
+ import NexxtFloatingPanel from '../../atoms/FloatingPanel/FloatingPanel.vue'
174
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
175
+ import NexxtInputField from '../../molecules/InputField/InputField.vue'
176
+ import NexxtIconPickerGrid from './_IconPickerGrid.vue'
177
+ import NexxtIconPickerRail from './_IconPickerRail.vue'
178
+ import { DEFAULT_SORT_LOCALE } from './iconPickerHelpers'
179
+ import { useIconPickerCatalogue } from './useIconPickerCatalogue'
180
+ import { useIconPickerNavigation } from './useIconPickerNavigation'
181
+
182
+ defineOptions({ name: 'NexxtIconPicker' })
183
+
184
+ const {
185
+ icons,
186
+ categories,
187
+ pinned,
188
+ styles,
189
+ catalogueStatus = 'ready',
190
+ iconClass,
191
+ label,
192
+ ariaLabel,
193
+ messages,
194
+ side = 'bottom',
195
+ sideOffset = 8,
196
+ sortLocale = DEFAULT_SORT_LOCALE,
197
+ } = defineProps<NexxtIconPickerProps>()
198
+
199
+ /** The chosen icon and style. Only a choice writes the model; moving the highlight never does. */
200
+ const model = defineModel<NexxtIconPickerValue | null>({ default: null })
201
+
202
+ const emit = defineEmits<{
203
+ /** The panel opens: the moment to start fetching the catalogue, lazily. */
204
+ open: []
205
+ /** The panel closed, by a choice, Escape, a click outside or focus leaving it. */
206
+ close: []
207
+ /** "Opnieuw proberen" in the error state. Fetch again and set `catalogueStatus` to `loading`. */
208
+ retry: []
209
+ }>()
210
+
211
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
212
+
213
+ const baseId = useId()
214
+ const triggerId = `${baseId}-trigger`
215
+ const labelId = `${baseId}-label`
216
+ const valueId = `${baseId}-value`
217
+ const gridId = `${baseId}-grid`
218
+ const panelId = `${baseId}-panel`
219
+ const currentDescriptionId = `${baseId}-current`
220
+ const cellIdPrefix = `${baseId}-cell`
221
+
222
+ const root = useTemplateRef<HTMLElement>('root')
223
+ const trigger = useTemplateRef<HTMLButtonElement>('trigger')
224
+ const floating = useTemplateRef<InstanceType<typeof NexxtFloatingPanel>>('floating')
225
+ const searchField = useTemplateRef<InstanceType<typeof NexxtInputField>>('searchField')
226
+ const grid = useTemplateRef<InstanceType<typeof NexxtIconPickerGrid>>('grid')
227
+
228
+ const isOpen = ref(false)
229
+ const search = ref('')
230
+ /** The category on show; `null` is "all icons". */
231
+ const category = ref<string | null>(null)
232
+
233
+ const term = computed(() => search.value.trim())
234
+
235
+ // ─── Catalogue ────────────────────────────────────────────────────────────────
236
+
237
+ const {
238
+ shownCategories,
239
+ activeCategory,
240
+ catalogueEmpty,
241
+ currentIcon,
242
+ currentStyle,
243
+ rows,
244
+ iconRows,
245
+ iconCount,
246
+ currentPosition,
247
+ rowLength,
248
+ iconAt,
249
+ styleOf,
250
+ glyphClass,
251
+ } = useIconPickerCatalogue({
252
+ icons: () => icons,
253
+ categories: () => categories,
254
+ pinned: () => pinned,
255
+ styles: () => styles,
256
+ iconClass: () => iconClass,
257
+ sortLocale: () => sortLocale,
258
+ allIconsLabel: () => copy.value.allIcons,
259
+ model,
260
+ term,
261
+ category,
262
+ })
263
+
264
+ const loading = computed(() => catalogueStatus === 'loading')
265
+ const failed = computed(() => catalogueStatus === 'error')
266
+ const showSkeletons = computed(() => loading.value && catalogueEmpty.value)
267
+
268
+ const showEmpty = computed(
269
+ () => !catalogueEmpty.value && term.value !== '' && iconRows.value.length === 0,
270
+ )
271
+ /** Error without a current icon leaves nothing to show: no grid, and nothing for the field to control. */
272
+ const showGrid = computed(() => !showSkeletons.value && iconRows.value.length > 0)
273
+ const hasGrid = computed(() => showSkeletons.value || showGrid.value)
274
+
275
+ // ─── Rail ─────────────────────────────────────────────────────────────────────
276
+
277
+ const tabs = computed(() => [
278
+ { id: null, label: copy.value.allIcons },
279
+ ...shownCategories.value.map(({ id, label: text }) => ({ id, label: text })),
280
+ ])
281
+
282
+ const tabId = (index: number) => `${baseId}-tab-${index}`
283
+
284
+ const activeTabIndex = computed(() =>
285
+ Math.max(
286
+ 0,
287
+ tabs.value.findIndex((tab) => tab.id === (activeCategory.value?.id ?? null)),
288
+ ),
289
+ )
290
+
291
+ const activeTabId = computed(() => tabId(activeTabIndex.value))
292
+
293
+ // ─── Trigger ──────────────────────────────────────────────────────────────────
294
+
295
+ const triggerGlyph = computed(() =>
296
+ currentIcon.value ? glyphClass(currentIcon.value, model.value?.style) : null,
297
+ )
298
+
299
+ const triggerText = computed(() => currentIcon.value?.label ?? copy.value.placeholder)
300
+
301
+ // "Symbool, Star, Light": the name, then what is on show, as the trigger reads it. A visible
302
+ // label wins, so the name matches what a sighted user reads (WCAG 2.5.3)
303
+ const triggerAriaLabel = computed(() => {
304
+ if (label || !ariaLabel) return undefined
305
+ return [ariaLabel, triggerText.value, currentStyle.value?.label].filter(Boolean).join(', ')
306
+ })
307
+
308
+ const triggerLabelledby = computed(() => (label ? `${labelId} ${valueId}` : undefined))
309
+
310
+ const panelLabel = computed(() => label ?? ariaLabel)
311
+
312
+ const valueClass = computed(() => [
313
+ 'flex min-w-0 flex-1 items-center small-normal',
314
+ currentIcon.value ? 'text-gray-900' : 'text-gray-700',
315
+ ])
316
+
317
+ const chevronClass = computed(() => [CHEVRON_CLASSES.base, isOpen.value && CHEVRON_CLASSES.open])
318
+
319
+ // A trigger without either has no name for the field. `process.env.NODE_ENV` stays in the library
320
+ // build, so the consumer's bundler decides whether this runs (as in NexxtFontPicker)
321
+ if (process.env.NODE_ENV !== 'production') {
322
+ watchEffect(() => {
323
+ if (!label && !ariaLabel) {
324
+ console.warn('[NexxtIconPicker] Pass `label` or `ariaLabel`: the trigger has no name.')
325
+ }
326
+ })
327
+ }
328
+
329
+ // ─── Placement ────────────────────────────────────────────────────────────────
330
+
331
+ const { left: triggerLeft } = useElementBounding(trigger)
332
+
333
+ /**
334
+ * Decided here rather than by FloatingPanel's `flip()`, which would flip `left` to the right of a
335
+ * side panel: beside the trigger where the panel fits with its viewport margin, below it otherwise.
336
+ */
337
+ const placement = computed(() => {
338
+ if (side !== 'left') return 'bottom-start'
339
+ return triggerLeft.value - sideOffset >= PANEL_WIDTH + VIEWPORT_MARGIN
340
+ ? 'left-start'
341
+ : 'bottom-end'
342
+ })
343
+
344
+ const offsetDistance = computed(() =>
345
+ placement.value === 'left-start' ? sideOffset : BOTTOM_OFFSET,
346
+ )
347
+
348
+ // The surface's border is drawn around the content, so the content is that much smaller
349
+ const panelStyle = (availableHeight: number | null) => ({
350
+ width: `min(${PANEL_WIDTH - SURFACE_BORDER}px, calc(100vw - ${2 * VIEWPORT_MARGIN + SURFACE_BORDER}px))`,
351
+ height: `min(${PANEL_HEIGHT - SURFACE_BORDER}px, ${availableHeight ?? PANEL_HEIGHT}px)`,
352
+ })
353
+
354
+ // ─── Panel ────────────────────────────────────────────────────────────────────
355
+
356
+ const focusSearch = () => searchField.value?.focus()
357
+
358
+ // Focus goes back to the trigger first, before the element that has it leaves the DOM
359
+ const closeToTrigger = () => {
360
+ trigger.value?.focus()
361
+ floating.value?.close()
362
+ }
363
+
364
+ const choose = (icon: NexxtIconPickerIcon) => {
365
+ const style = styleOf(icon)
366
+ if (style && (icon.name !== model.value?.icon || style !== model.value?.style)) {
367
+ model.value = { icon: icon.name, style }
368
+ }
369
+ closeToTrigger()
370
+ }
371
+
372
+ // Typing searches the whole catalogue
373
+ watch(term, (value) => {
374
+ if (value) category.value = null
375
+ })
376
+
377
+ const navigation = useIconPickerNavigation({
378
+ iconRows,
379
+ currentPosition,
380
+ iconCount,
381
+ catalogueEmpty,
382
+ iconAt,
383
+ rowLength,
384
+ search,
385
+ term,
386
+ category,
387
+ isOpen,
388
+ grid: () => grid.value,
389
+ focusSearch,
390
+ choose,
391
+ results: () => copy.value.results,
392
+ })
393
+
394
+ const { highlight, highlightedIcon, gridFocused, announcement } = navigation
395
+
396
+ const onOpen = () => {
397
+ navigation.open(() => {
398
+ isOpen.value = true
399
+ search.value = ''
400
+ category.value = null
401
+ })
402
+ emit('open')
403
+ }
404
+
405
+ const onClose = () => {
406
+ isOpen.value = false
407
+ navigation.close()
408
+ emit('close')
409
+ }
410
+
411
+ const onTriggerKeydown = (event: KeyboardEvent) => {
412
+ if (event.key !== 'ArrowDown' || isOpen.value) return
413
+ event.preventDefault()
414
+ floating.value?.open()
415
+ }
416
+
417
+ /**
418
+ * Tab from the grid (the last stop) closes the panel and focus goes on to the control after the
419
+ * trigger, as if the panel were not there: it is no dialog, so there is no focus trap. The focus
420
+ * guards of the teleported panel sit inside `floatingRef`, so Tab off the last stop lands on one
421
+ * first (no close) and the step after it closes. A `null` target is a press on something that
422
+ * takes no focus: FloatingPanel's own outside click handles that.
423
+ */
424
+ const onFocusOut = (event: FocusEvent) => {
425
+ const next = event.relatedTarget
426
+ if (!(next instanceof Node)) return
427
+ if (root.value?.contains(next) || floating.value?.floatingRef?.contains(next)) return
428
+ floating.value?.close()
429
+ }
430
+
431
+ useEventListener(() => (isOpen.value ? floating.value?.floatingRef : null), 'focusout', onFocusOut)
432
+
433
+ const clearSearch = () => {
434
+ search.value = ''
435
+ focusSearch()
436
+ }
437
+
438
+ const selectCategory = (id: string | null) => {
439
+ search.value = ''
440
+ category.value = id
441
+ }
442
+
443
+ // The panel's content mounts a tick after it opens
444
+ watch(searchField, (field) => field?.focus())
445
+
446
+ // The chosen category emptied (a reloaded catalogue, categories that changed): back to "all icons",
447
+ // so the state matches the rail and no empty tab panel is left
448
+ watch(activeCategory, (active) => {
449
+ if (!active && category.value !== null) category.value = null
450
+ })
451
+ </script>
452
+
453
+ <template>
454
+ <div ref="root" class="flex w-full min-w-0 flex-col" @focusout="onFocusOut">
455
+ <label v-if="label" :id="labelId" :for="triggerId" class="mb-1 small-normal text-gray-700">
456
+ {{ label }}
457
+ </label>
458
+
459
+ <!-- Teleported: a side panel scrolls, and would clip a panel inside it -->
460
+ <NexxtFloatingPanel
461
+ ref="floating"
462
+ class="w-full"
463
+ :placement="placement"
464
+ :offset-distance="offsetDistance"
465
+ surface
466
+ fit-height
467
+ teleport
468
+ @open="onOpen"
469
+ @close="onClose"
470
+ >
471
+ <template #trigger="{ toggle }">
472
+ <!--
473
+ `grid`, not `dialog`: the panel is no dialog, and what is picked from is the grid. The
474
+ accessible name is the field, the icon and its style: "Symbool, Star, Light".
475
+ -->
476
+ <button
477
+ :id="triggerId"
478
+ ref="trigger"
479
+ type="button"
480
+ aria-haspopup="grid"
481
+ :aria-expanded="isOpen"
482
+ :aria-label="triggerAriaLabel"
483
+ :aria-labelledby="triggerLabelledby"
484
+ :class="TRIGGER_CLASSES"
485
+ @click="toggle"
486
+ @keydown="onTriggerKeydown"
487
+ >
488
+ <span
489
+ class="flex size-7 shrink-0 items-center justify-center rounded-md bg-gray-50"
490
+ aria-hidden="true"
491
+ >
492
+ <i v-if="triggerGlyph" :class="[triggerGlyph, 'text-base text-gray-900']" />
493
+ </span>
494
+
495
+ <span :id="valueId" :class="valueClass">
496
+ <span class="min-w-0 truncate">{{ triggerText }}</span>
497
+ <span v-if="currentStyle" class="shrink-0 text-gray-700">
498
+ {{ DETAIL_PREFIX }}{{ currentStyle.label }}
499
+ </span>
500
+ </span>
501
+
502
+ <NexxtIcon name="chevron-down" aria-hidden="true" :class="chevronClass" />
503
+ </button>
504
+ </template>
505
+
506
+ <template #content="{ availableHeight }">
507
+ <div
508
+ role="group"
509
+ :aria-label="panelLabel"
510
+ class="flex flex-col"
511
+ :style="panelStyle(availableHeight)"
512
+ >
513
+ <div class="shrink-0 border-b border-gray-200 p-3">
514
+ <NexxtInputField
515
+ ref="searchField"
516
+ v-model="search"
517
+ class="w-full"
518
+ left-icon="magnifying-glass"
519
+ autocomplete="off"
520
+ :placeholder="copy.searchPlaceholder"
521
+ :aria-label="copy.searchLabel"
522
+ :aria-controls="hasGrid ? gridId : undefined"
523
+ @keydown="navigation.onSearchKeydown"
524
+ />
525
+ </div>
526
+
527
+ <div class="flex min-h-0 flex-1">
528
+ <NexxtIconPickerRail
529
+ :tabs="tabs"
530
+ :active-index="activeTabIndex"
531
+ :label="copy.categoriesLabel"
532
+ :panel-id="panelId"
533
+ :tab-id="tabId"
534
+ :loading="showSkeletons"
535
+ @select="selectCategory"
536
+ />
537
+
538
+ <div
539
+ :id="panelId"
540
+ role="tabpanel"
541
+ :aria-labelledby="activeTabId"
542
+ class="flex min-h-0 min-w-0 flex-1 flex-col"
543
+ >
544
+ <div
545
+ v-if="showEmpty"
546
+ class="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 p-6 text-center"
547
+ >
548
+ <NexxtIcon
549
+ name="magnifying-glass"
550
+ aria-hidden="true"
551
+ class="mb-2 text-4xl text-gray-400"
552
+ />
553
+ <p class="small-semibold text-gray-900">{{ copy.emptyTitle }}</p>
554
+ <p class="small-normal text-gray-700">{{ copy.emptyDescription(term) }}</p>
555
+ <NexxtButton variant="link" @click="clearSearch">{{
556
+ copy.clearSearch
557
+ }}</NexxtButton>
558
+ </div>
559
+
560
+ <NexxtIconPickerGrid
561
+ v-else-if="hasGrid"
562
+ ref="grid"
563
+ :rows="rows"
564
+ :skeleton="showSkeletons"
565
+ :grid-id="gridId"
566
+ :labelledby="activeTabId"
567
+ :cell-id-prefix="cellIdPrefix"
568
+ :current-description-id="currentDescriptionId"
569
+ :highlight="highlight"
570
+ :focused="gridFocused"
571
+ :selected="currentPosition"
572
+ :glyph-class="glyphClass"
573
+ @focus="navigation.onGridFocus"
574
+ @blur="navigation.onGridBlur"
575
+ @keydown="navigation.onGridKeydown"
576
+ @hover="navigation.onCellHover"
577
+ @choose="choose"
578
+ />
579
+
580
+ <!-- Outside the grid, so it is no cell; after it in the tab order -->
581
+ <div
582
+ v-if="failed"
583
+ role="status"
584
+ class="flex shrink-0 items-start gap-2 border-t border-gray-200 px-3 py-2.5"
585
+ >
586
+ <NexxtIcon
587
+ name="circle-exclamation"
588
+ aria-hidden="true"
589
+ class="mt-0.5 shrink-0 text-gray-700"
590
+ />
591
+ <!-- NexxtButton is a 40px flex box, so it gets a line of its own under the text -->
592
+ <div class="flex min-w-0 flex-col items-start">
593
+ <p class="extra-small-normal text-gray-800">{{ copy.catalogueError }}</p>
594
+ <NexxtButton variant="link" @click="emit('retry')">{{ copy.retry }}</NexxtButton>
595
+ </div>
596
+ </div>
597
+ </div>
598
+ </div>
599
+
600
+ <!-- What the highlight is on, large; screen readers hear it on the cell itself -->
601
+ <div
602
+ class="flex h-11 shrink-0 items-center gap-3 border-t border-gray-200 px-3"
603
+ aria-hidden="true"
604
+ >
605
+ <template v-if="highlightedIcon">
606
+ <i :class="[glyphClass(highlightedIcon), 'text-2xl text-gray-900']" />
607
+ <span class="truncate small-semibold text-gray-900">{{ highlightedIcon.label }}</span>
608
+ <span class="truncate extra-small-normal-mono text-gray-700">{{
609
+ highlightedIcon.name
610
+ }}</span>
611
+ </template>
612
+ </div>
613
+
614
+ <span :id="currentDescriptionId" hidden>{{ copy.current }}</span>
615
+
616
+ <div class="sr-only" aria-live="polite">{{ announcement }}</div>
617
+ </div>
618
+ </template>
619
+ </NexxtFloatingPanel>
620
+ </div>
621
+ </template>