@getdom/studio 0.2.2 → 0.2.4
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/CHANGELOG.md +84 -0
- package/package.json +1 -1
- package/src/pages/_layout/componentCatalog.generated.js +114 -12
- package/src/pages/charts/_shared/CompactChartTooltip.vue +39 -0
- package/src/pages/charts/_shared/XYChart.vue +7 -2
- package/src/pages/charts/_shared/XYRenderer.vue +18 -11
- package/src/pages/charts/sparkline/DomSparkline.vue +1 -0
- package/src/pages/forms/_shared/DomOptionStatus.vue +18 -0
- package/src/pages/forms/_shared/useOptionMenu.js +18 -2
- package/src/pages/forms/_shared/useOptionPaging.js +42 -0
- package/src/pages/forms/_shared/useSelectedOptions.js +25 -0
- package/src/pages/forms/autocomplete/DomAutocomplete.vue +36 -8
- package/src/pages/forms/combobox/DomCombobox.vue +44 -7
- package/src/pages/forms/font-picker/DomFontPicker.vue +416 -0
- package/src/pages/forms/font-picker/FontPreview.vue +39 -0
- package/src/pages/forms/font-picker/fontPicker.js +102 -0
- package/src/pages/forms/font-picker/googleFonts.js +1951 -0
- package/src/pages/forms/select/DomSelect.vue +118 -29
- package/src/pages/forms/tag-combobox/DomTagCombobox.vue +67 -15
- package/src/pages/lib/headless/autocomplete.js +5 -0
- package/src/pages/lib/headless/combobox.js +129 -25
- package/src/pages/lib/headless/dropdown.js +3 -2
- package/src/pages/lib/headless/listbox.d.ts +1 -0
- package/src/pages/lib/headless/listbox.js +41 -5
- package/src/pages/lib/vue/index.d.ts +137 -3
- package/src/pages/lib/vue/index.js +1 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
3
|
+
import DomSelect from '../select/DomSelect.vue';
|
|
4
|
+
import FontPreview from './FontPreview.vue';
|
|
5
|
+
import { fieldProps, fieldPropNames } from '../field/fieldProps.js';
|
|
6
|
+
import { buildGoogleFontsStylesheetUrl, filterFonts, fontStack, normalizeFont } from './fontPicker.js';
|
|
7
|
+
import { googleFonts } from './googleFonts.js';
|
|
8
|
+
|
|
9
|
+
defineOptions({
|
|
10
|
+
inheritAttrs: false,
|
|
11
|
+
__doc: {
|
|
12
|
+
name: 'Font picker',
|
|
13
|
+
tag: '<DomFontPicker>',
|
|
14
|
+
description: 'Searchable Google Fonts picker that previews only visible families and keeps font network loading inside the mounted control.',
|
|
15
|
+
icon: 'M5 5h14M12 5v14M8 19h8',
|
|
16
|
+
playground: {
|
|
17
|
+
initial: {
|
|
18
|
+
label: 'Font family',
|
|
19
|
+
modelValue: 'Lato',
|
|
20
|
+
placeholder: 'Search Google Fonts',
|
|
21
|
+
category: '',
|
|
22
|
+
pageSize: 24,
|
|
23
|
+
previewText: '',
|
|
24
|
+
loadFonts: true,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
events: [
|
|
28
|
+
{ name: 'update:modelValue', payload: 'string', description: 'Fired with the selected font value.' },
|
|
29
|
+
{ name: 'select', payload: '{ item, value, label }', description: 'Fired when a font family is selected.' },
|
|
30
|
+
{ name: 'query', payload: 'string', description: 'Fired as the user searches the catalog.' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const props = defineProps({
|
|
36
|
+
...fieldProps,
|
|
37
|
+
modelValue: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: '',
|
|
40
|
+
_edit: { group: 'Control props', description: 'Selected font family or application-defined font value.' },
|
|
41
|
+
},
|
|
42
|
+
fonts: {
|
|
43
|
+
type: Array,
|
|
44
|
+
/**
|
|
45
|
+
* Return an empty override so the component can fall back to its built-in catalog.
|
|
46
|
+
*
|
|
47
|
+
* @returns {Array<Record<string, string>>} Empty font override.
|
|
48
|
+
*/
|
|
49
|
+
default: () => [],
|
|
50
|
+
_edit: {
|
|
51
|
+
group: 'Control props',
|
|
52
|
+
component: 'DomJsonListInput',
|
|
53
|
+
description: 'Lightweight family metadata. Font files are never bundled in this array.',
|
|
54
|
+
props: {
|
|
55
|
+
compact: true,
|
|
56
|
+
addLabel: '+ Add font',
|
|
57
|
+
schema: [
|
|
58
|
+
{ key: 'family', label: 'Family', placeholder: 'Lato' },
|
|
59
|
+
{ key: 'value', label: 'Value', placeholder: 'Lato' },
|
|
60
|
+
{ key: 'category', label: 'Fallback', placeholder: 'sans-serif' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
category: {
|
|
66
|
+
type: String,
|
|
67
|
+
default: '',
|
|
68
|
+
_edit: { group: 'Control props', options: ['', 'sans-serif', 'serif', 'display', 'handwriting', 'monospace'], description: 'Optionally restrict results to one Google Fonts category.' },
|
|
69
|
+
},
|
|
70
|
+
pageSize: {
|
|
71
|
+
type: Number,
|
|
72
|
+
default: 24,
|
|
73
|
+
_edit: { group: 'Control props', min: 6, max: 50, description: 'Number of metadata rows appended near the end. Preview fonts load only near the viewport.' },
|
|
74
|
+
},
|
|
75
|
+
previewText: {
|
|
76
|
+
type: String,
|
|
77
|
+
default: '',
|
|
78
|
+
_edit: { group: 'Control props', description: 'Optional shared sample. Empty uses each family name as its own preview.' },
|
|
79
|
+
},
|
|
80
|
+
loadFonts: {
|
|
81
|
+
type: Boolean,
|
|
82
|
+
default: true,
|
|
83
|
+
_edit: { group: 'Control props', description: 'Load the mounted selected label and visible previews from Google Fonts.' },
|
|
84
|
+
},
|
|
85
|
+
stylesheetUrl: {
|
|
86
|
+
type: String,
|
|
87
|
+
default: 'https://fonts.googleapis.com/css2',
|
|
88
|
+
_edit: { group: 'Control props', description: 'Google Fonts CSS v2 endpoint or an application-controlled compatible proxy.' },
|
|
89
|
+
},
|
|
90
|
+
clearable: {
|
|
91
|
+
type: Boolean,
|
|
92
|
+
default: true,
|
|
93
|
+
_edit: { group: 'Control props', description: 'Show a clear button after a font is selected.' },
|
|
94
|
+
},
|
|
95
|
+
placement: {
|
|
96
|
+
type: String,
|
|
97
|
+
default: 'bottom',
|
|
98
|
+
_edit: { group: 'Control props', options: ['bottom', 'top', 'right', 'left'], description: 'Preferred side before collision handling.' },
|
|
99
|
+
},
|
|
100
|
+
floatingMode: {
|
|
101
|
+
type: String,
|
|
102
|
+
default: 'viewport',
|
|
103
|
+
_edit: { group: 'Control props', options: ['viewport', 'anchor'], description: 'viewport keeps the list inside the browser; anchor keeps it attached while scrolling.' },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const emit = defineEmits(['update:modelValue', 'select', 'query', 'focus', 'blur']);
|
|
107
|
+
|
|
108
|
+
const activated = ref(false);
|
|
109
|
+
const isMounted = ref(false);
|
|
110
|
+
const searchQuery = ref('');
|
|
111
|
+
const resultLimit = ref(props.pageSize);
|
|
112
|
+
const intersectingFonts = ref(new Map());
|
|
113
|
+
const availableFonts = computed(getAvailableFonts);
|
|
114
|
+
const matchingFonts = computed(getMatchingFonts);
|
|
115
|
+
const visibleFonts = computed(getVisibleFonts);
|
|
116
|
+
const selectedFont = computed(getSelectedFont);
|
|
117
|
+
const fontLoadSignature = computed(getFontLoadSignature);
|
|
118
|
+
const selectProps = computed(getSelectProps);
|
|
119
|
+
let loadTimer = null;
|
|
120
|
+
const activeStylesheets = new Set();
|
|
121
|
+
const pendingStylesheets = new Map();
|
|
122
|
+
const loadedFamilies = new Set();
|
|
123
|
+
const pendingFamilies = new Set();
|
|
124
|
+
|
|
125
|
+
watch(fontLoadSignature, scheduleFontLoad);
|
|
126
|
+
watch(() => props.category, resetResultsAndFonts);
|
|
127
|
+
watch(() => props.fonts, resetResultsAndFonts, { deep: true });
|
|
128
|
+
watch(() => props.pageSize, resetResultsAndFonts);
|
|
129
|
+
watch(() => [props.previewText, props.stylesheetUrl], resetResultsAndFonts);
|
|
130
|
+
onMounted(startFontLoading);
|
|
131
|
+
onBeforeUnmount(removeFontStylesheets);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Allow browser-only preview requests after Vue has mounted the component.
|
|
135
|
+
*
|
|
136
|
+
* @returns {void}
|
|
137
|
+
*/
|
|
138
|
+
function startFontLoading() {
|
|
139
|
+
isMounted.value = true;
|
|
140
|
+
scheduleFontLoad();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Normalize the supplied metadata without loading any font assets.
|
|
145
|
+
*
|
|
146
|
+
* @returns {Array<Record<string, string>>} Normalized font records.
|
|
147
|
+
*/
|
|
148
|
+
function getAvailableFonts() {
|
|
149
|
+
const fonts = props.fonts.length ? props.fonts : googleFonts;
|
|
150
|
+
return fonts.map(normalizeFont).filter(Boolean);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Filter the complete metadata catalog without loading font assets.
|
|
155
|
+
*
|
|
156
|
+
* @returns {Array<Record<string, string>>} All matching font records.
|
|
157
|
+
*/
|
|
158
|
+
function getMatchingFonts() {
|
|
159
|
+
return filterFonts(availableFonts.value, searchQuery.value, Number.MAX_SAFE_INTEGER, props.category);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Return the incrementally revealed result window.
|
|
164
|
+
*
|
|
165
|
+
* @returns {Array<Record<string, string>>} Visible picker options.
|
|
166
|
+
*/
|
|
167
|
+
function getVisibleFonts() {
|
|
168
|
+
return matchingFonts.value.slice(0, Math.max(1, resultLimit.value));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve selected font metadata, including application-defined values.
|
|
173
|
+
*
|
|
174
|
+
* @returns {Record<string, string>|null} Selected font record.
|
|
175
|
+
*/
|
|
176
|
+
function getSelectedFont() {
|
|
177
|
+
if (!props.modelValue) return null;
|
|
178
|
+
return availableFonts.value.find((font) => font.value === props.modelValue || font.family === props.modelValue)
|
|
179
|
+
|| normalizeFont({ family: props.modelValue, value: props.modelValue });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Create a stable trigger when visible preview families or loading options change.
|
|
184
|
+
*
|
|
185
|
+
* @returns {string} Font loading signature.
|
|
186
|
+
*/
|
|
187
|
+
function getFontLoadSignature() {
|
|
188
|
+
if (!props.loadFonts) return '';
|
|
189
|
+
const records = getPreviewFontRecords();
|
|
190
|
+
return `${props.stylesheetUrl}|${props.previewText}|${records.map((font) => font.family).join('\u0000')}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Pass field and selection behaviour through to the shared searchable select.
|
|
195
|
+
*
|
|
196
|
+
* @returns {Record<string, unknown>} Props for DomSelect.
|
|
197
|
+
*/
|
|
198
|
+
function getSelectProps() {
|
|
199
|
+
const shared = Object.fromEntries(fieldPropNames.map((name) => [name, props[name]]));
|
|
200
|
+
return {
|
|
201
|
+
...shared,
|
|
202
|
+
modelValue: props.modelValue,
|
|
203
|
+
options: visibleFonts.value,
|
|
204
|
+
selectedOptions: selectedFont.value ? [selectedFont.value] : [],
|
|
205
|
+
searchable: true,
|
|
206
|
+
filterOptions: false,
|
|
207
|
+
hasMore: resultLimit.value < matchingFonts.value.length,
|
|
208
|
+
width: 'w-[min(28rem,calc(100vw-2rem))]',
|
|
209
|
+
clearable: props.clearable,
|
|
210
|
+
placement: props.placement,
|
|
211
|
+
floatingMode: props.floatingMode,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Update search results and mark preview loading as user-initiated.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} query Current transient select-panel query.
|
|
219
|
+
* @returns {void}
|
|
220
|
+
*/
|
|
221
|
+
function handleQuery(query) {
|
|
222
|
+
if (searchQuery.value !== query) {
|
|
223
|
+
resultLimit.value = props.pageSize;
|
|
224
|
+
clearFontStylesheets();
|
|
225
|
+
}
|
|
226
|
+
searchQuery.value = query;
|
|
227
|
+
if (!query) {
|
|
228
|
+
const selectedIndex = matchingFonts.value.findIndex((font) => font.value === props.modelValue);
|
|
229
|
+
resultLimit.value = Math.max(props.pageSize, Math.ceil((selectedIndex + 2) / Math.max(1, props.pageSize)) * Math.max(1, props.pageSize));
|
|
230
|
+
}
|
|
231
|
+
scheduleFontLoad();
|
|
232
|
+
emit('query', query);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Activate visible-row preview loading when the shared select panel opens.
|
|
237
|
+
*
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
function handleOpen() {
|
|
241
|
+
activated.value = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Stop observing previews when the select closes, leaving the chosen label intact. */
|
|
245
|
+
function handleClose() {
|
|
246
|
+
activated.value = false;
|
|
247
|
+
intersectingFonts.value.clear();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Track only rows intersecting the select's scroll viewport, plus a small overscan. */
|
|
251
|
+
function handleVisibility(font, visible) {
|
|
252
|
+
if (visible && activated.value) intersectingFonts.value.set(font.family, font);
|
|
253
|
+
else intersectingFonts.value.delete(font.family);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Adapt the select's option detail to the font picker's existing item contract.
|
|
258
|
+
*
|
|
259
|
+
* @param {Record<string, unknown>} detail Selection detail.
|
|
260
|
+
* @returns {void}
|
|
261
|
+
*/
|
|
262
|
+
function handleSelect(detail) {
|
|
263
|
+
emit('select', { ...detail, item: detail.option });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Append metadata when the list nears its end; visibility owns font loading.
|
|
268
|
+
*
|
|
269
|
+
* @returns {void}
|
|
270
|
+
*/
|
|
271
|
+
function loadMore() {
|
|
272
|
+
resultLimit.value = Math.min(matchingFonts.value.length, resultLimit.value + Math.max(1, props.pageSize));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Reset incremental results and component-owned preview stylesheets.
|
|
277
|
+
*
|
|
278
|
+
* @returns {void}
|
|
279
|
+
*/
|
|
280
|
+
function resetResultsAndFonts() {
|
|
281
|
+
resultLimit.value = props.pageSize;
|
|
282
|
+
clearFontStylesheets();
|
|
283
|
+
scheduleFontLoad();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Debounce loading for newly revealed preview families while the user types.
|
|
288
|
+
*
|
|
289
|
+
* @returns {void}
|
|
290
|
+
*/
|
|
291
|
+
function scheduleFontLoad() {
|
|
292
|
+
if (!isMounted.value) return;
|
|
293
|
+
if (loadTimer) window.clearTimeout(loadTimer);
|
|
294
|
+
if (!props.loadFonts) {
|
|
295
|
+
clearFontStylesheets();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
loadTimer = window.setTimeout(loadVisibleFontBatch, 120);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Request one stylesheet for the newly visible family batch.
|
|
303
|
+
*
|
|
304
|
+
* @returns {void}
|
|
305
|
+
*/
|
|
306
|
+
function loadVisibleFontBatch() {
|
|
307
|
+
const records = getPreviewFontRecords().filter((font) => !loadedFamilies.has(font.family) && !pendingFamilies.has(font.family));
|
|
308
|
+
if (!records.length) return;
|
|
309
|
+
const families = records.map((font) => font.family);
|
|
310
|
+
const previewText = props.previewText + records.map((font) => font.label).join('');
|
|
311
|
+
const url = buildGoogleFontsStylesheetUrl(families, previewText, props.stylesheetUrl);
|
|
312
|
+
if (!url) return;
|
|
313
|
+
const link = document.createElement('link');
|
|
314
|
+
link.rel = 'stylesheet';
|
|
315
|
+
link.href = url;
|
|
316
|
+
link.dataset.domFontPicker = '';
|
|
317
|
+
link.addEventListener('load', () => activateFontStylesheet(link), { once: true });
|
|
318
|
+
link.addEventListener('error', () => discardPendingStylesheet(link), { once: true });
|
|
319
|
+
pendingStylesheets.set(link, families);
|
|
320
|
+
families.forEach((family) => pendingFamilies.add(family));
|
|
321
|
+
document.head.append(link);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Collect currently visible rows plus a selected family that may be outside the result window.
|
|
326
|
+
*
|
|
327
|
+
* @returns {Array<Record<string, string>>} Unique font records eligible for preview loading.
|
|
328
|
+
*/
|
|
329
|
+
function getPreviewFontRecords() {
|
|
330
|
+
const visibleFamilies = new Set(visibleFonts.value.map((font) => font.family));
|
|
331
|
+
const records = activated.value ? [...intersectingFonts.value.values()].filter((font) => visibleFamilies.has(font.family)) : [];
|
|
332
|
+
if (selectedFont.value) records.push(selectedFont.value);
|
|
333
|
+
return [...new Map(records.map((font) => [font.family, font])).values()];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Keep a successfully loaded batch active for the lifetime of the current query.
|
|
338
|
+
*
|
|
339
|
+
* @param {HTMLLinkElement} link Loaded stylesheet element.
|
|
340
|
+
* @returns {void}
|
|
341
|
+
*/
|
|
342
|
+
function activateFontStylesheet(link) {
|
|
343
|
+
const families = pendingStylesheets.get(link);
|
|
344
|
+
if (!families) {
|
|
345
|
+
link.remove();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
pendingStylesheets.delete(link);
|
|
349
|
+
families.forEach((family) => {
|
|
350
|
+
pendingFamilies.delete(family);
|
|
351
|
+
loadedFamilies.add(family);
|
|
352
|
+
});
|
|
353
|
+
activeStylesheets.add(link);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Remove a failed batch so it can be retried after the result set changes.
|
|
358
|
+
*
|
|
359
|
+
* @param {HTMLLinkElement} link Failed stylesheet element.
|
|
360
|
+
* @returns {void}
|
|
361
|
+
*/
|
|
362
|
+
function discardPendingStylesheet(link) {
|
|
363
|
+
const families = pendingStylesheets.get(link) || [];
|
|
364
|
+
link.remove();
|
|
365
|
+
pendingStylesheets.delete(link);
|
|
366
|
+
families.forEach((family) => pendingFamilies.delete(family));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Clear current-query stylesheet batches while leaving the component mounted.
|
|
371
|
+
*
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
function clearFontStylesheets() {
|
|
375
|
+
if (loadTimer) window.clearTimeout(loadTimer);
|
|
376
|
+
loadTimer = null;
|
|
377
|
+
activeStylesheets.forEach((link) => link.remove());
|
|
378
|
+
pendingStylesheets.forEach((_families, link) => link.remove());
|
|
379
|
+
activeStylesheets.clear();
|
|
380
|
+
pendingStylesheets.clear();
|
|
381
|
+
loadedFamilies.clear();
|
|
382
|
+
pendingFamilies.clear();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Clear timers and component-owned stylesheet elements before unmounting.
|
|
387
|
+
*
|
|
388
|
+
* @returns {void}
|
|
389
|
+
*/
|
|
390
|
+
function removeFontStylesheets() {
|
|
391
|
+
isMounted.value = false;
|
|
392
|
+
clearFontStylesheets();
|
|
393
|
+
}
|
|
394
|
+
</script>
|
|
395
|
+
|
|
396
|
+
<template>
|
|
397
|
+
<DomSelect
|
|
398
|
+
v-bind="{ ...selectProps, ...$attrs }"
|
|
399
|
+
class="dom-font-picker"
|
|
400
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
401
|
+
@query="handleQuery"
|
|
402
|
+
@select="handleSelect"
|
|
403
|
+
@load-more="loadMore"
|
|
404
|
+
@open="handleOpen"
|
|
405
|
+
@close="handleClose"
|
|
406
|
+
@focus="emit('focus', $event)"
|
|
407
|
+
@blur="emit('blur', $event)"
|
|
408
|
+
>
|
|
409
|
+
<template #value="{ label, placeholder }">
|
|
410
|
+
<span :style="{ fontFamily: fontStack(selectedFont) }">{{ label || placeholder }}</span>
|
|
411
|
+
</template>
|
|
412
|
+
<template #option="{ option }">
|
|
413
|
+
<FontPreview :font="option" :text="previewText" :active="activated" @visibility="handleVisibility" />
|
|
414
|
+
</template>
|
|
415
|
+
</DomSelect>
|
|
416
|
+
</template>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { onBeforeUnmount, ref, watch } from 'vue';
|
|
3
|
+
import { fontStack } from './fontPicker.js';
|
|
4
|
+
|
|
5
|
+
const props = defineProps({ font: { type: Object, required: true }, text: { type: String, default: '' }, active: Boolean });
|
|
6
|
+
const emit = defineEmits(['visibility']);
|
|
7
|
+
const row = ref(null);
|
|
8
|
+
let observer = null;
|
|
9
|
+
|
|
10
|
+
/** Observe only this row's scroll viewport; off-screen metadata never requests a font. */
|
|
11
|
+
function observePreview() {
|
|
12
|
+
observer?.disconnect();
|
|
13
|
+
if (!props.active || !row.value) return;
|
|
14
|
+
observer = new IntersectionObserver(([entry]) => emit('visibility', props.font, entry.isIntersecting), {
|
|
15
|
+
root: row.value.closest('[role="listbox"], dom-listbox'),
|
|
16
|
+
rootMargin: '48px 0px',
|
|
17
|
+
});
|
|
18
|
+
observer.observe(row.value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Release the observer and remove an unmounted row from the pending preview set. */
|
|
22
|
+
function releasePreview() {
|
|
23
|
+
observer?.disconnect();
|
|
24
|
+
emit('visibility', props.font, false);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
watch([row, () => props.active], observePreview, { flush: 'post' });
|
|
28
|
+
onBeforeUnmount(releasePreview);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<span ref="row" class="block">
|
|
33
|
+
<span class="flex min-w-0 items-center justify-between gap-4">
|
|
34
|
+
<span class="min-w-0 truncate text-base" :style="{ fontFamily: fontStack(font) }">{{ text || font.label }}</span>
|
|
35
|
+
<span class="shrink-0 text-[0.6875rem] font-medium uppercase tracking-wide text-muted-fg">{{ font.category }}</span>
|
|
36
|
+
</span>
|
|
37
|
+
<span v-if="text" class="mt-0.5 block truncate text-xs text-muted-fg">{{ font.label }}</span>
|
|
38
|
+
</span>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const DEFAULT_STYLESHEET_URL = 'https://fonts.googleapis.com/css2';
|
|
2
|
+
const DEFAULT_FALLBACK = 'sans-serif';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a user-provided font family record for search and rendering.
|
|
6
|
+
*
|
|
7
|
+
* @param {string|Record<string, unknown>} font Font family name or metadata record.
|
|
8
|
+
* @param {number} index Position used for a stable fallback key.
|
|
9
|
+
* @returns {Record<string, string>|null} Normalized font record, or null for invalid input.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeFont(font, index = 0) {
|
|
12
|
+
const source = typeof font === 'string' ? { family: font } : font;
|
|
13
|
+
if (!source || typeof source !== 'object') return null;
|
|
14
|
+
const family = String(source.family || source.label || source.value || '').trim();
|
|
15
|
+
if (!family) return null;
|
|
16
|
+
const category = String(source.category || DEFAULT_FALLBACK).trim() || DEFAULT_FALLBACK;
|
|
17
|
+
const value = String(source.value || family);
|
|
18
|
+
const keywords = Array.isArray(source.keywords) ? source.keywords.join(' ') : String(source.keywords || '');
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...source,
|
|
22
|
+
key: String(source.key || `${family}-${index}`),
|
|
23
|
+
family,
|
|
24
|
+
label: String(source.label || family),
|
|
25
|
+
value,
|
|
26
|
+
category,
|
|
27
|
+
searchText: `${family} ${source.label || ''} ${category} ${keywords}`.toLowerCase(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Rank and limit font records for a picker query.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<string|Record<string, unknown>>} fonts Available font records.
|
|
35
|
+
* @param {string} query Current user query.
|
|
36
|
+
* @param {number} limit Maximum number of preview rows.
|
|
37
|
+
* @param {string} category Optional category filter.
|
|
38
|
+
* @returns {Array<Record<string, string>>} Matching normalized font records.
|
|
39
|
+
*/
|
|
40
|
+
export function filterFonts(fonts, query = '', limit = 12, category = '') {
|
|
41
|
+
const needle = query.trim().toLowerCase();
|
|
42
|
+
const normalized = fonts
|
|
43
|
+
.map(normalizeFont)
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.filter((font) => !category || font.category === category);
|
|
46
|
+
|
|
47
|
+
if (!needle) return normalized.slice(0, limit);
|
|
48
|
+
|
|
49
|
+
return normalized
|
|
50
|
+
.filter((font) => font.searchText.includes(needle))
|
|
51
|
+
.sort((left, right) => fontMatchRank(left, needle) - fontMatchRank(right, needle)
|
|
52
|
+
|| left.family.length - right.family.length
|
|
53
|
+
|| left.label.localeCompare(right.label))
|
|
54
|
+
.slice(0, limit);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a Google Fonts CSS v2 URL for only the requested preview families and glyphs.
|
|
59
|
+
*
|
|
60
|
+
* @param {Array<string>} families Font family names to request.
|
|
61
|
+
* @param {string} text Exact preview text to subset into the response.
|
|
62
|
+
* @param {string} baseUrl Google Fonts CSS endpoint or compatible proxy endpoint.
|
|
63
|
+
* @returns {string} Stylesheet URL, or an empty string when no families are supplied.
|
|
64
|
+
*/
|
|
65
|
+
export function buildGoogleFontsStylesheetUrl(families, text, baseUrl = DEFAULT_STYLESHEET_URL) {
|
|
66
|
+
const uniqueFamilies = [...new Set(families.map((family) => String(family || '').trim()).filter(Boolean))]
|
|
67
|
+
.sort((left, right) => left.localeCompare(right));
|
|
68
|
+
if (!uniqueFamilies.length) return '';
|
|
69
|
+
const familyQuery = uniqueFamilies
|
|
70
|
+
.map((family) => `family=${encodeURIComponent(family).replace(/%20/g, '+')}`)
|
|
71
|
+
.join('&');
|
|
72
|
+
const textQuery = text ? `&text=${encodeURIComponent(text)}` : '';
|
|
73
|
+
return `${baseUrl}?${familyQuery}${textQuery}&display=swap`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a safe inline font stack for a preview row.
|
|
78
|
+
*
|
|
79
|
+
* @param {Record<string, string>|null|undefined} font Normalized font record.
|
|
80
|
+
* @returns {string} CSS font-family value.
|
|
81
|
+
*/
|
|
82
|
+
export function fontStack(font) {
|
|
83
|
+
if (!font?.family) return DEFAULT_FALLBACK;
|
|
84
|
+
const family = font.family.replace(/[\\']/g, '\\$&');
|
|
85
|
+
const fallback = { serif: 'serif', monospace: 'monospace', handwriting: 'cursive', display: 'sans-serif', 'sans-serif': 'sans-serif' }[font.category] || DEFAULT_FALLBACK;
|
|
86
|
+
return `'${family}', ${fallback}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Score a font match so exact and prefix matches appear before loose matches.
|
|
91
|
+
*
|
|
92
|
+
* @param {Record<string, string>} font Normalized font record.
|
|
93
|
+
* @param {string} needle Lowercase search query.
|
|
94
|
+
* @returns {number} Lower scores sort first.
|
|
95
|
+
*/
|
|
96
|
+
function fontMatchRank(font, needle) {
|
|
97
|
+
const family = font.family.toLowerCase();
|
|
98
|
+
if (family === needle) return 0;
|
|
99
|
+
if (family.startsWith(needle)) return 1;
|
|
100
|
+
if (family.split(/\s+/).some((word) => word.startsWith(needle))) return 2;
|
|
101
|
+
return 3;
|
|
102
|
+
}
|