@saasmakers/ui 2.0.60 → 2.0.62

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.
@@ -4,6 +4,16 @@ html,
4
4
  body {
5
5
  scrollbar-gutter: stable;
6
6
  touch-action: manipulation;
7
+ -webkit-touch-callout: none;
8
+ -webkit-user-select: none;
9
+ user-select: none;
10
+ }
11
+
12
+ input,
13
+ textarea,
14
+ [contenteditable='true'] {
15
+ -webkit-user-select: text;
16
+ user-select: text;
7
17
  }
8
18
 
9
19
  /* Theme backgrounds */
@@ -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>
@@ -28,17 +28,18 @@ const id = useId()
28
28
  const listId = `${id}-list`
29
29
  const modelValue = defineModel<Item[]>({ default: () => [] })
30
30
  const inputRef = ref<HTMLInputElement>()
31
+ const listRef = ref<HTMLElement>()
31
32
  const opened = ref(false)
32
33
  const searchRaw = ref('')
33
34
  const highlightedIndex = ref(0)
34
35
 
35
36
  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)))
37
+ const selectedKeys = new Set(modelValue.value.map(item => getItemKey(item)))
38
+ const availableItems = props.items.filter(item => !selectedKeys.has(getItemKey(item)))
38
39
  const searchQuery = normalizeText(searchRaw.value.trim())
39
40
 
40
41
  return searchQuery
41
- ? availableItems.filter(item => normalizeText(labelOf(item)).includes(searchQuery))
42
+ ? availableItems.filter(item => normalizeText(getItemText(item)).includes(searchQuery))
42
43
  : availableItems
43
44
  })
44
45
 
@@ -48,13 +49,13 @@ const canCreate = computed(() => {
48
49
  if (props.allowCreate && trimmed) {
49
50
  const normalizedInput = normalizeText(trimmed)
50
51
 
51
- return !props.items.some(item => normalizeText(labelOf(item)) === normalizedInput)
52
+ return !props.items.some(item => normalizeText(getItemText(item)) === normalizedInput)
52
53
  }
53
54
 
54
55
  return false
55
56
  })
56
57
 
