@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.
- package/dist/components/editor/molecules/EditorFontSize/EditorFontSize.vue.d.ts +117 -0
- package/dist/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.vue.d.ts +40 -24
- package/dist/components/editor/molecules/EditorLineHeight/EditorLineHeight.vue.d.ts +52 -39
- package/dist/components/molecules/PickerList/PickerList.vue.d.ts +14 -2
- package/dist/components/organisms/FontPicker/FontPicker.vue.d.ts +125 -0
- package/dist/components/organisms/FontPicker/useFontPreviewLoader.d.ts +20 -0
- package/dist/components/organisms/PickerPanel/PickerPanel.vue.d.ts +11 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +2643 -2106
- package/dist/nuxt.js +2 -0
- package/package.json +1 -1
- package/src/components/atoms/Skeleton/Skeleton.vue +1 -1
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +6 -1
- package/src/components/editor/molecules/EditorFontSize/EditorFontSize.vue +253 -0
- package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.vue +102 -28
- package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.vue +133 -47
- package/src/components/molecules/PickerList/PickerList.vue +50 -10
- package/src/components/organisms/FontPicker/FontPicker.vue +576 -0
- package/src/components/organisms/FontPicker/useFontPreviewLoader.ts +118 -0
- package/src/components/organisms/PickerPanel/PickerPanel.vue +16 -1
- package/src/components.json +2 -0
- package/src/index.ts +20 -1
|
@@ -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
|
+
}
|