@nexxtmove/ui 1.47.0 → 1.49.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/molecules/Tabs/Tabs.vue.d.ts +12 -0
- package/dist/components/organisms/IconPicker/IconPicker.vue.d.ts +142 -0
- package/dist/components/organisms/IconPicker/_IconPickerGrid.vue.d.ts +40 -0
- package/dist/components/organisms/IconPicker/_IconPickerRail.vue.d.ts +22 -0
- package/dist/components/organisms/IconPicker/iconPickerHelpers.d.ts +39 -0
- package/dist/components/organisms/IconPicker/useIconPickerCatalogue.d.ts +72 -0
- package/dist/components/organisms/IconPicker/useIconPickerNavigation.d.ts +60 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2152 -1400
- package/dist/nuxt.js +1 -0
- package/package.json +1 -1
- package/src/components/molecules/Tabs/Tabs.vue +76 -13
- package/src/components/organisms/IconPicker/IconPicker.vue +621 -0
- package/src/components/organisms/IconPicker/_IconPickerGrid.vue +282 -0
- package/src/components/organisms/IconPicker/_IconPickerRail.vue +112 -0
- package/src/components/organisms/IconPicker/iconPickerHelpers.ts +111 -0
- package/src/components/organisms/IconPicker/useIconPickerCatalogue.ts +238 -0
- package/src/components/organisms/IconPicker/useIconPickerNavigation.ts +206 -0
- package/src/components.json +1 -0
- package/src/index.ts +18 -0
|
@@ -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>
|