@saasmakers/ui 2.0.68 → 2.0.70

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,125 @@
1
+ <script lang="ts" setup>
2
+ import type { BaseChip } from '../../types/bases'
3
+
4
+ const props = withDefaults(defineProps<BaseChip>(), {
5
+ active: false,
6
+ activeHighlight: 'gray',
7
+ ariaLabel: undefined,
8
+ ariaSelected: undefined,
9
+ as: 'button',
10
+ disabled: false,
11
+ hoverable: false,
12
+ icon: undefined,
13
+ iconLeading: false,
14
+ iconSize: undefined,
15
+ id: undefined,
16
+ maxWidth: false,
17
+ normalCase: false,
18
+ removable: false,
19
+ role: undefined,
20
+ text: '',
21
+ variant: 'outline',
22
+ })
23
+
24
+ const emit = defineEmits<{
25
+ click: [event: MouseEvent]
26
+ mousedown: [event: MouseEvent]
27
+ mouseenter: [event: MouseEvent]
28
+ }>()
29
+
30
+ defineSlots<{
31
+ default?: () => VNode[]
32
+ }>()
33
+
34
+ const hasLeadingIcon = computed(() => {
35
+ return props.iconLeading && !!props.icon
36
+ })
37
+
38
+ const hasTrailingIcon = computed(() => {
39
+ return props.removable || (!props.iconLeading && !!props.icon)
40
+ })
41
+
42
+ const resolvedIconSize = computed<BaseSize>(() => {
43
+ if (props.iconSize) {
44
+ return props.iconSize
45
+ }
46
+
47
+ if (props.maxWidth && props.iconLeading) {
48
+ return '2xs'
49
+ }
50
+
51
+ return 'xs'
52
+ })
53
+
54
+ function onClick(event: MouseEvent) {
55
+ emit('click', event)
56
+ }
57
+
58
+ function onMouseDown(event: MouseEvent) {
59
+ emit('mousedown', event)
60
+ }
61
+
62
+ function onMouseEnter(event: MouseEvent) {
63
+ emit('mouseenter', event)
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <component
69
+ :is="as"
70
+ :id="id"
71
+ class="min-h-8 inline-flex items-center border rounded-md font-normal tracking-tight outline-none transition-colors"
72
+ :class="{
73
+ 'max-w-full': maxWidth,
74
+ 'gap-1': hasLeadingIcon || hasTrailingIcon,
75
+ 'cursor-pointer': !disabled,
76
+ 'normal-case': normalCase,
77
+ 'field-disabled': disabled,
78
+ 'base-border bg-gray-100 py-1 pl-2 pr-1.5 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700': variant === 'filled',
79
+ 'base-border px-2 py-1 text-gray-600 dark:text-gray-400': variant === 'outline' && !active,
80
+ 'base-border-hover': variant === 'outline' && !active && hoverable,
81
+ 'base-border px-2 py-1 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300': variant === 'outline' && active && activeHighlight === 'gray',
82
+ 'base-highlight-indigo border-indigo-600/35 px-2 py-1 text-gray-900 dark:text-gray-100': variant === 'outline' && active && activeHighlight === 'indigo',
83
+ 'base-border base-border-hover border-dashed py-1 text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300': variant === 'dashed' && !active,
84
+ 'pl-1.5 pr-2': variant === 'dashed' && !active && iconLeading,
85
+ 'px-2 py-1': variant === 'dashed' && !active && !iconLeading,
86
+ 'base-border border-dashed px-2 py-1 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300': variant === 'dashed' && active && activeHighlight === 'gray',
87
+ 'base-highlight-indigo border-indigo-600/35 border-dashed px-2 py-1 text-gray-900 dark:text-gray-100': variant === 'dashed' && active && activeHighlight === 'indigo',
88
+ }"
89
+ :aria-label="ariaLabel"
90
+ :aria-selected="ariaSelected"
91
+ :disabled="as === 'button' ? disabled : undefined"
92
+ :role="role"
93
+ :type="as === 'button' ? 'button' : undefined"
94
+ @click="onClick"
95
+ @mousedown="onMouseDown"
96
+ @mouseenter="onMouseEnter"
97
+ >
98
+ <BaseIcon
99
+ v-if="hasLeadingIcon"
100
+ class="shrink-0"
101
+ :color="maxWidth ? 'gray' : undefined"
102
+ :icon="icon"
103
+ :size="resolvedIconSize"
104
+ />
105
+
106
+ <span class="truncate">
107
+ <slot>{{ text }}</slot>
108
+ </span>
109
+
110
+ <BaseIcon
111
+ v-if="removable"
112
+ color="gray"
113
+ :icon="getLayerIcon('close')"
114
+ size="xs"
115
+ />
116
+
117
+ <BaseIcon
118
+ v-else-if="hasTrailingIcon"
119
+ class="shrink-0"
120
+ color="gray"
121
+ :icon="icon"
122
+ :size="resolvedIconSize"
123
+ />
124
+ </component>
125
+ </template>
@@ -31,7 +31,6 @@ const inputRef = ref<HTMLInputElement>()
31
31
  const listRef = ref<HTMLElement>()
