@nexxtmove/ui 1.40.0 → 1.41.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,576 @@
1
+ <script lang="ts">
2
+ import type { NexxtPickerItemData } from '../../molecules/PickerList/PickerList.vue'
3
+
4
+ /** One family in the catalogue the consumer hands in. */
5
+ export interface NexxtFontPickerFont {
6
+ /** The family name: shown, searched, stored on the model and used as `font-family`. */
7
+ family: string
8
+ /**
9
+ * Decides the group and with it the order: `web-safe` is "Standaard", listed first, `google` is
10
+ * "Google Fonts" after it; within a group `fonts` keeps its own order. Deliberately this narrow:
11
+ * this is a font picker, not a generic picker with free-form groups.
12
+ */
13
+ source: 'web-safe' | 'google'
14
+ }
15
+
16
+ /** The "Huisstijl" option at the top of the list. */
17
+ export interface NexxtFontPickerInherit {
18
+ /** Text of the option, e.g. `'Huisstijl'`. */
19
+ label: string
20
+ /**
21
+ * The family the option points at, shown behind the label in that font. Empty: the option
22
+ * renders in `font-sans` and shows no family name.
23
+ */
24
+ family?: string
25
+ }
26
+
27
+ /** Overridable copy. Every key is optional and merged per key with the Dutch defaults. */
28
+ export interface NexxtFontPickerMessages {
29
+ /** Placeholder of the search field. Defaults to `'Lettertype zoeken'`. */
30
+ searchPlaceholder?: string
31
+ /** Accessible name of the search field. Defaults to `'Lettertype zoeken'`. */
32
+ searchLabel?: string
33
+ /** Accessible name of the listbox. Defaults to `'Lettertypes'`. */
34
+ listLabel?: string
35
+ /** Heading of the web-safe group. Defaults to `'Standaard'`. */
36
+ groupWebSafe?: string
37
+ /** Heading of the Google group. Defaults to `'Google Fonts'`. */
38
+ groupGoogle?: string
39
+ /** Description read out on the current choice. Defaults to `'Huidige keuze'`. */
40
+ current?: string
41
+ /** Description read out on the inherit option. Defaults to `'Volgt de huisstijl, ook als die later verandert'`. */
42
+ inheritDescription?: string
43
+ /** Heading of the empty search state. Defaults to `'Geen lettertypes gevonden'`. */
44
+ emptyTitle?: string
45
+ /** Line under the empty heading; receives the trimmed search term. */
46
+ emptyDescription?: (search: string) => string
47
+ /** Button in the empty state that clears the search. Defaults to `'Zoekopdracht wissen'`. */
48
+ clearSearch?: string
49
+ /** Notice under the list when the catalogue could not be loaded. */
50
+ catalogueError?: string
51
+ /** Button in that notice. Defaults to `'Opnieuw proberen'`. */
52
+ retry?: string
53
+ /** Announced 500ms after the search changes; receives the number of results. */
54
+ results?: (count: number) => string
55
+ }
56
+
57
+ export interface NexxtFontPickerProps {
58
+ /**
59
+ * The catalogue: web-safe and Google families together, in display order (web-safe in their
60
+ * own order, Google by popularity). The component fetches nothing itself.
61
+ */
62
+ fonts: NexxtFontPickerFont[]
63
+ /**
64
+ * State of fetching the Google catalogue. It only adds to the list: `loading` marks the listbox
65
+ * busy and, while `fonts` holds no Google family yet, shows skeleton rows under "Google Fonts";
66
+ * `error` shows a notice with a retry button. Whatever is in `fonts` is listed in every state,
67
+ * so a refresh or a failed retry never takes a catalogue away that is already there.
68
+ */
69
+ catalogueStatus?: 'loading' | 'ready' | 'error'
70
+ /**
71
+ * Loads the face for the preview. Called for rows that come within 150px of view (debounced
72
+ * 100ms after scrolling) and for the current value on mount. A family that loaded is never
73
+ * asked for again. After a failure (a rejection or a throw) the component asks again once the
74
+ * row has been out of view and comes back, or when the panel opens again; typing a search or a
75
+ * new loader function does not, so a loader that keeps failing is not called per keystroke.
76
+ * The loader needs no cache or retry of its own; meanwhile the row keeps its fallback font and
77
+ * stays choosable.
78
+ */
79
+ loadFont?: (family: string) => Promise<void> | void
80
+ /** Show the inherit option at the top of the list; `null` on the model is then that option. */
81
+ inherit?: NexxtFontPickerInherit
82
+ /**
83
+ * Visible label above the trigger, for a form. Give this or `ariaLabel`: without either the
84
+ * trigger has no name for the field, and a development build warns in the console.
85
+ */
86
+ label?: string
87
+ /**
88
+ * Accessible name when there is no visible label, as in the editor, where the settings block
89
+ * around the picker is the label. The trigger reads it followed by its value. Give this or
90
+ * `label`: without either a development build warns in the console.
91
+ */
92
+ ariaLabel?: string
93
+ /** Text in the trigger while nothing is chosen. Defaults to `'Kies een lettertype'`. */
94
+ placeholder?: string
95
+ /** The trigger is dimmed and does not open. */
96
+ disabled?: boolean
97
+ /**
98
+ * Copy of the panel. One object on purpose, following the editor components
99
+ * (`NexxtEditorLineHeight` and the like) rather than the separate copy props of
100
+ * `NexxtPickerPanel`. Merged per key with the Dutch defaults, so overriding one key keeps the
101
+ * others. Copy is never translated inside the component.
102
+ */
103
+ messages?: NexxtFontPickerMessages
104
+ }
105
+
106
+ const DEFAULT_MESSAGES = {
107
+ searchPlaceholder: 'Lettertype zoeken',
108
+ searchLabel: 'Lettertype zoeken',
109
+ listLabel: 'Lettertypes',
110
+ groupWebSafe: 'Standaard',
111
+ groupGoogle: 'Google Fonts',
112
+ current: 'Huidige keuze',
113
+ inheritDescription: 'Volgt de huisstijl, ook als die later verandert',
114
+ emptyTitle: 'Geen lettertypes gevonden',
115
+ emptyDescription: (search: string) => `Niets gevonden voor ‘${search}’.`,
116
+ clearSearch: 'Zoekopdracht wissen',
117
+ catalogueError:
118
+ 'Google Fonts konden niet worden geladen. Je kunt kiezen uit de standaardlettertypes.',
119
+ retry: 'Opnieuw proberen',
120
+ results: (count: number) => `${count} lettertypes`,
121
+ } satisfies Required<NexxtFontPickerMessages>
122
+
123
+ /** A list row with what the template needs beyond the picker item itself. */
124
+ interface FontRow {
125
+ item: NexxtPickerItemData
126
+ /** Family the row renders in; none renders in `font-sans`. */
127
+ family?: string
128
+ /** Family named behind the label, for the inherit option. */
129
+ detail?: string
130
+ /** What choosing the row writes to the model. */
131
+ value: string | null
132
+ }
133
+
134
+ // Fonts are prefixed so no family name can ever collide with the inherit option's id
135
+ const INHERIT_ID = 'inherit'
136
+ const fontId = (family: string) => `font:${family}`
137
+
138
+ const GROUP_WEB_SAFE = 'web-safe'
139
+ const GROUP_GOOGLE = 'google'
140
+
141
+ const PANEL_MIN_WIDTH = 320
142
+ const PANEL_HEIGHT = 360
143
+
144
+ const SKELETON_ROWS = [60, 45, 70, 50, 65, 40].map((width) => ({ width: `${width}%` }))
145
+
146
+ /** Between "Huisstijl" and its family, in the trigger's accessible name. */
147
+ const SEPARATOR = ' · '
148
+ /**
149
+ * The same separator on screen. No-break spaces: the label and the family are separate flex
150
+ * items, and a plain space at the start of the second one would collapse away.
151
+ */
152
+ const DETAIL_PREFIX = '\u00a0·\u00a0'
153
+
154
+ /** `font-family` for a preview: the family first, `font-sans` while it loads or if it never does. */
155
+ const fontStyle = (family?: string) =>
156
+ family ? { fontFamily: `'${family.replace(/['\\]/g, '')}', var(--font-sans)` } : undefined
157
+ </script>
158
+
159
+ <script lang="ts" setup>
160
+ import { computed, ref, useId, useTemplateRef, watchEffect } from 'vue'
161
+ import { useElementSize } from '@vueuse/core'
162
+ import NexxtButton from '../../atoms/Button/Button.vue'
163
+ import NexxtFloatingPanel from '../../atoms/FloatingPanel/FloatingPanel.vue'
164
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
165
+ import NexxtSkeleton from '../../atoms/Skeleton/Skeleton.vue'
166
+ import NexxtPickerGroup from '../../molecules/PickerGroup/PickerGroup.vue'
167
+ import NexxtPickerPanel from '../PickerPanel/PickerPanel.vue'
168
+ import { useFontPreviewLoader } from './useFontPreviewLoader'
169
+
170
+ defineOptions({ name: 'NexxtFontPicker' })
171
+
172
+ const {
173
+ fonts,
174
+ catalogueStatus = 'ready',
175
+ loadFont,
176
+ inherit,
177
+ label,
178
+ ariaLabel,
179
+ placeholder = 'Kies een lettertype',
180
+ disabled = false,
181
+ messages,
182
+ } = defineProps<NexxtFontPickerProps>()
183
+
184
+ /**
185
+ * The chosen family. `null` is the inherit option when `inherit` is given, and "nothing chosen"
186
+ * otherwise. `''` means the same, and is accepted only for existing stored values; the component
187
+ * itself always writes `null`. Only a choice writes the model; moving the highlight never does.
188
+ */
189
+ const model = defineModel<string | null>({ default: null })
190
+
191
+ const emit = defineEmits<{
192
+ /** The panel opens: the moment to start fetching the catalogue, lazily. */
193
+ open: []
194
+ /** The panel closed, by a choice, Escape, a click outside or focus leaving it. */
195
+ close: []
196
+ /** "Opnieuw proberen" in the error state. Fetch again and set `catalogueStatus` to `loading`. */
197
+ retry: []
198
+ }>()
199
+
200
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
201
+
202
+ const baseId = useId()
203
+ const triggerId = `${baseId}-trigger`
204
+ const labelId = `${baseId}-label`
205
+ const valueId = `${baseId}-value`
206
+
207
+ const root = useTemplateRef<HTMLElement>('root')
208
+ const trigger = useTemplateRef<HTMLButtonElement>('trigger')
209
+ const floating = useTemplateRef<InstanceType<typeof NexxtFloatingPanel>>('floating')
210
+ const panelRoot = useTemplateRef<HTMLElement>('panelRoot')
211
+
212
+ const highlighted = ref<string | null>(null)
213
+ const search = ref('')
214
+
215
+ // ─── Value ────────────────────────────────────────────────────────────────────
216
+
217
+ const chosenFamily = computed(() => model.value || null)
218
+
219
+ const currentId = computed(() => {
220
+ if (chosenFamily.value) return fontId(chosenFamily.value)
221
+ return inherit ? INHERIT_ID : null
222
+ })
223
+
224
+ /** What the trigger shows: the primary text, an optional family behind it, and its font. */
225
+ const display = computed(() => {
226
+ if (chosenFamily.value) {
227
+ return { primary: chosenFamily.value, family: chosenFamily.value, placeholder: false }
228
+ }
229
+ if (inherit) {
230
+ const family = inherit.family || undefined
231
+ return { primary: inherit.label, detail: family, family, placeholder: false }
232
+ }
233
+ return { primary: placeholder, placeholder: true }
234
+ })
235
+
236
+ const displayText = computed(() =>
237
+ [display.value.primary, display.value.detail].filter(Boolean).join(SEPARATOR),
238
+ )
239
+
240
+ // Label and value together, so the visible text is part of the name (WCAG 2.5.3). A visible
241
+ // label wins over `ariaLabel`, so the name matches what a sighted user reads.
242
+ const triggerLabelledby = computed(() => (label ? `${labelId} ${valueId}` : undefined))
243
+ const triggerAriaLabel = computed(() =>
244
+ !label && ariaLabel ? `${ariaLabel}, ${displayText.value}` : undefined,
245
+ )
246
+
247
+ // ─── Rows ─────────────────────────────────────────────────────────────────────
248
+
249
+ /** The catalogue as shown: everything in `fonts`, whatever the status, every family once. */
250
+ const shownFonts = computed(() => {
251
+ const seen = new Set<string>()
252
+ return fonts.filter(({ family }) => {
253
+ if (seen.has(family)) return false
254
+ seen.add(family)
255
+ return true
256
+ })
257
+ })
258
+
259
+ const descriptionFor = (id: string, extra?: string) =>
260
+ [id === currentId.value ? copy.value.current : undefined, extra].filter(Boolean).join('. ') ||
261
+ undefined
262
+
263
+ const rows = computed<FontRow[]>(() => {
264
+ const result: FontRow[] = []
265
+
266
+ if (inherit) {
267
+ const family = inherit.family || undefined
268
+ result.push({
269
+ item: {
270
+ id: INHERIT_ID,
271
+ label: inherit.label,
272
+ keywords: family ? [family] : undefined,
273
+ description: descriptionFor(INHERIT_ID, copy.value.inheritDescription),
274
+ },
275
+ family,
276
+ detail: family,
277
+ value: null,
278
+ })
279
+ }
280
+
281
+ // A family Google dropped, or a Google family while the catalogue is not in: the current
282
+ // choice is always in the list
283
+ const chosen = chosenFamily.value
284
+ if (chosen && !shownFonts.value.some(({ family }) => family === chosen)) {
285
+ const id = fontId(chosen)
286
+ result.push({
287
+ item: { id, label: chosen, description: descriptionFor(id) },
288
+ family: chosen,
289
+ value: chosen,
290
+ })
291
+ }
292
+
293
+ for (const { family, source } of shownFonts.value) {
294
+ const id = fontId(family)
295
+ result.push({
296
+ item: {
297
+ id,
298
+ label: family,
299
+ group: source === 'google' ? GROUP_GOOGLE : GROUP_WEB_SAFE,
300
+ description: descriptionFor(id),
301
+ },
302
+ family,
303
+ value: family,
304
+ })
305
+ }
306
+
307
+ return result
308
+ })
309
+
310
+ const rowById = computed(() => new Map(rows.value.map((row) => [row.item.id, row])))
311
+ const items = computed(() => rows.value.map(({ item }) => item))
312
+ const rowOf = (id: string) => rowById.value.get(id)
313
+
314
+ const groups = computed(() => [
315
+ { id: GROUP_WEB_SAFE, label: copy.value.groupWebSafe },
316
+ { id: GROUP_GOOGLE, label: copy.value.groupGoogle },
317
+ ])
318
+
319
+ const loading = computed(() => catalogueStatus === 'loading')
320
+ const failed = computed(() => catalogueStatus === 'error')
321
+ // The skeletons stand in for a Google group that is not there yet; next to a real one they would
322
+ // repeat its heading
323
+ const showSkeletons = computed(
324
+ () => loading.value && !shownFonts.value.some(({ source }) => source === 'google'),
325
+ )
326
+
327
+ // A trigger without either has no name for the field, and a union type to require one would
328
+ // collapse the props to `Record<string, any>` in the emitted d.ts. `process.env.NODE_ENV` stays
329
+ // in the library build, so the consumer's bundler decides whether this runs; `import.meta.env.DEV`
330
+ // would be fixed to `false` when the library is built.
331
+ if (process.env.NODE_ENV !== 'production') {
332
+ watchEffect(() => {
333
+ if (!label && !ariaLabel) {
334
+ console.warn('[NexxtFontPicker] Pass `label` or `ariaLabel`: the trigger has no name.')
335
+ }
336
+ })
337
+ }
338
+
339
+ // ─── Panel ────────────────────────────────────────────────────────────────────
340
+
341
+ const { width: observedWidth } = useElementSize(trigger, undefined, { box: 'border-box' })
342
+ // Also read on open: a ResizeObserver reports a frame after mount, and a panel opened before that
343
+ // would come out at its minimum width
344
+ const openedWidth = ref(0)
345
+ const triggerWidth = computed(() => observedWidth.value || openedWidth.value)
346
+
347
+ /** The trigger's width with a 320px minimum, within the viewport; 360px high where it fits. */
348
+ const panelStyle = (availableHeight: number | null) => ({
349
+ width: `min(max(${PANEL_MIN_WIDTH}px, ${Math.round(triggerWidth.value)}px), calc(100vw - 16px))`,
350
+ height: `min(${PANEL_HEIGHT}px, ${availableHeight ?? PANEL_HEIGHT}px, calc(100dvh - 5rem))`,
351
+ })
352
+
353
+ const panelLabel = computed(() => label ?? ariaLabel ?? copy.value.listLabel)
354
+
355
+ const onOpen = () => {
356
+ openedWidth.value = trigger.value?.offsetWidth ?? 0
357
+ search.value = ''
358
+ highlighted.value = currentId.value
359
+ emit('open')
360
+ }
361
+
362
+ const onTriggerKeydown = (event: KeyboardEvent) => {
363
+ if (event.key !== 'ArrowDown' || floating.value?.isOpen) return
364
+ event.preventDefault()
365
+ floating.value?.open()
366
+ }
367
+
368
+ // Focus goes back first: the panel is not teleported, so FloatingPanel does not hand it back
369
+ // itself, and the field that has it is about to leave the DOM
370
+ const closeToTrigger = () => {
371
+ trigger.value?.focus()
372
+ floating.value?.close()
373
+ }
374
+
375
+ const onSelect = (item: NexxtPickerItemData) => {
376
+ const row = rowOf(item.id)
377
+ // Against the raw model, so choosing the inherit option on a stored `''` writes `null`
378
+ if (row && row.value !== model.value) model.value = row.value
379
+ closeToTrigger()
380
+ }
381
+
382
+ // Tab out of the panel and the trigger closes the panel, without moving focus. No focus trap.
383
+ // A `null` target (a click on the panel's own background) is left to FloatingPanel's outside click.
384
+ const onFocusOut = (event: FocusEvent) => {
385
+ const next = event.relatedTarget
386
+ if (!(next instanceof Node)) return
387
+ if (root.value?.contains(next) || floating.value?.floatingRef?.contains(next)) return
388
+ floating.value?.close()
389
+ }
390
+
391
+ // ─── Loading faces ────────────────────────────────────────────────────────────
392
+
393
+ useFontPreviewLoader({
394
+ loadFont: () => loadFont,
395
+ container: () => panelRoot.value,
396
+ rows: [search, items],
397
+ current: () => display.value.family,
398
+ })
399
+
400
+ // ─── Classes ──────────────────────────────────────────────────────────────────
401
+
402
+ const TRIGGER_CLASSES = {
403
+ base: 'group flex h-10 w-full min-w-0 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',
404
+ enabled: 'cursor-pointer',
405
+ disabled: 'cursor-not-allowed opacity-50',
406
+ }
407
+
408
+ const CHEVRON_CLASSES = {
409
+ base: 'shrink-0 text-gray-700 transition-transform duration-200 motion-reduce:transition-none',
410
+ open: 'rotate-180',
411
+ }
412
+
413
+ // One text style everywhere; only "Huisstijl" keeps its width when a family follows it
414
+ const PRIMARY_CLASSES = {
415
+ alone: 'min-w-0 truncate',
416
+ withDetail: 'shrink-0',
417
+ }
418
+
419
+ const triggerClass = computed(() => [
420
+ TRIGGER_CLASSES.base,
421
+ disabled ? TRIGGER_CLASSES.disabled : TRIGGER_CLASSES.enabled,
422
+ ])
423
+
424
+ const valueClass = computed(() => [
425
+ 'flex min-w-0 flex-1 items-center small-normal',
426
+ display.value.placeholder ? 'text-gray-700' : 'text-gray-900',
427
+ ])
428
+
429
+ /**
430
+ * Grouped rows sit 12px further in than their group heading, the same step as the grouped options
431
+ * of NexxtComboboxPanel (heading `px-6`, option `pl-9`). The option keeps PickerItem's `px-2`, so
432
+ * the step goes on the row content; ungrouped rows (inherit, a value outside the catalogue) stay
433
+ * level with the headings.
434
+ */
435
+ const ROW_CLASSES = {
436
+ base: 'flex min-w-0 flex-1 items-center body-normal leading-6 text-gray-900',
437
+ grouped: 'pl-3',
438
+ }
439
+
440
+ const rowClass = (item: NexxtPickerItemData) => [
441
+ ROW_CLASSES.base,
442
+ item.group && ROW_CLASSES.grouped,
443
+ ]
444
+
445
+ const primaryClass = (detail?: string) =>
446
+ detail ? PRIMARY_CLASSES.withDetail : PRIMARY_CLASSES.alone
447
+
448
+ const chevronClass = (isOpen: boolean) => [CHEVRON_CLASSES.base, isOpen && CHEVRON_CLASSES.open]
449
+ </script>
450
+
451
+ <template>
452
+ <div ref="root" class="flex w-full min-w-0 flex-col" @focusout="onFocusOut">
453
+ <label v-if="label" :id="labelId" :for="triggerId" class="mb-1 small-normal text-gray-700">
454
+ {{ label }}
455
+ </label>
456
+
457
+ <NexxtFloatingPanel
458
+ ref="floating"
459
+ class="w-full"
460
+ placement="bottom-start"
461
+ surface
462
+ fit-height
463
+ @open="onOpen"
464
+ @close="emit('close')"
465
+ >
466
+ <template #trigger="{ isOpen, toggle }">
467
+ <!--
468
+ `aria-haspopup="listbox"`, as NexxtSelect: what the user picks from is the listbox, and
469
+ that is what a screen reader should promise. `dialog` would claim a `role="dialog"`,
470
+ which the panel is not (it is a non-modal `role="group"` around a search field and the
471
+ listbox), and ARIA has no `group` value to match it exactly.
472
+ -->
473
+ <button
474
+ :id="triggerId"
475
+ ref="trigger"
476
+ type="button"
477
+ aria-haspopup="listbox"
478
+ :aria-expanded="isOpen"
479
+ :aria-label="triggerAriaLabel"
480
+ :aria-labelledby="triggerLabelledby"
481
+ :disabled="disabled"
482
+ :class="triggerClass"
483
+ @click="toggle"
484
+ @keydown="onTriggerKeydown"
485
+ >
486
+ <span :id="valueId" :class="valueClass" :style="fontStyle(display.family)">
487
+ <span :class="primaryClass(display.detail)">{{ display.primary }}</span>
488
+ <span v-if="display.detail" class="min-w-0 truncate text-gray-700">
489
+ {{ DETAIL_PREFIX }}{{ display.detail }}
490
+ </span>
491
+ </span>
492
+ <NexxtIcon name="chevron-down" aria-hidden="true" :class="chevronClass(isOpen)" />
493
+ </button>
494
+ </template>
495
+
496
+ <template #content="{ availableHeight }">
497
+ <div ref="panelRoot" class="flex flex-col" :style="panelStyle(availableHeight)">
498
+ <NexxtPickerPanel
499
+ v-model:highlighted="highlighted"
500
+ v-model:search="search"
501
+ class="min-h-0 flex-1"
502
+ width="100%"
503
+ height="100%"
504
+ :preview="false"
505
+ :collapsible="false"
506
+ :items="items"
507
+ :groups="groups"
508
+ :busy="loading"
509
+ :aria-label="panelLabel"
510
+ :list-label="copy.listLabel"
511
+ :search-label="copy.searchLabel"
512
+ :search-placeholder="copy.searchPlaceholder"
513
+ :empty-title="copy.emptyTitle"
514
+ :empty-description="copy.emptyDescription"
515
+ :clear-label="copy.clearSearch"
516
+ :results-announcement="copy.results"
517
+ @select="onSelect"
518
+ @close="closeToTrigger"
519
+ >
520
+ <template #item="{ item }">
521
+ <span
522
+ :class="rowClass(item)"
523
+ :style="fontStyle(rowOf(item.id)?.family)"
524
+ :data-font-family="rowOf(item.id)?.family"
525
+ >
526
+ <span :class="primaryClass(rowOf(item.id)?.detail)">{{ item.label }}</span>
527
+ <span v-if="rowOf(item.id)?.detail" class="min-w-0 truncate text-gray-700">
528
+ {{ DETAIL_PREFIX }}{{ rowOf(item.id)?.detail }}
529
+ </span>
530
+ </span>
531
+ <NexxtIcon
532
+ v-if="item.id === currentId"
533
+ name="check"
534
+ aria-hidden="true"
535
+ class="shrink-0 text-xs text-cornflower-blue-600"
536
+ />
537
+ </template>
538
+
539
+ <!-- The heading and rows stand in for the Google group while it loads. They are no
540
+ options, so they stay out of the accessibility tree; `aria-busy` says the rest. -->
541
+ <template v-if="showSkeletons" #append>
542
+ <NexxtPickerGroup :label="copy.groupGoogle" :collapsible="false" aria-hidden="true">
543
+ <div
544
+ v-for="(style, index) in SKELETON_ROWS"
545
+ :key="index"
546
+ class="flex h-9 shrink-0 items-center pr-2 pl-5"
547
+ >
548
+ <!-- `pl-5`: PickerItem's 8px plus the 12px step of a grouped row -->
549
+ <NexxtSkeleton class="h-3" :style="style" />
550
+ </div>
551
+ </NexxtPickerGroup>
552
+ </template>
553
+ </NexxtPickerPanel>
554
+
555
+ <!-- Outside the listbox, so it is no option; after it in the tab order -->
556
+ <div
557
+ v-if="failed"
558
+ role="status"
559
+ class="flex shrink-0 items-start gap-2 border-t border-gray-200 px-3 py-2.5"
560
+ >
561
+ <NexxtIcon
562
+ name="circle-exclamation"
563
+ aria-hidden="true"
564
+ class="mt-0.5 shrink-0 text-gray-700"
565
+ />
566
+ <!-- NexxtButton is a 40px flex box, so it gets a line of its own under the text -->
567
+ <div class="flex min-w-0 flex-col items-start">
568
+ <p class="extra-small-normal text-gray-800">{{ copy.catalogueError }}</p>
569
+ <NexxtButton variant="link" @click="emit('retry')">{{ copy.retry }}</NexxtButton>
570
+ </div>
571
+ </div>
572
+ </div>
573
+ </template>
574
+ </NexxtFloatingPanel>
575
+ </div>
576
+ </template>
@@ -0,0 +1,118 @@
1
+ import { onBeforeUnmount, watch, type WatchSource } from 'vue'
2
+ import { useMounted } from '@vueuse/core'
3
+
4
+ /** Rows within this distance of the visible part of the list count as in view. */
5
+ const LOAD_MARGIN_PX = 150
6
+ /** Scrolling has to rest this long before the rows in view are requested. */
7
+ const LOAD_DEBOUNCE_MS = 100
8
+
9
+ export interface FontPreviewLoaderOptions {
10
+ /** The consumer's loader; nothing is requested without one. */
11
+ loadFont: () => ((family: string) => Promise<void> | void) | undefined
12
+ /** Element holding the listbox; `null` while the panel is closed. */
13
+ container: () => HTMLElement | null
14
+ /** Anything whose change re-renders the rows, so they are observed afresh. */
15
+ rows: WatchSource[]
16
+ /** Family requested as soon as the component is mounted: the one the trigger shows. */
17
+ current: () => string | undefined
18
+ }
19
+
20
+ /**
21
+ * Requests the face of every family that comes into view in the listbox. Rows mark their family
22
+ * with `data-font-family`. A request that succeeded (or is still on its way) is never repeated.
23
+ * One that failed is held back until its row has really been out of view, or until the panel
24
+ * opens again; re-observing the rows (a search, new items, a new loader) does not count, so a
25
+ * loader that keeps failing is not called on every keystroke. Meanwhile the row keeps its
26
+ * fallback font and stays choosable, so there is nothing to report.
27
+ */
28
+ export const useFontPreviewLoader = ({
29
+ loadFont,
30
+ container,
31
+ rows,
32
+ current,
33
+ }: FontPreviewLoaderOptions) => {
34
+ /** Loaded, or on its way. */
35
+ const requested = new Set<string>()
36
+ /** Failed, and not yet out of view since: not asked for again until then. */
37
+ const failed = new Set<string>()
38
+
39
+ const request = (family: string) => {
40
+ const load = loadFont()
41
+ if (!load || requested.has(family) || failed.has(family)) return
42
+ requested.add(family)
43
+ // async, so a loader that throws synchronously is caught the same way as a rejection. No
44
+ // retry loop and no timer: the family waits for its row to leave the view and come back
45
+ void (async () => load(family))().catch(() => {
46
+ requested.delete(family)
47
+ failed.add(family)
48
+ })
49
+ }
50
+
51
+ // Not during SSR: the face is loaded in the browser that shows it
52
+ const isMounted = useMounted()
53
+
54
+ // The loader is a source too: one that arrives after mount still gets the current family. Any
55
+ // later loader inherits `requested` and `failed`, so a new identity asks for nothing again
56
+ watch(
57
+ [isMounted, current, loadFont],
58
+ ([mounted, family]) => {
59
+ if (mounted && family) request(family)
60
+ },
61
+ { immediate: true },
62
+ )
63
+
64
+ /** Families of the rows within reach of the view right now. */
65
+ const inView = new Set<string>()
66
+ let observer: IntersectionObserver | undefined
67
+ let flushTimer: ReturnType<typeof setTimeout> | undefined
68
+
69
+ // Debounced, so scrolling fast past a few hundred rows requests only where it comes to rest
70
+ const onIntersect = (entries: IntersectionObserverEntry[]) => {
71
+ for (const entry of entries) {
72
+ const family = (entry.target as HTMLElement).dataset.fontFamily
73
+ if (!family) continue
74
+ if (entry.isIntersecting) inView.add(family)
75
+ else {
76
+ inView.delete(family)
77
+ // really out of view: a failed family may be tried again when it comes back
78
+ failed.delete(family)
79
+ }
80
+ }
81
+ clearTimeout(flushTimer)
82
+ flushTimer = setTimeout(() => inView.forEach(request), LOAD_DEBOUNCE_MS)
83
+ }
84
+
85
+ const stop = () => {
86
+ clearTimeout(flushTimer)
87
+ observer?.disconnect()
88
+ observer = undefined
89
+ inView.clear()
90
+ }
91
+
92
+ const observe = () => {
93
+ stop()
94
+ const listbox = container()?.querySelector<HTMLElement>('[role="listbox"]')
95
+ if (!listbox || !loadFont() || typeof IntersectionObserver === 'undefined') return
96
+
97
+ observer = new IntersectionObserver(onIntersect, {
98
+ root: listbox,
99
+ rootMargin: `${LOAD_MARGIN_PX}px 0px`,
100
+ })
101
+ listbox.querySelectorAll<HTMLElement>('[data-font-family]').forEach((row) => {
102
+ observer?.observe(row)
103
+ })
104
+ }
105
+
106
+ // Every change of the rendered rows, or of the loader, re-observes them; the panel closing
107
+ // disconnects. Opening the panel again gives failed families a new chance
108
+ watch(
109
+ [container, loadFont, ...rows],
110
+ ([element], [previous]) => {
111
+ if (element && !previous) failed.clear()
112
+ observe()
113
+ },
114
+ { flush: 'post' },
115
+ )
116
+
117
+ onBeforeUnmount(stop)
118
+ }