@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.
@@ -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 the rendering of each suggestion.' },
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="props.disabled || props.readOnly || undefined"
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
- class="cursor-pointer 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"
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 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>