@meistrari/tela-build 1.67.0 → 1.68.0

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.
@@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
32
32
  >
33
33
  <slot />
34
34
  </ComboboxInput>
35
- <div v-if="$slots.deleteSearchValue" class="absolute inset-y-0 end-0 flex items-center justify-center px-2.5">
35
+ <div v-if="$slots.deleteSearchValue" class="absolute inset-y-0 end-0 flex items-center justify-center px-1">
36
36
  <slot name="deleteSearchValue" />
37
37
  </div>
38
38
  </div>
@@ -173,6 +173,23 @@ const options = [
173
173
  />
174
174
  ```
175
175
 
176
+ ### Allow Create
177
+
178
+ Pins a "create new" action at the top of the list. It is never filtered out by the search — reka-ui registers an item's filter text once on mount, so the row is keyed by the search term to re-register as `textValue` and always match. When the user has typed something, `labelCreateWithSearch` renders the label with that term.
179
+
180
+ Picking it turns the row into a text input seeded with the current search term instead of closing the list. `create` is emitted with the confirmed name once the user presses Enter or clicks the check, so it never carries an empty name. Escape returns the row to the action.
181
+
182
+ ```vue
183
+ <TelaCombobox
184
+ v-model="selected"
185
+ :options="projects"
186
+ allow-create
187
+ label-create="Create a new project"
188
+ :label-create-with-search="(search) => `Create \"${search}\"`"
189
+ @create="(search) => startCreating(search)"
190
+ />
191
+ ```
192
+
176
193
  ### Disable Portal
177
194
 
178
195
  ```vue
@@ -318,6 +335,7 @@ The Combobox system consists of these sub-components:
318
335
 
319
336
  - `update:modelValue` - Emitted when selection changes with new value
320
337
  - `select` - Emitted when an option is selected with the value
338
+ - `create` - Emitted with the name typed into the `allow-create` row, once the user confirms it
321
339
 
322
340
  ## Keyboard Shortcuts
323
341
 
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Component, ComponentPublicInstance, Ref } from 'vue'
3
- import { useMagicKeys } from '@vueuse/core'
3
+ import { useEventListener, useMagicKeys } from '@vueuse/core'
4
4
 
