@saasmakers/ui 2.0.59 → 2.0.61

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.
@@ -0,0 +1,217 @@
1
+ <script lang="ts" setup>
2
+ import type { BaseRing } from '../../types/bases'
3
+
4
+ const props = withDefaults(defineProps<BaseRing>(), {
5
+ clickable: false,
6
+ completed: 0,
7
+ dashed: false,
8
+ format: 'count',
9
+ size: 'base',
10
+ total: 0,
11
+ transitionColors: false,
12
+ })
13
+
14
+ const emit = defineEmits<{
15
+ click: [event: MouseEvent]
16
+ }>()
17
+
18
+ const sizes = {
19
+ base: {
20
+ inset: 1,
21
+ size: 28,
22
+ strokeWidth: 3,
23
+ },
24
+ lg: {
25
+ inset: 0,
26
+ size: 36,
27
+ strokeWidth: 4,
28
+ },
29
+ } as const
30
+
31
+ const geometry = computed(() => {
32
+ const { inset, size, strokeWidth } = sizes[props.size]
33
+ const center = size / 2
34
+ const radius = center - strokeWidth / 2 - inset
35
+
36
+ return {
37
+ center,
38
+ circumference: 2 * Math.PI * radius,
39
+ radius,
40
+ size,
41
+ strokeWidth,
42
+ }
43
+ })
44
+
45
+ const clampedCompleted = computed(() => {
46
+ return Math.max(0, Math.min(props.completed, props.total))
47
+ })
48
+
49
+ const percentage = computed(() => {
50
+ if (props.total <= 0) {
51
+ return 0
52
+ }
53
+
54
+ return Math.round((clampedCompleted.value / props.total) * 100)
55
+ })
56
+
57
+ const centerLabel = computed(() => {
58
+ if (props.format === 'none' || props.total <= 0) {
59
+ return undefined
60
+ }
61
+
62
+ if (props.format === 'percent') {
63
+ return `${percentage.value}%`
64
+ }
65
+
66
+ return String(clampedCompleted.value)
67
+ })
68
+
69
+ const progressColor = computed(() => {
70
+ const progressPercentage = percentage.value
71
+
72
+ if (progressPercentage === 100) {
73
+ return 'var(--indigo-800)'
74
+ }
75
+
76
+ if (progressPercentage >= 75) {
77
+ return 'var(--indigo-700)'
78
+ }
79
+
80
+ if (progressPercentage >= 50) {
81
+ return 'var(--indigo-500)'
82
+ }
83
+
84
+ if (progressPercentage >= 25) {
85
+ return 'var(--indigo-400)'
86
+ }
87
+
88
+ return 'var(--indigo-300)'
89
+ })
90
+
91
+ // Visual gap (in viewBox units) between segments, widened for fewer steps.
92
+ function gapForTotal(total: number) {
93
+ if (total <= 4) {
94
+ return 3
95
+ }
96
+
97
+ if (total <= 6) {
98
+ return 2.5
99
+ }
100
+
101
+ return 2
102
+ }
103
+
104
+ const ringArcs = computed(() => {
105
+ const arcs: BaseRingArc[] = []
106
+ const { circumference } = geometry.value
107
+ const completed = clampedCompleted.value
108
+ const { total } = props
109
+
110
+ if (total <= 0) {
111
+ arcs.push({
112
+ dashArray: props.dashed ? '3 3' : undefined,
113
+ filled: false,
114
+ })
115
+
116
+ return arcs
117
+ }
118
+
119
+ if (completed === total) {
120
+ arcs.push({ filled: true })
121
+
122
+ return arcs
123
+ }
124
+
125
+ // Beyond 8 steps individual segments become too thin, so we collapse them
126
+ // into a single continuous progress arc.
127
+ if (total > 8) {
128
+ const progressLength = (circumference * completed) / total
129
+
130
+ arcs.push(
131
+ { filled: false },
132
+ {
133
+ dashArray: `${progressLength} ${circumference}`,
134
+ dashOffset: '0',
135
+ filled: true,
136
+ },
137
+ )
138
+
139
+ return arcs
140
+ }
141
+
142
+ const gap = gapForTotal(total)
143
+ const arcPerSegment = circumference / total
144
+ const segmentLength = Math.max(arcPerSegment - gap, 0)
145
+
146
+ for (let index = 0; index < total; index += 1) {
147
+ arcs.push({
148
+ dashArray: `${segmentLength} ${circumference - segmentLength}`,
149
+ dashOffset: `${-index * arcPerSegment}`,
150
+ filled: index < completed,
151
+ })
152
+ }
153
+
154
+ return arcs
155
+ })
156
+
157
+ function onClick(event: MouseEvent) {
158
+ if (props.clickable) {
159
+ emit('click', event)
160
+ }
161
+ }
162
+ </script>
163
+
164
+ <template>
165
+ <component
166
+ :is="clickable ? 'button' : 'div'"
167
+ class="inline-flex"
168
+ :aria-expanded="expanded"
169
+ :aria-label="label"
170
+ :class="{ 'cursor-pointer': clickable }"
171
+ :type="clickable ? 'button' : undefined"
172
+ @click="onClick"
173
+ >
174
+ <svg
175
+ class="size-full"
176
+ :height="geometry.size"
177
+ :viewBox="`0 0 ${geometry.size} ${geometry.size}`"
178
+ :width="geometry.size"
179
+ aria-hidden="true"
180
+ focusable="false"
181
+ >
182
+ <circle
183
+ v-for="(arc, index) in ringArcs"
184
+ :key="index"
185
+ :class="{
186
+ 'stroke-gray-300 dark:stroke-gray-700': !arc.filled,
187
+ 'transition-[stroke] duration-150 motion-reduce:transition-none': transitionColors,
188
+ }"
189
+ :cx="geometry.center"
190
+ :cy="geometry.center"
191
+ fill="none"
192
+ :r="geometry.radius"
193
+ :stroke="arc.filled ? progressColor : undefined"
194
+ :stroke-dasharray="arc.dashArray"
195
+ :stroke-dashoffset="arc.dashOffset"
196
+ stroke-linecap="butt"
197
+ :stroke-width="geometry.strokeWidth"
198
+ :transform="`rotate(-90 ${geometry.center} ${geometry.center})`"
199
+ />
200
+
201
+ <text
202
+ v-if="centerLabel"
203
+ class="fill-gray-600 font-bold tabular-nums dark:fill-gray-400"
204
+ :class="{
205
+ 'text-2xs': size === 'base',
206
+ 'text-3xs': size === 'lg',
207
+ }"
208
+ dominant-baseline="central"
209
+ text-anchor="middle"
210
+ :x="geometry.center"
211
+ :y="geometry.center"
212
+ >
213
+ {{ centerLabel }}
214
+ </text>
215
+ </svg>
216
+ </component>
217
+ </template>
@@ -4,7 +4,7 @@ import type { BaseTags } from '../../types/bases'
4
4
  const props = withDefaults(defineProps<BaseTags>(), {
5
5
  size: 'base',
6
6
  tags: () => [],
7
- wrap: false,
7
+ wrap: true,
8
8
  })