32
32
  const opened = ref(false)
33
33
  const searchRaw = ref('')
34
- const highlightedIndex = ref(0)
35
34
 
36
35
  const filteredItems = computed(() => {
37
36
  const selectedKeys = new Set(modelValue.value.map(item => getItemKey(item)))
@@ -63,12 +62,20 @@ const optionsCount = computed(() => {
63
62
  return filteredItems.value.length + (canCreate.value ? 1 : 0)
64
63
  })
65
64
 
66
- watch([filteredItems, canCreate], () => {
67
- const lastIndex = Math.max(0, optionsCount.value - 1)
65
+ const { highlightedIndex, onArrowKey, onHighlight, resetHighlight } = useChipNavigation({
66
+ containerRef: listRef,
67
+ count: optionsCount,
68
+ shouldIgnoreHorizontal: (direction) => {
69
+ const input = inputRef.value
70
+ const cursorAtStart = input ? input.selectionStart === 0 && input.selectionEnd === 0 : true
68
71
 
69
- if (highlightedIndex.value > lastIndex) {
70
- highlightedIndex.value = lastIndex
71
- }
72
+ const cursorAtEnd = input
73
+ ? input.selectionStart === input.value.length && input.selectionEnd === input.value.length
74
+ : true
75
+
76
+ return searchRaw.value.length > 0
77
+ && ((direction === 'left' && !cursorAtStart) || (direction === 'right' && !cursorAtEnd))
78
+ },
72
79
  })
73
80
 
74
81
  function getItemKey(item: Item) {
@@ -81,167 +88,22 @@ function getItemText(item: Item) {
81
88
  : String(readField(item, props.itemLabel) ?? '')
82
89
  }
83
90
 
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
91
  function onBackspace() {
235
92
  if (searchRaw.value === '' && modelValue.value.length > 0) {
236
93
  modelValue.value = modelValue.value.slice(0, -1)
237
94
  }
238
95
  }
239
96
 
97
+ function onChipMouseDown(event: MouseEvent) {
98
+ event.preventDefault()
99
+ }
100
+
240
101
  function onClose() {
241
102
  if (opened.value) {
242
103
  opened.value = false
243
104
  searchRaw.value = ''
244
- highlightedIndex.value = 0
105
+
106
+ resetHighlight()
245
107
  }
246
108
  }
247
109
 
@@ -253,7 +115,8 @@ async function onCreate() {
253
115
  emit('create', label)
254
116
 
255
117
  searchRaw.value = ''
256
- highlightedIndex.value = 0
118
+
119
+ resetHighlight()
257
120
 
258
121
  await nextTick()
259
122
 
@@ -276,14 +139,11 @@ function onEnter() {
276
139
  }
277
140
  }
278
141
 
279
- function onHighlight(index: number) {
280
- highlightedIndex.value = index
281
- }
282
-
283
142
  async function onOpen() {
284
143
  if (!props.disabled && !opened.value) {
285
144
  opened.value = true
286
- highlightedIndex.value = 0
145
+
146
+ resetHighlight()
287
147
 
288
148
  await nextTick()
289
149
 
@@ -301,7 +161,8 @@ function onRemove(item: Item) {
301
161
  async function onSelect(item: Item) {
302
162
  modelValue.value = [...modelValue.value, item]
303
163
  searchRaw.value = ''
304
- highlightedIndex.value = 0
164
+
165
+ resetHighlight()
305
166
 
306
167
  await nextTick()
307
168
 
@@ -349,39 +210,31 @@ function readField(item: Item, field: string) {
349
210
  'min-h-14': size === 'lg',
350
211
  }"
351
212
  >
352
- <button
213
+ <BaseChip
353
214
  v-for="item in modelValue"
354
215
  :key="getItemKey(item)"
355
216
  :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
217
+
218
+ removable normal-case
219
+ variant="filled"
220
+ @mousedown="onChipMouseDown"
359
221
  @click="onRemove(item)"
360
222
  >
361
- <span class="truncate">{{ getItemText(item) }}</span>
223
+ {{ getItemText(item) }}
224
+ </BaseChip>
362
225
 
363
- <BaseIcon
364
- color="gray"
365
- :icon="getLayerIcon('close')"
366
- size="xs"
367
- />
368
- </button>
369
-
370
- <button
226
+ <BaseChip
371
227
  v-if="!opened"
372
228
  :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
229
  :disabled="disabled"
375
- type="button"
230
+ :icon="getLayerIcon('plus')"
231
+ icon-leading
232
+ normal-case
233
+ variant="dashed"
376
234
  @click="onOpen"
377
235
  >
378
- <BaseIcon
379
- :icon="getLayerIcon('plus')"
380
- size="xs"
381
- />
382
-
383
- <span>{{ t('add') }}</span>
384
- </button>
236
+ {{ t('add') }}
237
+ </BaseChip>
385
238
 
386
239
  <input
387
240
  v-else
@@ -418,45 +271,42 @@ function readField(item: Item, field: string) {
418
271
  class="max-h-[150px] flex flex-wrap gap-1.5 overflow-y-auto"
419
272
  role="listbox"
420
273
  >
421
- <div
274
+ <BaseChip
422
275
  v-for="(item, index) in filteredItems"
423
276
  :id="`${id}-option-${index}`"
424
277
  :key="getItemKey(item)"
278
+ :active="index === highlightedIndex"
425
279
  :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 }"
280
+ as="div"
281
+ max-width
282
+ normal-case
428
283
  role="option"
284
+ variant="outline"
429
285
  @click="onSelect(item)"
430
- @mousedown.prevent
286
+ @mousedown="onChipMouseDown"
431
287
  @mouseenter="onHighlight(index)"
432
288
  >
433
- <span class="truncate">
434
- {{ getItemText(item) }}
435
- </span>
436
- </div>
289
+ {{ getItemText(item) }}
290
+ </BaseChip>
437
291
 
438
- <div
292
+ <BaseChip
439
293
  v-if="canCreate"
440
294
  :id="`${id}-create`"
295
+ :active="highlightedIndex === createOptionIndex"
441
296
  :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 }"
297
+ as="div"
298
+ max-width
299
+ :icon="getLayerIcon('plus')"
300
+ icon-leading
301
+ normal-case
444
302
  role="option"
303
+ variant="dashed"
445
304
  @click="onCreate"
446
- @mousedown.prevent
305
+ @mousedown="onChipMouseDown"
447
306
  @mouseenter="onHighlight(createOptionIndex)"
448
307
  >
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>
308
+ {{ t('create', { label: searchRaw.trim() }) }}
309
+ </BaseChip>
460
310
  </div>
461
311
  </div>
462
312
  </div>