57
- const createIndex = computed(() => {
58
+ const createOptionIndex = computed(() => {
58
59
  return canCreate.value ? filteredItems.value.length : -1
59
60
  })
60
61
 
@@ -70,22 +71,164 @@ watch([filteredItems, canCreate], () => {
70
71
  }
71
72
  })
72
73
 
73
- function keyOf(item: Item) {
74
+ function getItemKey(item: Item) {
74
75
  return typeof props.itemKey === 'function' ? props.itemKey(item) : String(readField(item, props.itemKey))
75
76
  }
76
77
 
77
- function labelOf(item: Item) {
78
+ function getItemText(item: Item) {
78
79
  return typeof props.itemLabel === 'function'
79
80
  ? props.itemLabel(item)
80
81
  : String(readField(item, props.itemLabel) ?? '')
81
82
  }
82
83
 
83
- function onArrowDown() {
84
- highlightedIndex.value = Math.min(highlightedIndex.value + 1, optionsCount.value - 1)
84
+ function getOptionElements() {
85
+ if (!listRef.value) {
86
+ return []
87
+ }
88
+
89
+ return Array.from(listRef.value.querySelectorAll<HTMLElement>('[role="option"]'))
85
90
  }
86
91
 
87
- function onArrowUp() {
88
- highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
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)
89
232
  }
90
233
 
91
234
  function onBackspace() {
@@ -102,7 +245,7 @@ function onClose() {
102
245
  }
103
246
  }
104
247
 
105
- function onCreate() {
248
+ async function onCreate() {
106
249
  const label = searchRaw.value.trim()
107
250
 
108
251
  if (label) {
@@ -112,13 +255,15 @@ function onCreate() {
112
255
  searchRaw.value = ''
113
256
  highlightedIndex.value = 0
114
257
 
115
- nextTick(() => inputRef.value?.focus())
258
+ await nextTick()
259
+
260
+ inputRef.value?.focus()
116
261
  }
117
262
  }
118
263
 
119
264
  function onEnter() {
120
265
  if (optionsCount.value > 0) {
121
- if (highlightedIndex.value === createIndex.value) {
266
+ if (highlightedIndex.value === createOptionIndex.value) {
122
267
  onCreate()
123
268
  }
124
269
  else {
@@ -135,30 +280,32 @@ function onHighlight(index: number) {
135
280
  highlightedIndex.value = index
136
281
  }
137
282
 
138
- function onOpen() {
283
+ async function onOpen() {
139
284
  if (!props.disabled && !opened.value) {
140
285
  opened.value = true
141
286
  highlightedIndex.value = 0
142
287
 
143
- nextTick(() => {
144
- inputRef.value?.focus()
145
- inputRef.value?.scrollIntoView({ block: 'nearest' })
146
- })
288
+ await nextTick()
289
+
290
+ inputRef.value?.focus()
291
+ inputRef.value?.scrollIntoView({ block: 'nearest' })
147
292
  }
148
293
  }
149
294
 
150
295
  function onRemove(item: Item) {
151
- const removedKey = keyOf(item)
296
+ const removedKey = getItemKey(item)
152
297
 
153
- modelValue.value = modelValue.value.filter(current => keyOf(current) !== removedKey)
298
+ modelValue.value = modelValue.value.filter(current => getItemKey(current) !== removedKey)
154
299
  }
155
300
 
156
- function onSelect(item: Item) {
301
+ async function onSelect(item: Item) {
157
302
  modelValue.value = [...modelValue.value, item]
158
303
  searchRaw.value = ''
159
304
  highlightedIndex.value = 0
160
305
 
161
- nextTick(() => inputRef.value?.focus())
306
+ await nextTick()
307
+
308
+ inputRef.value?.focus()
162
309
  }
163
310
 
164
311
  function readField(item: Item, field: string) {
@@ -204,26 +351,26 @@ function readField(item: Item, field: string) {
204
351
  >
205
352
  <button
206
353
  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"
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"
210
357
  type="button"
211
358
  @mousedown.prevent
212
359
  @click="onRemove(item)"
213
360
  >
214
- <span class="truncate">{{ labelOf(item) }}</span>
361
+ <span class="truncate">{{ getItemText(item) }}</span>
215
362
 
216
363
  <BaseIcon
217
364
  color="gray"
218
365
  :icon="getLayerIcon('close')"
219
- size="2xs"
366
+ size="xs"
220
367
  />
221
368
  </button>
222
369
 
223
370
  <button
224
371
  v-if="!opened"
225
372
  :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"
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"
227
374
  :disabled="disabled"
228
375
  type="button"
229
376
  @click="onOpen"
@@ -241,7 +388,7 @@ function readField(item: Item, field: string) {
241
388
  :id="id"
242
389
  ref="inputRef"
243
390
  v-model="searchRaw"
244
- :aria-activedescendant="opened && optionsCount > 0 ? highlightedIndex === createIndex ? `${id}-create` : `${id}-option-${highlightedIndex}` : undefined"
391
+ :aria-activedescendant="opened && optionsCount > 0 ? highlightedIndex === createOptionIndex ? `${id}-create` : `${id}-option-${highlightedIndex}` : undefined"
245
392
  aria-autocomplete="list"
246
393
  :aria-controls="optionsCount > 0 ? listId : undefined"
247
394
  :aria-expanded="opened"
@@ -252,10 +399,12 @@ function readField(item: Item, field: string) {
252
399
  spellcheck="false"
253
400
  @blur="onClose"
254
401
  @keydown.backspace="onBackspace"
255
- @keydown.down.prevent="onArrowDown"
402
+ @keydown.down="onArrowKey($event, 'down')"
256
403
  @keydown.enter.prevent="onEnter"
257
404
  @keydown.escape.stop="onClose"
258
- @keydown.up.prevent="onArrowUp"
405
+ @keydown.left="onArrowKey($event, 'left')"
406
+ @keydown.right="onArrowKey($event, 'right')"
407
+ @keydown.up="onArrowKey($event, 'up')"
259
408
  >
260
409
  </div>
261
410
 
@@ -265,51 +414,40 @@ function readField(item: Item, field: string) {
265
414
  >
266
415
  <div
267
416
  :id="listId"
268
- class="max-h-[150px] overflow-y-auto"
417
+ ref="listRef"
418
+ class="max-h-[150px] flex flex-wrap gap-1.5 overflow-y-auto"
269
419
  role="listbox"
270
420
  >
271
421
  <div
272
422
  v-for="(item, index) in filteredItems"
273
423
  :id="`${id}-option-${index}`"
274
- :key="keyOf(item)"
424
+ :key="getItemKey(item)"
275
425
  :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
- }"
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 }"
284
428
  role="option"
285
429
  @click="onSelect(item)"
286
430
  @mousedown.prevent
287
431
  @mouseenter="onHighlight(index)"
288
432
  >
289
433
  <span class="truncate">
290
- {{ labelOf(item) }}
434
+ {{ getItemText(item) }}
291
435
  </span>
292
436
  </div>
293
437
 
294
438
  <div
295
439
  v-if="canCreate"
296
440
  :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
- }"
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 }"
306
444
  role="option"
307
445
  @click="onCreate"
308
446
  @mousedown.prevent
309
- @mouseenter="onHighlight(createIndex)"
447
+ @mouseenter="onHighlight(createOptionIndex)"
310
448
  >
311
449
  <BaseIcon
312
- class="mr-1.5 shrink-0"
450
+ class="shrink-0"
313
451
  color="gray"
314
452
  :icon="getLayerIcon('plus')"
315
453
  size="2xs"
@@ -67,6 +67,9 @@ function focus() {
67
67
  }
68
68
 
69
69
  function onFieldBlur(event: FocusEvent) {
70
+ // iOS keyboard paste can update the DOM without firing `input` before blur
71
+ syncModelValueFromTarget(event.target)
72
+
70
73
  if (props.trim) {
71
74
  modelValue.value = modelValue.value.toString().trim()
72
75
  }
@@ -79,23 +82,13 @@ function onFieldFocus(event: FocusEvent) {
79
82
  }
80
83
 
81
84
  function onFieldInput(event: Event) {
82
- let value = (event.target as HTMLInputElement).value
83
-
84
- if (props.type === 'number' && props.max && Number.parseInt(value) > props.max) {
85
- value = props.max.toString()
86
- }
87
-
88
- if (props.slugOnly) {
89
- value = value.toString().toLowerCase().replace(/[^a-z0-9_-]/g, '')
90
- }
91
- else if (props.lowercaseOnly) {
92
- value = value.toString().toLowerCase()
93
- }
94
-
95
- modelValue.value = value
85
+ syncModelValueFromTarget(event.target)
96
86
  }
97
87
 
98
88
  function onFieldKeyDown(event: KeyboardEvent) {
89
+ // iOS keyboard paste can update the DOM without firing `input` before keydown
90
+ syncModelValueFromTarget(event.target)
91
+
99
92
  const value = (event.target as HTMLInputElement).value
100
93
 
101
94
  if (['Control', 'Escape'].includes(event.key)) {
@@ -138,6 +131,31 @@ function onTogglePasswordVisibility() {
138
131
  showPassword.value = !showPassword.value
139
132
  }
140
133
 
134
+ function syncModelValueFromTarget(target: EventTarget | null) {
135
+ if (!(target instanceof HTMLInputElement)) {
136
+ return
137
+ }
138
+
139
+ modelValue.value = transformFieldValue(target.value)
140
+ }
141
+
142
+ function transformFieldValue(value: string) {
143
+ let nextValue = value
144
+
145
+ if (props.type === 'number' && props.max && Number.parseInt(nextValue) > props.max) {
146
+ nextValue = props.max.toString()
147
+ }
148
+
149
+ if (props.slugOnly) {
150
+ nextValue = nextValue.toString().toLowerCase().replace(/[^a-z0-9_-]/g, '')
151
+ }
152
+ else if (props.lowercaseOnly) {
153
+ nextValue = nextValue.toString().toLowerCase()
154
+ }
155
+
156
+ return nextValue
157
+ }
158
+
141
159
  defineExpose({
142
160
  blur,
143
161
  focus,
@@ -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
 
@@ -39,12 +39,22 @@ onMounted(() => {
39
39
  })
40
40
 
41
41
  function onFieldBlur(event: FocusEvent) {
42
+ // iOS keyboard paste can update the DOM without syncing v-model before blur
43
+ syncModelValueFromTarget(event.target)
42
44
  emit('blur', event, modelValue.value || '')
43
45
  }
44
46
 
45
47
  function onFieldClick(event: MouseEvent) {
46
48
  emit('click', event, modelValue.value || '')
47
49
  }
50
+
51
+ function syncModelValueFromTarget(target: EventTarget | null) {
52
+ if (!(target instanceof HTMLTextAreaElement)) {
53
+ return
54
+ }
55
+
56
+ modelValue.value = target.value
57
+ }
48
58
  </script>
49
59
 
50
60
  <template>
@@ -1,10 +1,21 @@
1
1
  export default function useDevice() {
2
+ const { isNative } = useCapacitor()
3
+
2
4
  const isDesktopBrowser = computed(() => {
3
5
  if (typeof navigator === 'undefined') {
4
6
  return true
5
7
  }
6
8
 
7
- return !navigator.userAgentData?.mobile
9
+ if (isNative.value) {
10
+ return false
11
+ }
12
+
13
+ // Chromium Client Hints — unavailable on Safari / Firefox / iOS WebKit
14
+ if (navigator.userAgentData) {
15
+ return !navigator.userAgentData.mobile
16
+ }
17
+
18
+ return !/Android|iPad|iPhone|iPod|Mobile/i.test(navigator.userAgent)
8
19
  })
9
20
 
10
21
  return { isDesktopBrowser }
@@ -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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "2.0.60",
3
+ "version": "2.0.62",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",