9
9
 
10
10
  const sortedTags = computed(() => {
@@ -0,0 +1,568 @@
1
+ <script lang="ts" setup generic="Item extends object">
2
+ import type { FieldCombobox } from '../../types/fields'
3
+
4
+ const props = withDefaults(defineProps<Omit<FieldCombobox<Item>, 'modelValue'>>(), {
5
+ allowCreate: false,
6
+ border: 'full',
7
+ description: '',
8
+ disabled: false,
9
+ hideError: false,
10
+ itemKey: 'id',
11
+ itemLabel: 'name',
12
+ items: () => [],
13
+ label: '',
14
+ labelIcon: undefined,
15
+ placeholder: '',
16
+ required: false,
17
+ size: 'base',
18
+ uppercase: true,
19
+ validation: undefined,
20
+ })
21
+
22
+ const emit = defineEmits<{
23
+ create: [label: string]
24
+ }>()
25
+
26
+ const { t } = useI18n()
27
+ const id = useId()
28
+ const listId = `${id}-list`
29
+ const modelValue = defineModel<Item[]>({ default: () => [] })
30
+ const inputRef = ref<HTMLInputElement>()
31
+ const listRef = ref<HTMLElement>()
32
+ const opened = ref(false)
33
+ const searchRaw = ref('')
34
+ const highlightedIndex = ref(0)
35
+
36
+ const filteredItems = computed(() => {
37
+ const selectedKeys = new Set(modelValue.value.map(item => getItemKey(item)))
38
+ const availableItems = props.items.filter(item => !selectedKeys.has(getItemKey(item)))
39
+ const searchQuery = normalizeText(searchRaw.value.trim())
40
+
41
+ return searchQuery
42
+ ? availableItems.filter(item => normalizeText(getItemText(item)).includes(searchQuery))
43
+ : availableItems
44
+ })
45
+
46
+ const canCreate = computed(() => {
47
+ const trimmed = searchRaw.value.trim()
48
+
49
+ if (props.allowCreate && trimmed) {
50
+ const normalizedInput = normalizeText(trimmed)
51
+
52
+ return !props.items.some(item => normalizeText(getItemText(item)) === normalizedInput)
53
+ }
54
+
55
+ return false
56
+ })
57
+
58
+ const createOptionIndex = computed(() => {
59
+ return canCreate.value ? filteredItems.value.length : -1
60
+ })
61
+
62
+ const optionsCount = computed(() => {
63
+ return filteredItems.value.length + (canCreate.value ? 1 : 0)
64
+ })
65
+
66
+ watch([filteredItems, canCreate], () => {
67
+ const lastIndex = Math.max(0, optionsCount.value - 1)
68
+
69
+ if (highlightedIndex.value > lastIndex) {
70
+ highlightedIndex.value = lastIndex
71
+ }
72
+ })
73
+
74
+ function getItemKey(item: Item) {
75
+ return typeof props.itemKey === 'function' ? props.itemKey(item) : String(readField(item, props.itemKey))
76
+ }
77
+
78
+ function getItemText(item: Item) {
79
+ return typeof props.itemLabel === 'function'
80
+ ? props.itemLabel(item)
81
+ : String(readField(item, props.itemLabel) ?? '')
82
+ }
83
+
84
+ function getOptionElements() {
85
+ if (!listRef.value) {
86
+ return []
87
+ }
88
+
89
+ return Array.from(listRef.value.querySelectorAll<HTMLElement>('[role="option"]'))
90
+ }
91
+
92
+ function getOptionRects() {
93
+ return getOptionElements().map((element, index) => ({
94
+ index,
95
+ rect: element.getBoundingClientRect(),
96
+ }))
97
+ }
98
+
99
+ async function moveHighlight(direction: 'down' | 'left' | 'right' | 'up') {
100
+ const optionRects = getOptionRects()
101
+
102
+ if (optionRects.length === 0) {
103
+ return
104
+ }
105
+
106
+ const currentIndex = Math.min(highlightedIndex.value, optionRects.length - 1)
107
+ const current = optionRects[currentIndex]!
108
+
109
+ if (direction === 'left' || direction === 'right') {
110
+ const sameRowOptions = optionRects.filter((option) => {
111
+ return Math.abs(option.rect.top - current.rect.top) <= Math.min(option.rect.height, current.rect.height) / 2
112
+ })
113
+
114
+ if (direction === 'right') {
115
+ const rightOptions = sameRowOptions
116
+ .filter(option => option.rect.left > current.rect.left + 1)
117
+ .sort((first, second) => first.rect.left - second.rect.left)
118
+
119
+ if (rightOptions.length > 0) {
120
+ highlightedIndex.value = rightOptions[0]!.index
121
+ }
122
+ else {
123
+ const nextRowOptions = optionRects
124
+ .filter(option => option.rect.top > current.rect.bottom - 1)
125
+ .sort((first, second) => first.rect.top - second.rect.top || first.rect.left - second.rect.left)
126
+
127
+ if (nextRowOptions.length > 0) {
128
+ const nextRowTop = nextRowOptions[0]!.rect.top
129
+ const firstInNextRow = nextRowOptions.find(option => Math.abs(option.rect.top - nextRowTop) <= 4)
130
+
131
+ if (firstInNextRow) {
132
+ highlightedIndex.value = firstInNextRow.index
133
+ }
134
+ }
135
+ }
136
+ }
137
+ else {
138
+ const leftOptions = sameRowOptions
139
+ .filter(option => option.rect.right < current.rect.right - 1)
140
+ .sort((first, second) => second.rect.left - first.rect.left)
141
+
142
+ if (leftOptions.length > 0) {
143
+ highlightedIndex.value = leftOptions[0]!.index
144
+ }
145
+ else {
146
+ const previousRowOptions = optionRects
147
+ .filter(option => option.rect.bottom < current.rect.top + 1)
148
+ .sort((first, second) => second.rect.top - first.rect.top || second.rect.left - first.rect.left)
149
+
150
+ if (previousRowOptions.length > 0) {
151
+ const previousRowTop = previousRowOptions[0]!.rect.top
152
+
153
+ const lastInPreviousRow = previousRowOptions
154
+ .filter(option => Math.abs(option.rect.top - previousRowTop) <= 4)
155
+ .sort((first, second) => second.rect.left - first.rect.left)[0]
156
+
157
+ if (lastInPreviousRow) {
158
+ highlightedIndex.value = lastInPreviousRow.index
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ const currentCenterX = current.rect.left + current.rect.width / 2
166
+
167
+ const rowOptions = optionRects.filter((option) => {
168
+ if (direction === 'down') {
169
+ return option.rect.top > current.rect.top + 1
170
+ }
171
+
172
+ return option.rect.bottom < current.rect.bottom - 1
173
+ })
174
+
175
+ if (rowOptions.length === 0) {
176
+ return
177
+ }
178
+
179
+ const targetRowTop = direction === 'down'
180
+ ? Math.min(...rowOptions.map(option => option.rect.top))
181
+ : Math.max(...rowOptions.map(option => option.rect.top))
182
+
183
+ const targetRowOptions = rowOptions.filter(option => Math.abs(option.rect.top - targetRowTop) <= 4)
184
+
185
+ let closestIndex: number | undefined
186
+ let closestDist: number | undefined
187
+
188
+ for (const option of targetRowOptions) {
189
+ const centerX = option.rect.left + option.rect.width / 2
190
+ const dist = Math.abs(centerX - currentCenterX)
191
+
192
+ if (closestDist === undefined || dist < closestDist) {
193
+ closestDist = dist
194
+ closestIndex = option.index
195
+ }
196
+ }
197
+
198
+ if (closestIndex !== undefined) {
199
+ highlightedIndex.value = closestIndex
200
+ }
201
+ }
202
+
203
+ await nextTick()
204
+
205
+ getOptionElements()[highlightedIndex.value]?.scrollIntoView({
206
+ block: 'nearest',
207
+ inline: 'nearest',
208
+ })
209
+ }
210
+
211
+ async function onArrowKey(event: KeyboardEvent, direction: 'down' | 'left' | 'right' | 'up') {
212
+ if (optionsCount.value === 0) {
213
+ return
214
+ }
215
+
216
+ if (direction === 'left' || direction === 'right') {
217
+ const input = inputRef.value
218
+ const cursorAtStart = input ? input.selectionStart === 0 && input.selectionEnd === 0 : true
219
+
220
+ const cursorAtEnd = input
221
+ ? input.selectionStart === input.value.length && input.selectionEnd === input.value.length
222
+ : true
223
+
224
+ if (searchRaw.value.length > 0 && ((direction === 'left' && !cursorAtStart) || (direction === 'right' && !cursorAtEnd))) {
225
+ return
226
+ }
227
+ }
228
+
229
+ event.preventDefault()
230
+
231
+ await moveHighlight(direction)
232
+ }
233
+
234
+ function onBackspace() {
235
+ if (searchRaw.value === '' && modelValue.value.length > 0) {
236
+ modelValue.value = modelValue.value.slice(0, -1)
237
+ }
238
+ }
239
+
240
+ function onClose() {
241
+ if (opened.value) {
242
+ opened.value = false
243
+ searchRaw.value = ''
244
+ highlightedIndex.value = 0
245
+ }
246
+ }
247
+
248
+ async function onCreate() {
249
+ const label = searchRaw.value.trim()
250
+
251
+ if (label) {
252
+ // The parent owns creation (idempotency, colors, sync); we never mutate items ourselves.
253
+ emit('create', label)
254
+
255
+ searchRaw.value = ''
256
+ highlightedIndex.value = 0
257
+
258
+ await nextTick()
259
+
260
+ inputRef.value?.focus()
261
+ }
262
+ }
263
+
264
+ function onEnter() {
265
+ if (optionsCount.value > 0) {
266
+ if (highlightedIndex.value === createOptionIndex.value) {
267
+ onCreate()
268
+ }
269
+ else {
270
+ const item = filteredItems.value[highlightedIndex.value]
271
+
272
+ if (item) {
273
+ onSelect(item)
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ function onHighlight(index: number) {
280
+ highlightedIndex.value = index
281
+ }
282
+
283
+ async function onOpen() {
284
+ if (!props.disabled && !opened.value) {
285
+ opened.value = true
286
+ highlightedIndex.value = 0
287
+
288
+ await nextTick()
289
+
290
+ inputRef.value?.focus()
291
+ inputRef.value?.scrollIntoView({ block: 'nearest' })
292
+ }
293
+ }
294
+
295
+ function onRemove(item: Item) {
296
+ const removedKey = getItemKey(item)
297
+
298
+ modelValue.value = modelValue.value.filter(current => getItemKey(current) !== removedKey)
299
+ }
300
+
301
+ async function onSelect(item: Item) {
302
+ modelValue.value = [...modelValue.value, item]
303
+ searchRaw.value = ''
304
+ highlightedIndex.value = 0
305
+
306
+ await nextTick()
307
+
308
+ inputRef.value?.focus()
309
+ }
310
+
311
+ function readField(item: Item, field: string) {
312
+ return (item as Record<string, unknown>)[field]
313
+ }
314
+ </script>
315
+
316
+ <template>
317
+ <div class="flex flex-col">
318
+ <FieldLabel
319
+ v-if="label"
320
+ :disabled="disabled"
321
+ :for-field="id"
322
+ has-margin-bottom
323
+ :icon="labelIcon"
324
+ :label="label"
325
+ :required="required"
326
+ :size="size"
327
+ />
328
+
329
+ <div
330
+ class="overflow-hidden rounded-lg bg-white dark:bg-gray-900"
331
+ :class="{
332
+ 'text-xs': size === 'sm',
333
+ 'text-sm': size === 'base',
334
+ 'text-base': size === 'lg',
335
+ 'base-box': border === 'full',
336
+ 'base-border-b': border === 'bottom',
337
+ 'border-0': border === 'none',
338
+ 'base-border-active': opened && border !== 'none',
339
+ 'base-border-interactive-focus-within': !opened && !disabled && border !== 'none',
340
+ 'field-disabled': disabled,
341
+ 'field-uppercase': uppercase,
342
+ }"
343
+ >
344
+ <div
345
+ class="flex flex-wrap select-none items-center gap-1.5 p-1.5"
346
+ :class="{
347
+ 'min-h-10': size === 'sm',
348
+ 'min-h-12': size === 'base',
349
+ 'min-h-14': size === 'lg',
350
+ }"
351
+ >
352
+ <button
353
+ v-for="item in modelValue"
354
+ :key="getItemKey(item)"
355
+ :aria-label="t('remove', { label: getItemText(item) })"
356
+ class="min-h-8 inline-flex items-center gap-1 border base-border rounded-md bg-gray-100 py-1 pl-2 pr-1.5 text-gray-700 font-normal tracking-tight normal-case transition-colors dark:bg-gray-800 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
357
+ type="button"
358
+ @mousedown.prevent
359
+ @click="onRemove(item)"
360
+ >
361
+ <span class="truncate">{{ getItemText(item) }}</span>
362
+
363
+ <BaseIcon
364
+ color="gray"
365
+ :icon="getLayerIcon('close')"
366
+ size="xs"
367
+ />
368
+ </button>
369
+
370
+ <button
371
+ v-if="!opened"
372
+ :aria-label="t('add')"
373
+ class="min-h-8 inline-flex items-center gap-1 border base-border base-border-hover rounded-md border-dashed py-1 pl-1.5 pr-2 text-gray-600 font-normal tracking-tight normal-case dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
374
+ :disabled="disabled"
375
+ type="button"
376
+ @click="onOpen"
377
+ >
378
+ <BaseIcon
379
+ :icon="getLayerIcon('plus')"
380
+ size="xs"
381
+ />
382
+
383
+ <span>{{ t('add') }}</span>
384
+ </button>
385
+
386
+ <input
387
+ v-else
388
+ :id="id"
389
+ ref="inputRef"
390
+ v-model="searchRaw"
391
+ :aria-activedescendant="opened && optionsCount > 0 ? highlightedIndex === createOptionIndex ? `${id}-create` : `${id}-option-${highlightedIndex}` : undefined"
392
+ aria-autocomplete="list"
393
+ :aria-controls="optionsCount > 0 ? listId : undefined"
394
+ :aria-expanded="opened"
395
+ autocomplete="off"
396
+ class="min-w-[6rem] flex-1 select-text appearance-none bg-transparent px-2 text-gray-900 font-medium tracking-tight outline-none dark:text-gray-100 placeholder-gray-600 dark:placeholder-gray-400"
397
+ :placeholder="placeholder || (allowCreate ? t('searchOrCreate') : t('search'))"
398
+ role="combobox"
399
+ spellcheck="false"
400
+ @blur="onClose"
401
+ @keydown.backspace="onBackspace"
402
+ @keydown.down="onArrowKey($event, 'down')"
403
+ @keydown.enter.prevent="onEnter"
404
+ @keydown.escape.stop="onClose"
405
+ @keydown.left="onArrowKey($event, 'left')"
406
+ @keydown.right="onArrowKey($event, 'right')"
407
+ @keydown.up="onArrowKey($event, 'up')"
408
+ >
409
+ </div>
410
+
411
+ <div
412
+ v-if="opened && optionsCount > 0"
413
+ class="select-none border-t base-border px-1.5 pb-1.5 pt-1"
414
+ >
415
+ <div
416
+ :id="listId"
417
+ ref="listRef"
418
+ class="max-h-[150px] flex flex-wrap gap-1.5 overflow-y-auto"
419
+ role="listbox"
420
+ >
421
+ <div
422
+ v-for="(item, index) in filteredItems"
423
+ :id="`${id}-option-${index}`"
424
+ :key="getItemKey(item)"
425
+ :aria-selected="index === highlightedIndex"
426
+ class="max-w-full min-h-8 inline-flex cursor-pointer items-center border base-border rounded-md px-2 py-1 text-gray-600 font-normal tracking-tight normal-case outline-none dark:text-gray-400"
427
+ :class="{ 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300': index === highlightedIndex }"
428
+ role="option"
429
+ @click="onSelect(item)"
430
+ @mousedown.prevent
431
+ @mouseenter="onHighlight(index)"
432
+ >
433
+ <span class="truncate">
434
+ {{ getItemText(item) }}
435
+ </span>
436
+ </div>
437
+
438
+ <div
439
+ v-if="canCreate"
440
+ :id="`${id}-create`"
441
+ :aria-selected="highlightedIndex === createOptionIndex"
442
+ class="max-w-full min-h-8 inline-flex cursor-pointer items-center gap-1 border base-border rounded-md border-dashed px-2 py-1 text-gray-600 font-normal tracking-tight normal-case outline-none dark:text-gray-400"
443
+ :class="{ 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300': highlightedIndex === createOptionIndex }"
444
+ role="option"
445
+ @click="onCreate"
446
+ @mousedown.prevent
447
+ @mouseenter="onHighlight(createOptionIndex)"
448
+ >
449
+ <BaseIcon
450
+ class="shrink-0"
451
+ color="gray"
452
+ :icon="getLayerIcon('plus')"
453
+ size="2xs"
454
+ />
455
+
456
+ <span class="truncate">
457
+ {{ t('create', { label: searchRaw.trim() }) }}
458
+ </span>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ </div>
463
+
464
+ <FieldMessage
465
+ :description="description"
466
+ :disabled="disabled"
467
+ :hide-error="hideError"
468
+ :size="size"
469
+ :validation="validation"
470
+ />
471
+ </div>
472
+ </template>
473
+
474
+ <i18n lang="json">
475
+ {
476
+ "de": {
477
+ "add": "Hinzufügen",
478
+ "create": "„{label}“ erstellen",
479
+ "remove": "{label} entfernen",
480
+ "search": "Suchen…",
481
+ "searchOrCreate": "Suchen oder erstellen…"
482
+ },
483
+ "en": {
484
+ "add": "Add",
485
+ "create": "Create \"{label}\"",
486
+ "remove": "Remove {label}",
487
+ "search": "Search…",
488
+ "searchOrCreate": "Search or create…"
489
+ },
490
+ "es": {
491
+ "add": "Añadir",
492
+ "create": "Crear «{label}»",
493
+ "remove": "Eliminar {label}",
494
+ "search": "Buscar…",
495
+ "searchOrCreate": "Buscar o crear…"
496
+ },
497
+ "fr": {
498
+ "add": "Ajouter",
499
+ "create": "Créer « {label} »",
500
+ "remove": "Retirer {label}",
501
+ "search": "Rechercher…",
502
+ "searchOrCreate": "Rechercher ou créer…"
503
+ },
504
+ "id": {
505
+ "add": "Tambah",
506
+ "create": "Buat \"{label}\"",
507
+ "remove": "Hapus {label}",
508
+ "search": "Cari…",
509
+ "searchOrCreate": "Cari atau buat…"
510
+ },
511
+ "it": {
512
+ "add": "Aggiungi",
513
+ "create": "Crea «{label}»",
514
+ "remove": "Rimuovi {label}",
515
+ "search": "Cerca…",
516
+ "searchOrCreate": "Cerca o crea…"
517
+ },
518
+ "ja": {
519
+ "add": "追加",
520
+ "create": "「{label}」を作成",
521
+ "remove": "{label} を削除",
522
+ "search": "検索…",
523
+ "searchOrCreate": "検索または作成…"
524
+ },
525
+ "ko": {
526
+ "add": "추가",
527
+ "create": "\"{label}\" 만들기",
528
+ "remove": "{label} 제거",
529
+ "search": "검색…",
530
+ "searchOrCreate": "검색 또는 생성…"
531
+ },
532
+ "nl": {
533
+ "add": "Toevoegen",
534
+ "create": "\"{label}\" maken",
535
+ "remove": "{label} verwijderen",
536
+ "search": "Zoeken…",
537
+ "searchOrCreate": "Zoeken of maken…"
538
+ },
539
+ "pl": {
540
+ "add": "Dodaj",
541
+ "create": "Utwórz „{label}”",
542
+ "remove": "Usuń {label}",
543
+ "search": "Szukaj…",
544
+ "searchOrCreate": "Szukaj lub utwórz…"
545
+ },
546
+ "pt": {
547
+ "add": "Adicionar",
548
+ "create": "Criar «{label}»",
549
+ "remove": "Remover {label}",
550
+ "search": "Pesquisar…",
551
+ "searchOrCreate": "Pesquisar ou criar…"
552
+ },
553
+ "pt-BR": {
554
+ "add": "Adicionar",
555
+ "create": "Criar \"{label}\"",
556
+ "remove": "Remover {label}",
557
+ "search": "Pesquisar…",
558
+ "searchOrCreate": "Pesquisar ou criar…"
559
+ },
560
+ "vi": {
561
+ "add": "Thêm",
562
+ "create": "Tạo \"{label}\"",
563
+ "remove": "Xóa {label}",
564
+ "search": "Tìm…",
565
+ "searchOrCreate": "Tìm hoặc tạo…"
566
+ }
567
+ }
568
+ </i18n>
@@ -113,11 +113,11 @@ const textSize = computed<BaseSize>(() => {
113
113
  }
114
114
  })
115
115
 
116
- watch(opened, (isOpened) => {
116
+ watch(opened, async (isOpened) => {
117
117
  if (isOpened && props.searchable) {
118
- nextTick(() => {
119
- searchInput.value?.focus()
120
- })
118
+ await nextTick()
119
+
120
+ searchInput.value?.focus()
121
121
  }
122
122
  })
123
123
 
@@ -299,6 +299,28 @@ export type BaseQuoteBackground = 'gray' | 'gray-light' | 'white'
299
299
 
300
300
  export type BaseQuoteSize = 'base' | 'sm' | 'xs'
301
301
 
302
+ export interface BaseRing {
303
+ clickable?: boolean
304
+ completed?: number
305
+ dashed?: boolean
306
+ expanded?: boolean
307
+ format?: BaseRingFormat
308
+ label?: string
309
+ size?: BaseRingSize
310
+ total?: number
311
+ transitionColors?: boolean
312
+ }
313
+
314
+ export interface BaseRingArc {
315
+ dashArray?: string
316
+ dashOffset?: string
317
+ filled: boolean
318
+ }
319
+
320
+ export type BaseRingFormat = 'count' | 'none' | 'percent'
321
+
322
+ export type BaseRingSize = 'base' | 'lg'
323
+
302
324
  export interface BaseShortcut {
303
325
  active?: boolean
304
326
  shortcut?: string
@@ -338,8 +360,6 @@ export interface BaseTag {
338
360
 
339
361
  export type BaseTagColor = 'gray' | 'green' | 'indigo' | 'orange'
340
362
 
341
- export type BaseTagVariant = 'filled' | 'ghost'
342
-
343
363
  export interface BaseTagItem extends BaseTag {
344
364
  id?: number | string
345
365
  order?: number
@@ -353,6 +373,8 @@ export interface BaseTags {
353
373
 
354
374
  export type BaseTagSize = 'base' | 'sm' | 'xs'
355
375
 
376
+ export type BaseTagVariant = 'filled' | 'ghost'
377
+
356
378
  export interface BaseText {
357
379
  alignment?: BaseAlignment
358
380
  background?: BaseBackground
@@ -37,6 +37,25 @@ export interface FieldCheckbox {
37
37
  validation?: VuelidateValidation
38
38
  }
39
39
 
40
+ export interface FieldCombobox<Item = Record<string, unknown>> {
41
+ allowCreate?: boolean
42
+ border?: FieldInputBorder
43
+ description?: BaseTextText
44
+ disabled?: boolean
45
+ hideError?: boolean
46
+ itemKey?: ((item: Item) => string) | string
47
+ itemLabel?: ((item: Item) => string) | string
48
+ items?: Item[]
49
+ label?: BaseTextText
50
+ labelIcon?: string
51
+ modelValue: Item[]
52
+ placeholder?: string
53
+ required?: boolean
54
+ size?: FieldSize
55
+ uppercase?: boolean
56
+ validation?: VuelidateValidation
57
+ }
58
+
40
59
  export interface FieldDays {
41
60
  disabled?: boolean
42
61
  modelValue: number[]
@@ -64,6 +64,10 @@ declare global {
64
64
  type BaseQuote = import('./bases').BaseQuote
65
65
  type BaseQuoteBackground = import('./bases').BaseQuoteBackground
66
66
  type BaseQuoteSize = import('./bases').BaseQuoteSize
67
+ type BaseRing = import('./bases').BaseRing
68
+ type BaseRingArc = import('./bases').BaseRingArc
69
+ type BaseRingFormat = import('./bases').BaseRingFormat
70
+ type BaseRingSize = import('./bases').BaseRingSize
67
71
  type BaseShortcut = import('./bases').BaseShortcut
68
72
  type BaseSize = import('./bases').BaseSize
69
73
  type BaseSpinner = import('./bases').BaseSpinner
@@ -91,6 +95,7 @@ declare global {
91
95
  type FieldAvatar = import('./fields').FieldAvatar
92
96
  type FieldBackground = import('./fields').FieldBackground
93
97
  type FieldCheckbox = import('./fields').FieldCheckbox
98
+ type FieldCombobox<Item = Record<string, unknown>> = import('./fields').FieldCombobox<Item>
94
99
  type FieldDays = import('./fields').FieldDays
95
100
  type FieldEmojis = import('./fields').FieldEmojis
96
101
  type FieldInput = import('./fields').FieldInput
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "2.0.59",
3
+ "version": "2.0.61",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",
package/uno.config.ts CHANGED
@@ -39,6 +39,7 @@ export const shortcuts: Record<string, string> = {
39
39
  'base-highlight-indigo': 'border border-indigo-600/35 bg-indigo-600/[0.08] shadow-indigo-600/15 dark:border-indigo-400/35 dark:bg-indigo-400/[0.08] dark:shadow-indigo-400/15',
40
40
  'base-surface': 'border base-border base-bg shadow-sm',
41
41
  'field-disabled': 'opacity-50 cursor-not-allowed',
42
+ 'field-uppercase': 'uppercase [&_*]:uppercase [&_input::placeholder]:uppercase',
42
43
  'scroll-y-stable': 'overflow-y-auto [scrollbar-gutter:stable]',
43
44
  }
44
45