5
5
  interface ComboboxOption {
6
6
  value: string
@@ -63,6 +63,11 @@ const props = withDefaults(defineProps<{
63
63
  labelMultimodal?: string
64
64
  labelInputMax?: string
65
65
  labelOutputMax?: string
66
+ allowCreate?: boolean
67
+ labelCreate?: string
68
+ labelCreateDescription?: string
69
+ labelCreateWithSearch?: (search: string) => string
70
+ labelCreatePlaceholder?: string
66
71
  }>(), {
67
72
  compact: true,
68
73
  align: 'start',
@@ -87,17 +92,27 @@ const props = withDefaults(defineProps<{
87
92
  labelMultimodal: 'Multimodal',
88
93
  labelInputMax: 'Input max',
89
94
  labelOutputMax: 'Output max',
95
+ allowCreate: false,
96
+ labelCreate: 'Create new',
97
+ labelCreatePlaceholder: 'Name',
90
98
  })
91
99
 
92
100
  const emit = defineEmits<{
93
101
  'update:modelValue': [value: string]
94
102
  'select': [value: string]
103
+ 'create': [search: string]
95
104
  }>()
96
105
 
106
+ // Per-instance so a consumer-supplied option value can never be mistaken for the synthetic create row.
107
+ const createOptionValue = `__tela-combobox-create__${useId()}`
108
+
97
109
  const selectedTab = ref('all')
98
110
  const search = ref('')
99
111
  const innerValue = ref<string>('')
100
112
  const isOpen = ref(false)
113
+ const isCreating = ref(false)
114
+ const createName = ref('')
115
+ const createInputEl = ref<HTMLInputElement>()
101
116
 
102
117
  const currentOption = computed(() => {
103
118
  if (!innerValue.value && !props.modelValue && props.placeholder) {
@@ -170,7 +185,7 @@ const groupedOptions = computed(() => {
170
185
  }
171
186
 
172
187
  if (!hasGroups.value) {
173
- return [{ heading: props.labelOptionsGroup, children: filteredOptions }]
188
+ return filteredOptions.length ? [{ heading: props.labelOptionsGroup, children: filteredOptions }] : []
174
189
  }
175
190
 
176
191
  const groups: Record<string, typeof filteredOptions> = {}
@@ -193,6 +208,88 @@ const groupedOptions = computed(() => {
193
208
 
194
209
  const isEmpty = computed(() => groupedOptions.value.every(group => group.children.length === 0))
195
210
 
211
+ const showCreateOption = computed(() => props.allowCreate && !isCreating.value && search.value.trim().length > 0 && isEmpty.value)
212
+
213
+ const createOption = computed(() => {
214
+ const term = search.value.trim()
215
+ return {
216
+ value: createOptionValue,
217
+ label: term && props.labelCreateWithSearch ? props.labelCreateWithSearch(term) : props.labelCreate,
218
+ }
219
+ })
220
+
221
+ let clearSearchOnOpen = false
222
+
223
+ function startCreating() {
224
+ createName.value = search.value.trim()
225
+ isCreating.value = true
226
+ nextTick(() => createInputEl.value?.focus())
227
+ }
228
+
229
+ function resetCreating() {
230
+ isCreating.value = false
231
+ createName.value = ''
232
+ }
233
+
234
+ function cancelCreating() {
235
+ if (createInputEl.value && document.activeElement === createInputEl.value) {
236
+ const searchInput = createInputEl.value.closest('.ComboboxContent')?.querySelector('input')
237
+ searchInput?.focus()
238
+ }
239
+
240
+ resetCreating()
241
+ }
242
+
243
+ function confirmCreating() {
244
+ const name = createName.value.trim()
245
+ if (!name)
246
+ return
247
+
248
+ clearSearchOnOpen = true
249
+ isOpen.value = false
250
+ emit('create', name)
251
+
252
+ // Reka refocuses the trigger when the list unmounts; after a keyboard confirm that paints
253
+ // a focus-visible ring on it, which reads as if the field were still active.
254
+ nextTick(() => {
255
+ const active = document.activeElement
256
+ if (active instanceof HTMLElement && active.getAttribute('aria-haspopup') === 'listbox')
257
+ active.blur()
258
+ })
259
+ }
260
+
261
+ function onCreateKeydown(event: KeyboardEvent) {
262
+ // The list owns arrow/enter navigation, so the input has to keep its keys to itself.
263
+ event.stopPropagation()
264
+
265
+ if (event.key === 'Enter') {
266
+ event.preventDefault()
267
+ confirmCreating()
268
+ }
269
+ }
270
+
271
+ // Escape is handled here, at window capture, instead of being left to Reka: TelaModal is a
272
+ // radix-vue Dialog with its own dismissable-layer stack, so both libraries believe they own the
273
+ // top layer and a single Esc would close the list and the modal together. Owning the key while
274
+ // the list is open gives the expected sequence: leave create mode, close the list, close the modal.
275
+ useEventListener(window, 'keydown', (event) => {
276
+ if (event.key !== 'Escape' || !isOpen.value)
277
+ return
278
+
279
+ event.preventDefault()
280
+ event.stopImmediatePropagation()
281
+
282
+ if (isCreating.value)
283
+ cancelCreating()
284
+ else
285
+ onOpenChange(false)
286
+ }, { capture: true })
287
+
288
+ watch(search, () => {
289
+ if (isCreating.value)
290
+ cancelCreating()
291
+ })
292
+
196
293
  const handleDeleteSearchValue = () => search.value = ''
197
294
 
198
295
  const keys = useMagicKeys()
@@ -261,7 +358,15 @@ function updateSubmenuPosition(itemValue: string) {
261
358
 
262
359
  function onOpenChange(open: boolean) {
263
360
  isOpen.value = open
264
- if (!open) {
361
+
362
+ if (open) {
363
+ resetCreating()
364
+ if (clearSearchOnOpen) {
365
+ search.value = ''
366
+ clearSearchOnOpen = false
367
+ }
368
+ }
369
+ else {
265
370
  expandedItem.value = null
266
371
  }
267
372
  }
@@ -287,6 +392,11 @@ function handleChildSelect(child: ComboboxOption, event: Event) {
287
392
 
288
393
  function handleSelect(option: any) {
289
394
  const value = typeof option === 'string' ? option : option?.value
395
+ if (value === createOptionValue) {
396
+ innerValue.value = ''
397
+ startCreating()
398
+ return
399
+ }
290
400
  const found = props.options.find(o => o.value === value)
291
401
  if (found?.children?.length)
292
402
  return
@@ -299,7 +409,7 @@ watch(innerValue, (raw) => {
299
409
  return
300
410
 
301
411
  const value = typeof raw === 'string' ? raw : (raw as any)?.value
302
- if (!value)
412
+ if (!value || value === createOptionValue)
303
413
  return
304
414
 
305
415
  const option = props.options.find(o => o.value === value)
@@ -355,7 +465,16 @@ watch(innerValue, (raw) => {
355
465
  v-if="hasSearchbar"
356
466
  v-model="search"
357
467
  :placeholder="inputPlaceholder || labelSearchPlaceholder"
358
- />
468
+ >
469
+ <template v-if="allowCreate && !isCreating" #deleteSearchValue>
470
+ <TelaIconButton
471
+ icon="i-ph-plus"
472
+ size="sm"
473
+ color="secondary"
474
+ @click="startCreating"
475
+ />
476
+ </template>
477
+ </TelaComboboxInput>
359
478
  <TelaTabsRoot v-if="hasTabs" v-model="selectedTab">
360
479
  <TelaTabsList class="sticky z-10 top-0 shrink-0 flex gap-2 w-full">
361
480
  <TelaTabsIndicator />
@@ -365,8 +484,58 @@ watch(innerValue, (raw) => {
365
484
  </TelaTabsList>
366
485
  </TelaTabsRoot>
367
486
  <div :class="cn('max-h-262px overflow-y-auto no-scrollbar', hasTabs && 'pt-2')">
487
+ <div v-if="isCreating || showCreateOption" :class="cn('px-4px pt-4px', isEmpty && 'pb-4px')">
488
+ <div
489
+ v-if="isCreating"
490
+ flex items-center h-36px rounded-12px
491
+ :class="cn(
492
+ 'outline-solid outline-[1px] outline-offset-[-1px] outline-border',
493
+ !compact && 'px-6px',
494
+ compact ? 'gap-2' : 'gap-1.5',
495
+ )"
496
+ >
497
+ <div w-5 h-5 shrink-0 flex items-center justify-center>
498
+ <TelaIcon name="i-ph-plus" color="icon-tertiary" />
499
+ </div>
500
+ <input
501
+ ref="createInputEl"
502
+ v-model="createName"
503
+ :placeholder="labelCreatePlaceholder"
504
+ min-w-0 flex-1 bg-transparent text-primary outline-none
505
+ placeholder:text-tertiary
506
+ :class="compact ? 'body-12-regular font-540' : 'body-14-regular'"
507
+ @keydown="onCreateKeydown"
508
+ >
509
+ <TelaIconButton
510
+ icon="i-ph-check"
511
+ size="xs"
512
+ :disabled="!createName.trim()"
513
+ @click="confirmCreating"
514
+ />
515
+ </div>
516
+ <TelaComboboxItem
517
+ v-else-if="showCreateOption"
518
+ :key="search"
519
+ :value="createOption"
520
+ :text-value="search"
521
+ :class="cn('py-2', !compact && '!px-1.5')"
522
+ @select="(e) => { e.preventDefault(); startCreating() }"
523
+ >
524
+ <div flex items-center min-w-0 :class="compact ? 'gap-2' : 'gap-1.5'">
525
+ <div w-5 h-5 shrink-0 flex items-center justify-center>
526
+ <TelaIcon name="i-ph-plus" color="icon-tertiary" />
527
+ </div>
528
+ <div flex flex-col min-w-0>
529
+ <span font-medium truncate text-tertiary :class="cn(compact ? 'text-sm font-540' : 'text-body-14-regular', labelItemClass)">{{ createOption.label }}</span>
530
+ <span v-if="labelCreateDescription" font-normal text-sm leading-none text-tertiary truncate :class="descriptionClass">
531
+ {{ labelCreateDescription }}
532
+ </span>
533
+ </div>
534
+ </div>
535
+ </TelaComboboxItem>
536
+ </div>
368
537
  <TelaComboboxEmpty
369
- v-if="isEmpty"
538
+ v-if="isEmpty && !showCreateOption && !isCreating"
370
539
  class="flex flex-col items-center justify-center gap-5 px-7"
371
540
  >
372
541
  <div class="flex flex-col items-center justify-center gap-3">
@@ -425,11 +594,11 @@ watch(innerValue, (raw) => {
425
594
  </div>
426
595
  <div class="flex flex-col">
427
596
  <div class="flex items-center gap-1">
428
- <span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-580' : 'text-body-14-regular', labelItemClass)">{{ item.label }}</span>
597
+ <span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-540' : 'text-body-14-regular', labelItemClass)">{{ item.label }}</span>
429
598
  <TelaBadge v-if="item.isMultiModal" variant="filled" class="py-[2px] bg-muted" text-class="leading-none">
430
599
  {{ labelMultimodal }}
431
600
  </TelaBadge>
432
- <span v-if="item.cost !== undefined" class="text-10px font-580 ml-1px">
601
+ <span v-if="item.cost !== undefined" class="text-10px font-540 ml-1px">
433
602
  <span class="text-primary">{{ renderCostIndicator(item.cost)?.blackSymbols }}</span>
434
603
  <span class="text-neutral-300">{{ renderCostIndicator(item.cost)?.graySymbols }}</span>
435
604
  </span>
@@ -494,7 +663,7 @@ watch(innerValue, (raw) => {
494
663
  <Component :is="child.icon" v-else />
495
664
  </div>
496
665
  <div class="flex flex-col">
497
- <span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-580' : 'text-body-14-regular', labelItemClass)">{{ child.label }}</span>
666
+ <span :class="cn('font-medium truncate max-w-140px', compact ? 'text-sm font-540' : 'text-body-14-regular', labelItemClass)">{{ child.label }}</span>
498
667
  <span v-if="child.description" :class="cn('font-normal text-sm leading-none text-secondary', descriptionClass)">
499
668
  {{ child.description }}
500
669
  </span>
@@ -33,7 +33,7 @@ const style = computed(() => {
33
33
  const size = ({
34
34
  '3xs': 'text-6px p-1 rounded-8px',
35
35
  '2xs': 'text-8px p-4px rounded-4px', // 16px
36
- 'xs': 'text-12px p-6px rounded-6px', // 26px
36
+ 'xs': 'text-12px p-6px rounded-8px', // 26px
37
37
  'sm': 'text-16px p-6px rounded-10px', // 32px
38
38
  'md': 'text-20px p-6.4px rounded-10px', // 44px
39
39
  } as Record<typeof props.size, string>)[props.size]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",
@@ -73,20 +73,21 @@
73
73
  "typescript": "5.8.2",
74
74
  "unified": "11.0.5",
75
75
  "unocss": "66.5.12",
76
- "vue": "3.5.17",
77
76
  "vue-component-meta": "3.0.8",
78
77
  "vue-docgen-api": "4.78.0",
79
78
  "vue-input-otp": "0.3.2",
80
79
  "vue-router": "4.5.0"
81
80
  },
82
81
  "peerDependencies": {
83
- "nuxt": "^3.17.0 || ^4.0.0"
82
+ "nuxt": "^3.17.0 || ^4.0.0",
83
+ "vue": "^3.5.13"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@nuxt/kit": "3.17.7",
87
87
  "@types/node": "18.19.79",
88
88
  "fs-extra": "11.2.0",
89
89
  "gray-matter": "4.0.3",
90
- "nuxt": "3.17.7"
90
+ "nuxt": "3.17.7",
91
+ "vue": "3.5.17"
91
92
  }
92
93
  }