@saasmakers/ui 2.0.67 → 2.0.69

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,19 @@ 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
71
+ const cursorAtEnd = input
72
+ ? input.selectionStart === input.value.length && input.selectionEnd === input.value.length
73
+ : true
68
74
 
69
- if (highlightedIndex.value > lastIndex) {
70
- highlightedIndex.value = lastIndex
71
- }
75
+ return searchRaw.value.length > 0
76
+ && ((direction === 'left' && !cursorAtStart) || (direction === 'right' && !cursorAtEnd))
77
+ },
72
78
  })
73
79
 
74
80
  function getItemKey(item: Item) {
@@ -81,156 +87,6 @@ function getItemText(item: Item) {
81
87
  : String(readField(item, props.itemLabel) ?? '')
82
88
  }
83
89
 
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
90
  function onBackspace() {
235
91
  if (searchRaw.value === '' && modelValue.value.length > 0) {
236
92
  modelValue.value = modelValue.value.slice(0, -1)
@@ -241,7 +97,7 @@ function onClose() {
241
97
  if (opened.value) {
242
98
  opened.value = false
243
99
  searchRaw.value = ''
244
- highlightedIndex.value = 0
100
+ resetHighlight()
245
101
  }
246
102
  }
247
103
 
@@ -253,7 +109,7 @@ async function onCreate() {
253
109
  emit('create', label)
254
110
 
255
111
  searchRaw.value = ''
256
- highlightedIndex.value = 0
112
+ resetHighlight()
257
113
 
258
114
  await nextTick()
259
115
 
@@ -276,14 +132,14 @@ function onEnter() {
276
132
  }
277
133
  }
278
134
 
279
- function onHighlight(index: number) {
280
- highlightedIndex.value = index
135
+ function onChipMouseDown(event: MouseEvent) {
136
+ event.preventDefault()
281
137
  }
282
138
 
283
139
  async function onOpen() {
284
140
  if (!props.disabled && !opened.value) {
285
141
  opened.value = true
286
- highlightedIndex.value = 0
142
+ resetHighlight()
287
143
 
288
144
  await nextTick()
289
145
 
@@ -301,7 +157,7 @@ function onRemove(item: Item) {
301
157
  async function onSelect(item: Item) {
302
158
  modelValue.value = [...modelValue.value, item]
303
159
  searchRaw.value = ''
304
- highlightedIndex.value = 0
160
+ resetHighlight()
305
161
 
306
162
  await nextTick()
307
163
 
@@ -349,39 +205,31 @@ function readField(item: Item, field: string) {
349
205
  'min-h-14': size === 'lg',
350
206
  }"
351
207
  >
352
- <button
208
+ <BaseChip
353
209
  v-for="item in modelValue"
354
210
  :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
211
+ :ariaLabel="t('remove', { label: getItemText(item) })"
212
+ normal-case
213
+ removable
214
+ variant="filled"
215
+ @mousedown="onChipMouseDown"
359
216
  @click="onRemove(item)"
360
217
  >
361
- <span class="truncate">{{ getItemText(item) }}</span>
218
+ {{ getItemText(item) }}
219
+ </BaseChip>
362
220
 
363
- <BaseIcon
364
- color="gray"
365
- :icon="getLayerIcon('close')"
366
- size="xs"
367
- />
368
- </button>
369
-
370
- <button
221
+ <BaseChip
371
222
  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"
223
+ :ariaLabel="t('add')"
374
224
  :disabled="disabled"
375
- type="button"
225
+ :icon="getLayerIcon('plus')"
226
+ icon-leading
227
+ normal-case
228
+ variant="dashed"
376
229
  @click="onOpen"
377
230
  >
378
- <BaseIcon
379
- :icon="getLayerIcon('plus')"
380
- size="xs"
381
- />
382
-
383
- <span>{{ t('add') }}</span>
384
- </button>
231
+ {{ t('add') }}
232
+ </BaseChip>
385
233
 
386
234
  <input
387
235
  v-else
@@ -418,45 +266,42 @@ function readField(item: Item, field: string) {
418
266
  class="max-h-[150px] flex flex-wrap gap-1.5 overflow-y-auto"
419
267
  role="listbox"
420
268
  >
421
- <div
269
+ <BaseChip
422
270
  v-for="(item, index) in filteredItems"
423
271
  :id="`${id}-option-${index}`"
424
272
  :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 }"
273
+ :active="index === highlightedIndex"
274
+ :ariaSelected="index === highlightedIndex"
275
+ as="div"
276
+ max-width
277
+ normal-case
428
278
  role="option"
279
+ variant="outline"
429
280
  @click="onSelect(item)"
430
- @mousedown.prevent
281
+ @mousedown="onChipMouseDown"
431
282
  @mouseenter="onHighlight(index)"
432
283
  >
433
- <span class="truncate">
434
- {{ getItemText(item) }}
435
- </span>
436
- </div>
284
+ {{ getItemText(item) }}
285
+ </BaseChip>
437
286
 
438
- <div
287
+ <BaseChip
439
288
  v-if="canCreate"
440
289
  :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 }"
290
+ :active="highlightedIndex === createOptionIndex"
291
+ :ariaSelected="highlightedIndex === createOptionIndex"
292
+ as="div"
293
+ max-width
294
+ :icon="getLayerIcon('plus')"
295
+ icon-leading
296
+ normal-case
444
297
  role="option"
298
+ variant="dashed"
445
299
  @click="onCreate"
446
- @mousedown.prevent
300
+ @mousedown="onChipMouseDown"
447
301
  @mouseenter="onHighlight(createOptionIndex)"
448
302
  >
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>
303
+ {{ t('create', { label: searchRaw.trim() }) }}
304
+ </BaseChip>
460
305
  </div>
461
306
  </div>
462
307
  </div>