@getdom/studio 0.2.2 → 0.2.3
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 +52 -0
- package/package.json +1 -1
- package/src/pages/_layout/componentCatalog.generated.js +114 -12
- 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 +133 -3
- package/src/pages/lib/vue/index.js +1 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, onMounted, ref, shallowRef, useId, watch } from 'vue';
|
|
2
|
+
import { computed, nextTick, onMounted, ref, shallowRef, useId, watch } from 'vue';
|
|
3
3
|
import { resolveFloatingTeleportTarget } from '../../lib/vue/floatingTeleportTarget.js';
|
|
4
4
|
import DomFieldLoadingSpinner from '../_shared/DomFieldLoadingSpinner.vue';
|
|
5
|
-
import { booleanProp, normalizeOption } from '../_shared/options.js';
|
|
5
|
+
import { booleanProp, normalizeOption, searchableText } from '../_shared/options.js';
|
|
6
|
+
import DomOptionStatus from '../_shared/DomOptionStatus.vue';
|
|
7
|
+
import { useOptionPaging } from '../_shared/useOptionPaging.js';
|
|
8
|
+
import { useSelectedOptions } from '../_shared/useSelectedOptions.js';
|
|
6
9
|
import DomField from '../field/DomField.vue';
|
|
7
10
|
import { fieldProps } from '../field/fieldProps.js';
|
|
8
11
|
import { useField } from '../field/useField.js';
|
|
@@ -15,11 +18,15 @@ defineOptions({
|
|
|
15
18
|
tag: '<DomCombobox>',
|
|
16
19
|
description: 'Select-like combobox with a styled input, floating list, keyboard navigation, and query events for async lookups.',
|
|
17
20
|
slots: [
|
|
18
|
-
{ name: 'item', payload: '{ item, index }', description: 'Replaces
|
|
21
|
+
{ name: 'item', payload: '{ item, index, selected, query }', description: 'Replaces each option body. The control keeps the selected checkmark.' },
|
|
19
22
|
],
|
|
20
23
|
events: [
|
|
21
24
|
{ name: 'query', payload: '(query: string)', description: 'Fired as the user types. Use it to fetch or replace options.' },
|
|
22
25
|
{ name: 'select', payload: '({ item, value, label })', description: 'Fired when an option is selected. item is the original option object.' },
|
|
26
|
+
{ name: 'list-scroll', payload: '(event: Event)', description: 'Fired when the floating option list scrolls.' },
|
|
27
|
+
{ name: 'load-more', payload: '{ query }', description: 'Request the next page when hasMore is true. The parent owns fetching and appending options.' },
|
|
28
|
+
{ name: 'open', description: 'Fired when the options open with an empty transient query.' },
|
|
29
|
+
{ name: 'close', description: 'Fired when the options close.' },
|
|
23
30
|
],
|
|
24
31
|
},
|
|
25
32
|
});
|
|
@@ -72,11 +79,17 @@ const props = defineProps({
|
|
|
72
79
|
default: false,
|
|
73
80
|
_edit: { group: 'Control props', description: 'Show an inline spinner while async options are being fetched.' },
|
|
74
81
|
},
|
|
82
|
+
filterOptions: { type: [Boolean, String], default: true },
|
|
83
|
+
hasMore: { type: [Boolean, String], default: false },
|
|
84
|
+
selectedOptions: { type: Array, default: () => [] },
|
|
75
85
|
});
|
|
76
|
-
const emit = defineEmits(['update:modelValue', 'query', 'select', 'focus', 'blur']);
|
|
86
|
+
const emit = defineEmits(['update:modelValue', 'query', 'select', 'list-scroll', 'load-more', 'open', 'close', 'focus', 'blur']);
|
|
77
87
|
|
|
78
88
|
const root = ref(null);
|
|
79
89
|
const inputEl = ref(null);
|
|
90
|
+
const list = ref(null);
|
|
91
|
+
const query = ref('');
|
|
92
|
+
const isOpen = ref(false);
|
|
80
93
|
const isMounted = ref(false);
|
|
81
94
|
const teleportTarget = shallowRef(null);
|
|
82
95
|
const field = useField(props, emit, { idPrefix: 'dom-combobox' });
|
|
@@ -96,6 +109,11 @@ const inputPaddingClass = computed(() => {
|
|
|
96
109
|
const loadingPositionClass = computed(() => (isClearable.value && hasSelection.value ? 'right-[4.25rem]' : 'right-9'));
|
|
97
110
|
|
|
98
111
|
const optionList = computed(() => props.options.map((option) => normalizeOption(option)).filter(Boolean));
|
|
112
|
+
const selectedItems = useSelectedOptions(optionList, computed(() => [field.value.value]), () => props.selectedOptions);
|
|
113
|
+
const hasMoreOptions = computed(() => booleanProp(props.hasMore));
|
|
114
|
+
const filterLocally = computed(() => booleanProp(props.filterOptions, true));
|
|
115
|
+
const matchingOptions = computed(() => optionList.value.filter((option) => !filterLocally.value || searchableText(option, ['label', 'value', 'description', 'group', 'email', 'role']).includes(query.value.trim().toLowerCase())));
|
|
116
|
+
const paging = useOptionPaging(props, emit, { list, options: optionList, query, open: isOpen });
|
|
99
117
|
|
|
100
118
|
/**
|
|
101
119
|
* Resolve the user-facing text shown for a committed value.
|
|
@@ -104,7 +122,7 @@ const optionList = computed(() => props.options.map((option) => normalizeOption(
|
|
|
104
122
|
* @returns {string|number} Option label or raw value fallback.
|
|
105
123
|
*/
|
|
106
124
|
function displayForValue(value) {
|
|
107
|
-
const selected =
|
|
125
|
+
const selected = selectedItems.value.find((option) => String(option.value) === String(value));
|
|
108
126
|
return selected ? (selected.label ?? selected.value ?? '') : (value ?? '');
|
|
109
127
|
}
|
|
110
128
|
|
|
@@ -133,6 +151,7 @@ function syncInputAfterOptionsChange() {
|
|
|
133
151
|
onMounted(async () => {
|
|
134
152
|
teleportTarget.value = resolveFloatingTeleportTarget(root.value);
|
|
135
153
|
isMounted.value = true;
|
|
154
|
+
await nextTick();
|
|
136
155
|
await import('../../lib/headless/combobox.js');
|
|
137
156
|
syncInputFromModel();
|
|
138
157
|
root.value?.addEventListener('dom:change', (event) => field.onInput(event.detail.value));
|
|
@@ -146,8 +165,12 @@ onMounted(async () => {
|
|
|
146
165
|
});
|
|
147
166
|
});
|
|
148
167
|
root.value?.addEventListener('dom:query', (event) => {
|
|
168
|
+
query.value = event.detail.query;
|
|
149
169
|
emit('query', event.detail.query);
|
|
150
170
|
});
|
|
171
|
+
root.value?.addEventListener('dom:open', () => { isOpen.value = true; emit('open'); });
|
|
172
|
+
root.value?.addEventListener('dom:close', () => { isOpen.value = false; emit('close'); });
|
|
173
|
+
root.value?.addEventListener('dom:load-more', () => paging.requestMore());
|
|
151
174
|
});
|
|
152
175
|
|
|
153
176
|
watch(field.value, syncInputFromModel);
|
|
@@ -159,6 +182,7 @@ watch(optionList, syncInputAfterOptionsChange);
|
|
|
159
182
|
* @returns {void}
|
|
160
183
|
*/
|
|
161
184
|
function clearSelection() {
|
|
185
|
+
if (field.disabled.value || field.readOnly.value) return;
|
|
162
186
|
field.setValue('');
|
|
163
187
|
if (root.value) root.value.value = '';
|
|
164
188
|
if (inputEl.value) inputEl.value.value = '';
|
|
@@ -173,8 +197,11 @@ function clearSelection() {
|
|
|
173
197
|
<dom-combobox
|
|
174
198
|
ref="root"
|
|
175
199
|
:data-menu-id="listId"
|
|
200
|
+
:value-label="displayForValue(field.value.value)"
|
|
176
201
|
:placement="placement"
|
|
177
202
|
:floating-mode="floatingMode"
|
|
203
|
+
:filter-options="String(filterLocally)"
|
|
204
|
+
:has-more="hasMoreOptions ? '' : null"
|
|
178
205
|
class="relative block"
|
|
179
206
|
>
|
|
180
207
|
<input
|
|
@@ -197,6 +224,7 @@ function clearSelection() {
|
|
|
197
224
|
type="button"
|
|
198
225
|
class="absolute right-9 top-1 inline-flex size-8 items-center justify-center rounded-full text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
199
226
|
aria-label="Clear selection"
|
|
227
|
+
:disabled="field.disabled.value || field.readOnly.value"
|
|
200
228
|
@click.stop="clearSelection"
|
|
201
229
|
@mousedown.prevent
|
|
202
230
|
>
|
|
@@ -210,6 +238,7 @@ function clearSelection() {
|
|
|
210
238
|
type="button"
|
|
211
239
|
class="absolute right-1 top-1 inline-flex size-8 items-center justify-center rounded-full text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
212
240
|
aria-label="Show options"
|
|
241
|
+
:disabled="field.disabled.value || field.readOnly.value"
|
|
213
242
|
>
|
|
214
243
|
<svg viewBox="0 0 20 20" class="size-4" fill="none">
|
|
215
244
|
<path d="M6 8l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
|
@@ -218,7 +247,11 @@ function clearSelection() {
|
|
|
218
247
|
<Teleport :to="teleportTarget || 'body'" :disabled="!isMounted || !teleportTarget">
|
|
219
248
|
<ul
|
|
220
249
|
:id="listId"
|
|
250
|
+
ref="list"
|
|
251
|
+
hidden
|
|
252
|
+
:aria-busy="isLoading"
|
|
221
253
|
class="dom-floating-transition skin-popover z-50 max-h-[min(15rem,var(--dom-floating-available-height))] overflow-auto rounded-2xl p-1"
|
|
254
|
+
@scroll="paging.onScroll"
|
|
222
255
|
>
|
|
223
256
|
<li
|
|
224
257
|
v-for="(option, index) in optionList"
|
|
@@ -226,10 +259,14 @@ function clearSelection() {
|
|
|
226
259
|
:data-value="option.value"
|
|
227
260
|
:data-label="option.label"
|
|
228
261
|
:data-index="index"
|
|
229
|
-
|
|
262
|
+
:data-search="searchableText(option, ['label', 'value', 'description', 'group', 'email', 'role'])"
|
|
263
|
+
:aria-disabled="option.disabled || undefined"
|
|
264
|
+
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-sm transition data-[active]:bg-accent aria-selected:bg-accent aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
|
230
265
|
>
|
|
231
|
-
<slot name="item" :item="option" :index="index">{{ option.label }}</slot>
|
|
266
|
+
<span class="min-w-0 flex-1"><slot name="item" :item="option" :index="index" :selected="String(option.value) === String(field.value.value)" :query="query">{{ option.label }}</slot></span>
|
|
267
|
+
<svg v-if="String(option.value) === String(field.value.value)" viewBox="0 0 20 20" class="size-4 shrink-0" fill="none" aria-hidden="true"><path d="M4.5 10.5 8 14l7.5-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
232
268
|
</li>
|
|
269
|
+
<DomOptionStatus as="li" :loading="isLoading" :has-more="hasMoreOptions" :empty="!matchingOptions.length" @load-more="paging.requestMore(true)" />
|
|
233
270
|
</ul>
|
|
234
271
|
</Teleport>
|
|
235
272
|
</dom-combobox>
|
|
@@ -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>
|