@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
|
@@ -1,8 +1,10 @@
|
|
|
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';
|
|
6
8
|
import DomField from '../field/DomField.vue';
|
|
7
9
|
import { fieldProps } from '../field/fieldProps.js';
|
|
8
10
|
import { useField } from '../field/useField.js';
|
|
@@ -15,12 +17,15 @@ defineOptions({
|
|
|
15
17
|
tag: '<DomAutocomplete>',
|
|
16
18
|
description: 'Free-text autocomplete with optional suggestions, floating positioning, keyboard navigation, and query events for async lookups.',
|
|
17
19
|
slots: [
|
|
18
|
-
{ name: 'item', payload: '{ item, index }', description: 'Replaces
|
|
20
|
+
{ name: 'item', payload: '{ item, index, selected, query }', description: 'Replaces each suggestion body without replacing the selection checkmark.' },
|
|
19
21
|
],
|
|
20
22
|
events: [
|
|
21
23
|
{ name: 'query', payload: '(query: string)', description: 'Fired as the user types. Use it to fetch or replace suggestions.' },
|
|
22
24
|
{ name: 'select', payload: '({ item, value, label, text, query })', description: 'Fired when a suggestion is chosen. item is the original option object.' },
|
|
23
25
|
{ name: 'commit', payload: '({ value, label, custom })', description: 'Fired when the current text is committed with Enter.' },
|
|
26
|
+
{ name: 'load-more', payload: '{ query }', description: 'Request the next page when hasMore is true.' },
|
|
27
|
+
{ name: 'open', description: 'Fired when suggestions open.' },
|
|
28
|
+
{ name: 'close', description: 'Fired when suggestions close.' },
|
|
24
29
|
],
|
|
25
30
|
},
|
|
26
31
|
});
|
|
@@ -68,11 +73,16 @@ const props = defineProps({
|
|
|
68
73
|
default: false,
|
|
69
74
|
_edit: { group: 'Control props', description: 'Show an inline spinner while async suggestions are being fetched.' },
|
|
70
75
|
},
|
|
76
|
+
filterOptions: { type: [Boolean, String], default: true },
|
|
77
|
+
hasMore: { type: [Boolean, String], default: false },
|
|
71
78
|
});
|
|
72
|
-
const emit = defineEmits(['update:modelValue', 'query', 'select', 'commit', 'focus', 'blur']);
|
|
79
|
+
const emit = defineEmits(['update:modelValue', 'query', 'select', 'commit', 'load-more', 'list-scroll', 'open', 'close', 'focus', 'blur']);
|
|
73
80
|
|
|
74
81
|
const root = ref(null);
|
|
75
82
|
const inputEl = ref(null);
|
|
83
|
+
const list = ref(null);
|
|
84
|
+
const query = ref('');
|
|
85
|
+
const isOpen = ref(false);
|
|
76
86
|
const isMounted = ref(false);
|
|
77
87
|
const teleportTarget = shallowRef(null);
|
|
78
88
|
const currentText = ref('');
|
|
@@ -80,6 +90,10 @@ const field = useField(props, emit, { idPrefix: 'dom-autocomplete' });
|
|
|
80
90
|
const isLoading = computed(() => booleanProp(props.loading, false));
|
|
81
91
|
|
|
82
92
|
const items = computed(() => props.options.map((option) => normalizeOption(option)).filter(Boolean));
|
|
93
|
+
const hasMoreOptions = computed(() => booleanProp(props.hasMore));
|
|
94
|
+
const filterLocally = computed(() => booleanProp(props.filterOptions, true));
|
|
95
|
+
const matchingOptions = computed(() => items.value.filter((option) => !filterLocally.value || searchableText(option, ['label', 'value', 'description', 'group', 'email', 'role']).includes(query.value.trim().toLowerCase())));
|
|
96
|
+
const paging = useOptionPaging(props, emit, { list, options: items, query, open: isOpen });
|
|
83
97
|
|
|
84
98
|
/**
|
|
85
99
|
* Synchronize the free-text input from the Vue model value.
|
|
@@ -94,13 +108,17 @@ function syncInputFromModel() {
|
|
|
94
108
|
onMounted(async () => {
|
|
95
109
|
teleportTarget.value = resolveFloatingTeleportTarget(root.value);
|
|
96
110
|
isMounted.value = true;
|
|
111
|
+
await nextTick();
|
|
97
112
|
await import('../../lib/headless/autocomplete.js');
|
|
98
113
|
syncInputFromModel();
|
|
99
114
|
root.value?.addEventListener('dom:input', (event) => {
|
|
100
115
|
currentText.value = event.detail.value;
|
|
101
116
|
field.onInput(event.detail.value);
|
|
102
117
|
});
|
|
103
|
-
root.value?.addEventListener('dom:query', (event) => emit('query', event.detail.query));
|
|
118
|
+
root.value?.addEventListener('dom:query', (event) => { query.value = event.detail.query; emit('query', event.detail.query); });
|
|
119
|
+
root.value?.addEventListener('dom:open', () => { isOpen.value = true; emit('open'); });
|
|
120
|
+
root.value?.addEventListener('dom:close', () => { isOpen.value = false; emit('close'); });
|
|
121
|
+
root.value?.addEventListener('dom:load-more', () => paging.requestMore());
|
|
104
122
|
root.value?.addEventListener('dom:select', (event) => {
|
|
105
123
|
const index = Number(event.detail.option?.dataset.index);
|
|
106
124
|
const item = Number.isFinite(index) ? items.value[index] : null;
|
|
@@ -131,6 +149,8 @@ watch(field.value, syncInputFromModel);
|
|
|
131
149
|
:value="field.value.value ?? null"
|
|
132
150
|
:placement="placement"
|
|
133
151
|
:floating-mode="floatingMode"
|
|
152
|
+
:filter-options="String(filterLocally)"
|
|
153
|
+
:has-more="hasMoreOptions ? '' : null"
|
|
134
154
|
class="relative block"
|
|
135
155
|
>
|
|
136
156
|
<input
|
|
@@ -152,7 +172,7 @@ watch(field.value, syncInputFromModel);
|
|
|
152
172
|
type="button"
|
|
153
173
|
class="absolute right-1 top-1 inline-flex size-8 items-center justify-center rounded-md text-muted-fg transition hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-not-allowed disabled:opacity-50"
|
|
154
174
|
aria-label="Show suggestions"
|
|
155
|
-
:disabled="
|
|
175
|
+
:disabled="field.disabled.value || field.readOnly.value || undefined"
|
|
156
176
|
>
|
|
157
177
|
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
|
|
158
178
|
<path d="M6 8l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
|
@@ -161,6 +181,10 @@ watch(field.value, syncInputFromModel);
|
|
|
161
181
|
<Teleport :to="teleportTarget || 'body'" :disabled="!isMounted || !teleportTarget">
|
|
162
182
|
<ul
|
|
163
183
|
:id="listId"
|
|
184
|
+
ref="list"
|
|
185
|
+
hidden
|
|
186
|
+
:aria-busy="isLoading"
|
|
187
|
+
@scroll="paging.onScroll"
|
|
164
188
|
class="dom-floating-transition skin-popover z-50 max-h-[min(15rem,var(--dom-floating-available-height))] overflow-auto rounded-2xl p-1"
|
|
165
189
|
>
|
|
166
190
|
<li
|
|
@@ -169,10 +193,14 @@ watch(field.value, syncInputFromModel);
|
|
|
169
193
|
:data-value="option.value"
|
|
170
194
|
:data-label="option.label"
|
|
171
195
|
:data-index="index"
|
|
172
|
-
|
|
196
|
+
:data-search="searchableText(option, ['label', 'value', 'description', 'group', 'email', 'role'])"
|
|
197
|
+
:aria-disabled="option.disabled || undefined"
|
|
198
|
+
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-sm text-canvas-fg transition hover:bg-accent hover:text-accent-fg data-[active]:bg-accent data-[active]:text-accent-fg aria-selected:bg-accent aria-selected:text-accent-fg aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
|
173
199
|
>
|
|
174
|
-
<slot name="item" :item="option" :index="index">{{ option.label }}</slot>
|
|
200
|
+
<span class="min-w-0 flex-1"><slot name="item" :item="option" :index="index" :selected="option.label === field.value.value" :query="query">{{ option.label }}</slot></span>
|
|
201
|
+
<svg v-if="option.label === 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>
|
|
175
202
|
</li>
|
|
203
|
+
<DomOptionStatus as="li" :loading="isLoading" :has-more="hasMoreOptions" :empty="!matchingOptions.length" @load-more="paging.requestMore(true)" />
|
|
176
204
|
</ul>
|
|
177
205
|
</Teleport>
|
|
178
206
|
</dom-autocomplete>
|
|
@@ -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>
|