@saasmakers/ui 2.0.58 → 2.0.60

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.
@@ -31,16 +31,16 @@ function onClick(event: MouseEvent) {
31
31
  'px-0.5 py-px text-3xs': size === 'xs',
32
32
  'px-1 py-px text-3xs': size === 'sm',
33
33
  'px-1.5 py-0.5 text-2xs': size === 'base',
34
- 'bg-transparent text-gray-900 dark:text-gray-100': variant === 'ghost' && !active,
35
- 'hover:bg-gray-500/10': variant === 'ghost' && !active && clickable,
36
- 'bg-transparent text-indigo-800 dark:text-indigo-300': variant === 'ghost' && active && color === 'indigo',
37
- 'bg-transparent text-orange-800 dark:text-orange-300': variant === 'ghost' && active && color === 'orange',
38
- 'bg-transparent text-green-800 dark:text-green-300': variant === 'ghost' && active && color === 'green',
34
+ 'bg-transparent text-gray-900 dark:text-gray-100': variant === 'ghost' && !active && color === 'gray',
35
+ 'hover:bg-gray-500/10': variant === 'ghost' && !active && color === 'gray' && clickable,
36
+ 'bg-transparent text-indigo-800 dark:text-indigo-300': variant === 'ghost' && color === 'indigo',
37
+ 'bg-transparent text-orange-800 dark:text-orange-300': variant === 'ghost' && color === 'orange',
38
+ 'bg-transparent text-green-800 dark:text-green-300': variant === 'ghost' && color === 'green',
39
39
  'bg-transparent text-gray-700 dark:text-gray-300': variant === 'ghost' && active && color === 'gray',
40
- 'bg-orange-500/15 text-orange-800 dark:text-orange-300': variant === 'filled' && color === 'orange' && (!clickable || active),
41
- 'bg-indigo-500/15 text-indigo-800 dark:text-indigo-300': variant === 'filled' && color === 'indigo' && (!clickable || active),
42
- 'bg-green-500/15 text-green-800 dark:text-green-300': variant === 'filled' && color === 'green' && (!clickable || active),
43
- 'bg-gray-500/20 text-gray-700 dark:text-gray-300': variant === 'filled' && (color === 'gray' || (clickable && !active)),
40
+ 'bg-orange-500/15 text-orange-800 dark:text-orange-300': variant === 'filled' && color === 'orange',
41
+ 'bg-indigo-500/15 text-indigo-800 dark:text-indigo-300': variant === 'filled' && color === 'indigo',
42
+ 'bg-green-500/15 text-green-800 dark:text-green-300': variant === 'filled' && color === 'green',
43
+ 'bg-gray-500/20 text-gray-700 dark:text-gray-300': variant === 'filled' && color === 'gray',
44
44
  }"
45
45
  :type="clickable ? 'button' : undefined"
46
46
  @click="onClick"
