@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.
- package/app/components/bases/BaseRing.vue +217 -0
- package/app/components/bases/BaseTags.vue +1 -1
- package/app/components/fields/FieldCombobox.vue +568 -0
- package/app/components/fields/FieldSelect.vue +4 -4
- package/app/types/bases.d.ts +24 -2
- package/app/types/fields.d.ts +19 -0
- package/app/types/global.d.ts +5 -0
- package/package.json +1 -1
- package/uno.config.ts +1 -0
|
@@ -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>
|
|
@@ -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
|
-
|
|
120
|
-
|
|
118
|
+
await nextTick()
|
|
119
|
+
|
|
120
|
+
searchInput.value?.focus()
|
|
121
121
|
}
|
|
122
122
|
})
|
|
123
123
|
|
package/app/types/bases.d.ts
CHANGED
|
@@ -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
|
package/app/types/fields.d.ts
CHANGED
|
@@ -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[]
|
package/app/types/global.d.ts
CHANGED
|
@@ -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
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
|
|