@getdom/studio 0.2.1 → 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.
Files changed (30) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE.md +23 -9
  3. package/package.json +1 -1
  4. package/src/pages/_layout/componentCatalog.generated.js +115 -13
  5. package/src/pages/components/command-palette/DomCommandPalette.vue +6 -1
  6. package/src/pages/data/agent-chat/DomAgentChatShell.vue +52 -53
  7. package/src/pages/data/agent-chat/DomAgentConversation.vue +163 -154
  8. package/src/pages/data/agent-chat/DomAgentConversationList.vue +53 -57
  9. package/src/pages/data/month-calendar/DomMonthCalendar.vue +8 -9
  10. package/src/pages/forms/_shared/DomOptionStatus.vue +18 -0
  11. package/src/pages/forms/_shared/useOptionMenu.js +18 -2
  12. package/src/pages/forms/_shared/useOptionPaging.js +42 -0
  13. package/src/pages/forms/_shared/useSelectedOptions.js +25 -0
  14. package/src/pages/forms/autocomplete/DomAutocomplete.vue +36 -8
  15. package/src/pages/forms/combobox/DomCombobox.vue +44 -7
  16. package/src/pages/forms/font-picker/DomFontPicker.vue +416 -0
  17. package/src/pages/forms/font-picker/FontPreview.vue +39 -0
  18. package/src/pages/forms/font-picker/fontPicker.js +102 -0
  19. package/src/pages/forms/font-picker/googleFonts.js +1951 -0
  20. package/src/pages/forms/select/DomSelect.vue +118 -29
  21. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +67 -15
  22. package/src/pages/lib/headless/autocomplete.js +5 -0
  23. package/src/pages/lib/headless/combobox.js +129 -25
  24. package/src/pages/lib/headless/dropdown.js +3 -2
  25. package/src/pages/lib/headless/listbox.d.ts +1 -0
  26. package/src/pages/lib/headless/listbox.js +41 -5
  27. package/src/pages/lib/vue/index.d.ts +136 -6
  28. package/src/pages/lib/vue/index.js +1 -0
  29. package/src/tailwind.css +5 -1
  30. package/src/theme.css +4 -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 the rendering of each option.' },
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 = optionList.value.find((option) => String(option.value ?? option.label) === String(value));
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
- class="cursor-pointer rounded-xl px-3 py-2 text-sm transition data-[active]:bg-accent aria-selected:bg-accent"
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>