@@ -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,430 @@
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 opened = ref(false)
32
+ const searchRaw = ref('')
33
+ const highlightedIndex = ref(0)
34
+
35
+ const filteredItems = computed(() => {
36
+ const selectedKeys = new Set(modelValue.value.map(item => keyOf(item)))
37
+ const availableItems = props.items.filter(item => !selectedKeys.has(keyOf(item)))
38
+ const searchQuery = normalizeText(searchRaw.value.trim())
39
+
40
+ return searchQuery
41
+ ? availableItems.filter(item => normalizeText(labelOf(item)).includes(searchQuery))
42
+ : availableItems
43
+ })
44
+
45
+ const canCreate = computed(() => {
46
+ const trimmed = searchRaw.value.trim()
47
+
48
+ if (props.allowCreate && trimmed) {
49
+ const normalizedInput = normalizeText(trimmed)
50
+
51
+ return !props.items.some(item => normalizeText(labelOf(item)) === normalizedInput)
52
+ }
53
+
54
+ return false
55
+ })
56
+
57
+ const createIndex = computed(() => {
58
+ return canCreate.value ? filteredItems.value.length : -1
59
+ })
60
+
61
+ const optionsCount = computed(() => {
62
+ return filteredItems.value.length + (canCreate.value ? 1 : 0)
63
+ })
64
+
65
+ watch([filteredItems, canCreate], () => {
66
+ const lastIndex = Math.max(0, optionsCount.value - 1)
67
+
68
+ if (highlightedIndex.value > lastIndex) {
69
+ highlightedIndex.value = lastIndex
70
+ }
71
+ })
72
+
73
+ function keyOf(item: Item) {
74
+ return typeof props.itemKey === 'function' ? props.itemKey(item) : String(readField(item, props.itemKey))
75
+ }
76
+
77
+ function labelOf(item: Item) {
78
+ return typeof props.itemLabel === 'function'
79
+ ? props.itemLabel(item)
80
+ : String(readField(item, props.itemLabel) ?? '')
81
+ }
82
+
83
+ function onArrowDown() {
84
+ highlightedIndex.value = Math.min(highlightedIndex.value + 1, optionsCount.value - 1)
85
+ }
86
+
87
+ function onArrowUp() {
88
+ highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
89
+ }
90
+
91
+ function onBackspace() {
92
+ if (searchRaw.value === '' && modelValue.value.length > 0) {
93
+ modelValue.value = modelValue.value.slice(0, -1)
94
+ }
95
+ }
96
+
97
+ function onClose() {
98
+ if (opened.value) {
99
+ opened.value = false
100
+ searchRaw.value = ''
101
+ highlightedIndex.value = 0
102
+ }
103
+ }
104
+
105
+ function onCreate() {
106
+ const label = searchRaw.value.trim()
107
+
108
+ if (label) {
109
+ // The parent owns creation (idempotency, colors, sync); we never mutate items ourselves.
110
+ emit('create', label)
111
+
112
+ searchRaw.value = ''
113
+ highlightedIndex.value = 0
114
+
115
+ nextTick(() => inputRef.value?.focus())
116
+ }
117
+ }
118
+
119
+ function onEnter() {
120
+ if (optionsCount.value > 0) {
121
+ if (highlightedIndex.value === createIndex.value) {
122
+ onCreate()
123
+ }
124
+ else {
125
+ const item = filteredItems.value[highlightedIndex.value]
126
+
127
+ if (item) {
128
+ onSelect(item)
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ function onHighlight(index: number) {
135
+ highlightedIndex.value = index
136
+ }
137
+
138
+ function onOpen() {
139
+ if (!props.disabled && !opened.value) {
140
+ opened.value = true
141
+ highlightedIndex.value = 0
142
+
143
+ nextTick(() => {
144
+ inputRef.value?.focus()
145
+ inputRef.value?.scrollIntoView({ block: 'nearest' })
146
+ })
147
+ }
148
+ }
149
+
150
+ function onRemove(item: Item) {
151
+ const removedKey = keyOf(item)
152
+
153
+ modelValue.value = modelValue.value.filter(current => keyOf(current) !== removedKey)
154
+ }
155
+
156
+ function onSelect(item: Item) {
157
+ modelValue.value = [...modelValue.value, item]
158
+ searchRaw.value = ''
159
+ highlightedIndex.value = 0
160
+
161
+ nextTick(() => inputRef.value?.focus())
162
+ }
163
+
164
+ function readField(item: Item, field: string) {
165
+ return (item as Record<string, unknown>)[field]
166
+ }
167
+ </script>
168
+
169
+ <template>
170
+ <div class="flex flex-col">
171
+ <FieldLabel
172
+ v-if="label"
173
+ :disabled="disabled"
174
+ :for-field="id"
175
+ has-margin-bottom
176
+ :icon="labelIcon"
177
+ :label="label"
178
+ :required="required"
179
+ :size="size"
180
+ />
181
+
182
+ <div
183
+ class="overflow-hidden rounded-lg bg-white dark:bg-gray-900"
184
+ :class="{
185
+ 'text-xs': size === 'sm',
186
+ 'text-sm': size === 'base',
187
+ 'text-base': size === 'lg',
188
+ 'base-box': border === 'full',
189
+ 'base-border-b': border === 'bottom',
190
+ 'border-0': border === 'none',
191
+ 'base-border-active': opened && border !== 'none',
192
+ 'base-border-interactive-focus-within': !opened && !disabled && border !== 'none',
193
+ 'field-disabled': disabled,
194
+ 'field-uppercase': uppercase,
195
+ }"
196
+ >
197
+ <div
198
+ class="flex flex-wrap select-none items-center gap-1.5 p-1.5"
199
+ :class="{
200
+ 'min-h-10': size === 'sm',
201
+ 'min-h-12': size === 'base',
202
+ 'min-h-14': size === 'lg',
203
+ }"
204
+ >
205
+ <button
206
+ v-for="item in modelValue"
207
+ :key="keyOf(item)"
208
+ :aria-label="t('remove', { label: labelOf(item) })"
209
+ 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-900 font-medium tracking-tight hover:border-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:hover:border-gray-700"
210
+ type="button"
211
+ @mousedown.prevent
212
+ @click="onRemove(item)"
213
+ >
214
+ <span class="truncate">{{ labelOf(item) }}</span>
215
+
216
+ <BaseIcon
217
+ color="gray"
218
+ :icon="getLayerIcon('close')"
219
+ size="2xs"
220
+ />
221
+ </button>
222
+
223
+ <button
224
+ v-if="!opened"
225
+ :aria-label="t('add')"
226
+ 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-medium tracking-tight dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
227
+ :disabled="disabled"
228
+ type="button"
229
+ @click="onOpen"
230
+ >
231
+ <BaseIcon
232
+ :icon="getLayerIcon('plus')"
233
+ size="xs"
234
+ />
235
+
236
+ <span>{{ t('add') }}</span>
237
+ </button>
238
+
239
+ <input
240
+ v-else
241
+ :id="id"
242
+ ref="inputRef"
243
+ v-model="searchRaw"
244
+ :aria-activedescendant="opened && optionsCount > 0 ? highlightedIndex === createIndex ? `${id}-create` : `${id}-option-${highlightedIndex}` : undefined"
245
+ aria-autocomplete="list"
246
+ :aria-controls="optionsCount > 0 ? listId : undefined"
247
+ :aria-expanded="opened"
248
+ autocomplete="off"
249
+ 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"
250
+ :placeholder="placeholder || (allowCreate ? t('searchOrCreate') : t('search'))"
251
+ role="combobox"
252
+ spellcheck="false"
253
+ @blur="onClose"
254
+ @keydown.backspace="onBackspace"
255
+ @keydown.down.prevent="onArrowDown"
256
+ @keydown.enter.prevent="onEnter"
257
+ @keydown.escape.stop="onClose"
258
+ @keydown.up.prevent="onArrowUp"
259
+ >
260
+ </div>
261
+
262
+ <div
263
+ v-if="opened && optionsCount > 0"
264
+ class="select-none border-t base-border px-1.5 pb-1.5 pt-1"
265
+ >
266
+ <div
267
+ :id="listId"
268
+ class="max-h-[150px] overflow-y-auto"
269
+ role="listbox"
270
+ >
271
+ <div
272
+ v-for="(item, index) in filteredItems"
273
+ :id="`${id}-option-${index}`"
274
+ :key="keyOf(item)"
275
+ :aria-selected="index === highlightedIndex"
276
+ class="flex cursor-pointer items-center rounded-md px-2 font-medium tracking-tight outline-none"
277
+ :class="{
278
+ 'min-h-8': size === 'sm',
279
+ 'min-h-10': size === 'base',
280
+ 'min-h-12': size === 'lg',
281
+ 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100': index === highlightedIndex,
282
+ 'text-gray-600 dark:text-gray-400': index !== highlightedIndex,
283
+ }"
284
+ role="option"
285
+ @click="onSelect(item)"
286
+ @mousedown.prevent
287
+ @mouseenter="onHighlight(index)"
288
+ >
289
+ <span class="truncate">
290
+ {{ labelOf(item) }}
291
+ </span>
292
+ </div>
293
+
294
+ <div
295
+ v-if="canCreate"
296
+ :id="`${id}-create`"
297
+ :aria-selected="highlightedIndex === createIndex"
298
+ class="flex cursor-pointer items-center rounded-md px-2 font-medium tracking-tight outline-none"
299
+ :class="{
300
+ 'min-h-8': size === 'sm',
301
+ 'min-h-10': size === 'base',
302
+ 'min-h-12': size === 'lg',
303
+ 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100': highlightedIndex === createIndex,
304
+ 'text-gray-600 dark:text-gray-400': highlightedIndex !== createIndex,
305
+ }"
306
+ role="option"
307
+ @click="onCreate"
308
+ @mousedown.prevent
309
+ @mouseenter="onHighlight(createIndex)"
310
+ >
311
+ <BaseIcon
312
+ class="mr-1.5 shrink-0"
313
+ color="gray"
314
+ :icon="getLayerIcon('plus')"
315
+ size="2xs"
316
+ />
317
+
318
+ <span class="truncate">
319
+ {{ t('create', { label: searchRaw.trim() }) }}
320
+ </span>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <FieldMessage
327
+ :description="description"
328
+ :disabled="disabled"
329
+ :hide-error="hideError"
330
+ :size="size"
331
+ :validation="validation"
332
+ />
333
+ </div>
334
+ </template>
335
+
336
+ <i18n lang="json">
337
+ {
338
+ "de": {
339
+ "add": "Hinzufügen",
340
+ "create": "„{label}“ erstellen",
341
+ "remove": "{label} entfernen",
342
+ "search": "Suchen…",
343
+ "searchOrCreate": "Suchen oder erstellen…"
344
+ },
345
+ "en": {
346
+ "add": "Add",
347
+ "create": "Create \"{label}\"",
348
+ "remove": "Remove {label}",
349
+ "search": "Search…",
350
+ "searchOrCreate": "Search or create…"
351
+ },
352
+ "es": {
353
+ "add": "Añadir",
354
+ "create": "Crear «{label}»",
355
+ "remove": "Eliminar {label}",
356
+ "search": "Buscar…",
357
+ "searchOrCreate": "Buscar o crear…"
358
+ },
359
+ "fr": {
360
+ "add": "Ajouter",
361
+ "create": "Créer « {label} »",
362
+ "remove": "Retirer {label}",
363
+ "search": "Rechercher…",
364
+ "searchOrCreate": "Rechercher ou créer…"
365
+ },
366
+ "id": {
367
+ "add": "Tambah",
368
+ "create": "Buat \"{label}\"",
369
+ "remove": "Hapus {label}",
370
+ "search": "Cari…",
371
+ "searchOrCreate": "Cari atau buat…"
372
+ },
373
+ "it": {
374
+ "add": "Aggiungi",
375
+ "create": "Crea «{label}»",
376
+ "remove": "Rimuovi {label}",
377
+ "search": "Cerca…",
378
+ "searchOrCreate": "Cerca o crea…"
379
+ },
380
+ "ja": {
381
+ "add": "追加",
382
+ "create": "「{label}」を作成",
383
+ "remove": "{label} を削除",
384
+ "search": "検索…",
385
+ "searchOrCreate": "検索または作成…"
386
+ },
387
+ "ko": {
388
+ "add": "추가",
389
+ "create": "\"{label}\" 만들기",
390
+ "remove": "{label} 제거",
391
+ "search": "검색…",
392
+ "searchOrCreate": "검색 또는 생성…"
393
+ },
394
+ "nl": {
395
+ "add": "Toevoegen",
396
+ "create": "\"{label}\" maken",
397
+ "remove": "{label} verwijderen",
398
+ "search": "Zoeken…",
399
+ "searchOrCreate": "Zoeken of maken…"
400
+ },
401
+ "pl": {
402
+ "add": "Dodaj",
403
+ "create": "Utwórz „{label}”",
404
+ "remove": "Usuń {label}",
405
+ "search": "Szukaj…",
406
+ "searchOrCreate": "Szukaj lub utwórz…"
407
+ },
408
+ "pt": {
409
+ "add": "Adicionar",
410
+ "create": "Criar «{label}»",
411
+ "remove": "Remover {label}",
412
+ "search": "Pesquisar…",
413
+ "searchOrCreate": "Pesquisar ou criar…"
414
+ },
415
+ "pt-BR": {
416
+ "add": "Adicionar",
417
+ "create": "Criar \"{label}\"",
418
+ "remove": "Remover {label}",
419
+ "search": "Pesquisar…",
420
+ "searchOrCreate": "Pesquisar ou criar…"
421
+ },
422
+ "vi": {
423
+ "add": "Thêm",
424
+ "create": "Tạo \"{label}\"",
425
+ "remove": "Xóa {label}",
426
+ "search": "Tìm…",
427
+ "searchOrCreate": "Tìm hoặc tạo…"
428
+ }
429
+ }
430
+ </i18n>
@@ -338,8 +338,6 @@ export interface BaseTag {
338
338
 
339
339
  export type BaseTagColor = 'gray' | 'green' | 'indigo' | 'orange'
340
340
 
341
- export type BaseTagVariant = 'filled' | 'ghost'
342
-
343
341
  export interface BaseTagItem extends BaseTag {
344
342
  id?: number | string
345
343
  order?: number
@@ -353,6 +351,8 @@ export interface BaseTags {
353
351
 
354
352
  export type BaseTagSize = 'base' | 'sm' | 'xs'
355
353
 
354
+ export type BaseTagVariant = 'filled' | 'ghost'
355
+
356
356
  export interface BaseText {
357
357
  alignment?: BaseAlignment
358
358
  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[]
@@ -91,6 +91,7 @@ declare global {
91
91
  type FieldAvatar = import('./fields').FieldAvatar
92
92
  type FieldBackground = import('./fields').FieldBackground
93
93
  type FieldCheckbox = import('./fields').FieldCheckbox
94
+ type FieldCombobox<Item = Record<string, unknown>> = import('./fields').FieldCombobox<Item>
94
95
  type FieldDays = import('./fields').FieldDays
95
96
  type FieldEmojis = import('./fields').FieldEmojis
96
97
  type FieldInput = import('./fields').FieldInput
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "2.0.58",
3
+ "version": "2.0.60",
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