@meistrari/tela-build 1.42.2 → 1.43.0
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/components/tela/badge/badge.vue +1 -1
- package/components/tela/filter/filter-bar.vue +11 -0
- package/components/tela/filter/filter-content-item.vue +12 -0
- package/components/tela/filter/filter-content.vue +320 -0
- package/components/tela/filter/filter-trigger.vue +54 -0
- package/components/tela/filter/filter.mdx +293 -0
- package/components/tela/filter/filter.stories.ts +597 -0
- package/components/tela/filter/filter.vue +221 -0
- package/components/tela/filter/types.ts +25 -0
- package/components/tela/input/input.vue +8 -5
- package/components/tela/select-menu/select-menu-trigger.vue +1 -1
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@ const tag = computed(() => props.to ? NuxtLink : 'div')
|
|
|
22
22
|
v-bind="props.to ? { to: props.to, target: props.target } : {}"
|
|
23
23
|
:class="cn(
|
|
24
24
|
'inline-block px-[5px] py-[3px] rounded-[5px] select-none',
|
|
25
|
-
variant === 'outline' && 'border-[0.5px] border-border',
|
|
25
|
+
variant === 'outline' && 'bg-background border-[0.5px] border-border',
|
|
26
26
|
variant === 'filled' && 'bg-lowered',
|
|
27
27
|
props.class,
|
|
28
28
|
)"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div :class="cn('flex flex-wrap items-center select-none', props.class)">
|
|
9
|
+
<slot />
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue'
|
|
3
|
+
import { PopoverContent } from 'reka-ui'
|
|
4
|
+
import TelaRadioGroupRoot from '../radio-group/radio-group-root.vue'
|
|
5
|
+
import type { FilterContentConfig, FilterSelectionMode, FilterSelections } from './types'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
configs: FilterContentConfig[]
|
|
9
|
+
closing: boolean
|
|
10
|
+
settled: boolean
|
|
11
|
+
modelValue: FilterSelections
|
|
12
|
+
width: string
|
|
13
|
+
contentClass?: HTMLAttributes['class']
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
'update:modelValue': [value: FilterSelections]
|
|
18
|
+
'interactOutside': [event: Event]
|
|
19
|
+
'pointerDownOutside': [event: Event]
|
|
20
|
+
'select': []
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const fadeTransition = {
|
|
24
|
+
transition: {
|
|
25
|
+
opacity: { duration: 0.25 },
|
|
26
|
+
filter: { duration: 0.15 },
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const contentMeasure = useTemplateRef<HTMLElement>('contentMeasure')
|
|
31
|
+
const { height: heightContent } = useElementSize(contentMeasure)
|
|
32
|
+
|
|
33
|
+
const computedHeightContent = computed(() => {
|
|
34
|
+
if (heightContent.value > 0) {
|
|
35
|
+
return `${heightContent.value}px`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return undefined
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function isChecked(filterKey: string, optionValue: string) {
|
|
42
|
+
return props.modelValue[filterKey]?.includes(optionValue) ?? false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toggleOption(filterKey: string, optionValue: string, mode: FilterSelectionMode | undefined) {
|
|
46
|
+
const current = props.modelValue[filterKey] ?? []
|
|
47
|
+
|
|
48
|
+
let next: string[]
|
|
49
|
+
|
|
50
|
+
if (mode === 'multiple') {
|
|
51
|
+
next = current.includes(optionValue)
|
|
52
|
+
? current.filter(v => v !== optionValue)
|
|
53
|
+
: [...current, optionValue]
|
|
54
|
+
}
|
|
55
|
+
else if (mode === 'single') {
|
|
56
|
+
next = current.includes(optionValue) ? [] : [optionValue]
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
next = [optionValue]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
emit('update:modelValue', {
|
|
63
|
+
...props.modelValue,
|
|
64
|
+
[filterKey]: next,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (mode === undefined) {
|
|
68
|
+
emit('select')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getItemButtons(): HTMLButtonElement[] {
|
|
73
|
+
return Array.from(
|
|
74
|
+
contentMeasure.value?.querySelectorAll<HTMLButtonElement>('[data-filter-item]') ?? [],
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function focusItemAt(index: number) {
|
|
79
|
+
const buttons = getItemButtons()
|
|
80
|
+
|
|
81
|
+
if (buttons.length === 0) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const safe = ((index % buttons.length) + buttons.length) % buttons.length
|
|
86
|
+
|
|
87
|
+
buttons[safe]?.focus()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onItemsPointerOver(event: PointerEvent) {
|
|
91
|
+
const target = event.target as HTMLElement | null
|
|
92
|
+
|
|
93
|
+
if (!target?.closest('[data-filter-item]')) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const active = document.activeElement as HTMLElement | null
|
|
98
|
+
|
|
99
|
+
if (active && active.matches('[data-filter-item]')) {
|
|
100
|
+
active.blur()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onItemsKeydown(event: KeyboardEvent) {
|
|
105
|
+
const activeConfig = props.configs[0]?.config
|
|
106
|
+
|
|
107
|
+
if (!activeConfig || activeConfig.selectionMode === 'single') {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const buttons = getItemButtons()
|
|
112
|
+
|
|
113
|
+
if (buttons.length === 0) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
|
118
|
+
|
|
119
|
+
switch (event.key) {
|
|
120
|
+
case 'ArrowDown':
|
|
121
|
+
event.preventDefault()
|
|
122
|
+
focusItemAt(currentIndex === -1 ? 0 : currentIndex + 1)
|
|
123
|
+
break
|
|
124
|
+
case 'ArrowUp':
|
|
125
|
+
event.preventDefault()
|
|
126
|
+
focusItemAt(currentIndex === -1 ? buttons.length - 1 : currentIndex - 1)
|
|
127
|
+
break
|
|
128
|
+
case 'Home':
|
|
129
|
+
event.preventDefault()
|
|
130
|
+
focusItemAt(0)
|
|
131
|
+
break
|
|
132
|
+
case 'End':
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
focusItemAt(buttons.length - 1)
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
watch(() => props.settled, async (settled) => {
|
|
140
|
+
if (!settled) {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const activeConfig = props.configs[0]?.config
|
|
145
|
+
|
|
146
|
+
if (!activeConfig || activeConfig.selectionMode === 'single') {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await nextTick()
|
|
151
|
+
|
|
152
|
+
const selectedValue = props.modelValue[activeConfig.key]?.[0]
|
|
153
|
+
const selectedIndex = selectedValue
|
|
154
|
+
? activeConfig.options.findIndex(o => o.value === selectedValue)
|
|
155
|
+
: -1
|
|
156
|
+
|
|
157
|
+
focusItemAt(selectedIndex >= 0 ? selectedIndex : 0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
function getSlideMotionVariants(index: number, closingState: boolean) {
|
|
161
|
+
const exitIndex = closingState ? 0 : index
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
initial: {
|
|
165
|
+
x: `${100 * index}%`,
|
|
166
|
+
opacity: 0,
|
|
167
|
+
filter: 'blur(2px)',
|
|
168
|
+
...fadeTransition,
|
|
169
|
+
},
|
|
170
|
+
animate: {
|
|
171
|
+
x: 0,
|
|
172
|
+
opacity: 1,
|
|
173
|
+
filter: 'blur(0px)',
|
|
174
|
+
...fadeTransition,
|
|
175
|
+
},
|
|
176
|
+
exit: {
|
|
177
|
+
x: `${100 * exitIndex}%`,
|
|
178
|
+
opacity: 0,
|
|
179
|
+
filter: 'blur(2px)',
|
|
180
|
+
...fadeTransition,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
</script>
|
|
185
|
+
|
|
186
|
+
<template>
|
|
187
|
+
<PopoverContent
|
|
188
|
+
:side-offset="4"
|
|
189
|
+
align="start"
|
|
190
|
+
data-popover-content
|
|
191
|
+
:data-settled="settled || undefined"
|
|
192
|
+
:class="cn(
|
|
193
|
+
'relative select-none z-99999 p-4px bg-background border-[0.5px] border-border rounded-12px overflow-hidden',
|
|
194
|
+
'[box-shadow:0_3px_10px_0_rgba(163,163,163,0.08),0_12px_20px_0_rgba(163,163,163,0.06)]',
|
|
195
|
+
contentClass,
|
|
196
|
+
)"
|
|
197
|
+
@interact-outside="emit('interactOutside', $event)"
|
|
198
|
+
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
|
199
|
+
>
|
|
200
|
+
<div
|
|
201
|
+
class="[transition:height_0.25s_ease-out,width_0.25s_ease-out]"
|
|
202
|
+
:style="{ width, ...(computedHeightContent ? { height: computedHeightContent } : {}) }"
|
|
203
|
+
>
|
|
204
|
+
<div ref="contentMeasure" @keydown="onItemsKeydown" @pointerover="onItemsPointerOver">
|
|
205
|
+
<AnimatePresence mode="popLayout" :initial="false">
|
|
206
|
+
<Motion
|
|
207
|
+
v-for="cfg in configs"
|
|
208
|
+
:key="cfg.key"
|
|
209
|
+
v-bind="getSlideMotionVariants(cfg.slideIndex, closing)"
|
|
210
|
+
tabindex="-1"
|
|
211
|
+
flex="~ col"
|
|
212
|
+
>
|
|
213
|
+
<slot
|
|
214
|
+
:name="`header-${cfg.config.key}`"
|
|
215
|
+
:filter="cfg.config"
|
|
216
|
+
:selected="props.modelValue[cfg.config.key] ?? []"
|
|
217
|
+
:toggle="(value: string) => toggleOption(cfg.config.key, value, cfg.config.selectionMode)"
|
|
218
|
+
/>
|
|
219
|
+
|
|
220
|
+
<div mx--4px>
|
|
221
|
+
<TelaScrollArea :class="cn('flex flex-col', cfg.config.optionsClass)" tabindex="-1">
|
|
222
|
+
<div px-4px>
|
|
223
|
+
<component
|
|
224
|
+
:is="cfg.config.selectionMode === 'single' ? TelaRadioGroupRoot : 'div'"
|
|
225
|
+
v-bind="cfg.config.selectionMode === 'single'
|
|
226
|
+
? {
|
|
227
|
+
'modelValue': props.modelValue[cfg.config.key]?.[0] ?? '',
|
|
228
|
+
'onUpdate:modelValue': (val: string) => toggleOption(cfg.config.key, val, 'single'),
|
|
229
|
+
}
|
|
230
|
+
: {}"
|
|
231
|
+
class="contents"
|
|
232
|
+
>
|
|
233
|
+
<template v-for="option in cfg.config.options" :key="option.value">
|
|
234
|
+
<slot
|
|
235
|
+
:name="`option-${cfg.config.key}`"
|
|
236
|
+
:option="option"
|
|
237
|
+
:filter="cfg.config"
|
|
238
|
+
:checked="isChecked(cfg.config.key, option.value)"
|
|
239
|
+
:toggle="() => toggleOption(cfg.config.key, option.value, cfg.config.selectionMode)"
|
|
240
|
+
:mode="cfg.config.selectionMode"
|
|
241
|
+
>
|
|
242
|
+
<TelaFilterContentItem
|
|
243
|
+
:checked="isChecked(cfg.config.key, option.value)"
|
|
244
|
+
@click="toggleOption(cfg.config.key, option.value, cfg.config.selectionMode)"
|
|
245
|
+
>
|
|
246
|
+
<span text-primary body-14-medium truncate>{{ option.label }}</span>
|
|
247
|
+
<TelaRadioGroupItem
|
|
248
|
+
v-if="cfg.config.selectionMode === 'single'"
|
|
249
|
+
:value="option.value"
|
|
250
|
+
class="pointer-events-none"
|
|
251
|
+
/>
|
|
252
|
+
<TelaCheckbox
|
|
253
|
+
v-else-if="cfg.config.selectionMode === 'multiple'"
|
|
254
|
+
:model-value="isChecked(cfg.config.key, option.value)"
|
|
255
|
+
class="pointer-events-none"
|
|
256
|
+
@update:model-value="toggleOption(cfg.config.key, option.value, cfg.config.selectionMode)"
|
|
257
|
+
@click.stop
|
|
258
|
+
/>
|
|
259
|
+
</TelaFilterContentItem>
|
|
260
|
+
</slot>
|
|
261
|
+
</template>
|
|
262
|
+
</component>
|
|
263
|
+
</div>
|
|
264
|
+
</TelaScrollArea>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<slot
|
|
268
|
+
:name="`footer-${cfg.config.key}`"
|
|
269
|
+
:filter="cfg.config"
|
|
270
|
+
:selected="props.modelValue[cfg.config.key] ?? []"
|
|
271
|
+
:toggle="(value: string) => toggleOption(cfg.config.key, value, cfg.config.selectionMode)"
|
|
272
|
+
/>
|
|
273
|
+
</Motion>
|
|
274
|
+
</AnimatePresence>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</PopoverContent>
|
|
278
|
+
</template>
|
|
279
|
+
|
|
280
|
+
<style>
|
|
281
|
+
[data-popover-content] {
|
|
282
|
+
transform-origin: var(--reka-popover-content-transform-origin);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
[data-popover-content][data-state="open"] {
|
|
286
|
+
animation: enter 0.12s ease-out forwards;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
[data-popover-content][data-state="closed"] {
|
|
290
|
+
animation: exit 0.12s ease-out forwards;
|
|
291
|
+
animation-delay: 0s;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
[data-reka-popper-content-wrapper]:has(> [data-popover-content][data-settled]) {
|
|
295
|
+
transition: transform 0.25s ease-out;
|
|
296
|
+
will-change: transform;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
@keyframes enter {
|
|
300
|
+
from {
|
|
301
|
+
opacity: 0;
|
|
302
|
+
transform: translateY(2px) scale(0.97);
|
|
303
|
+
}
|
|
304
|
+
to {
|
|
305
|
+
opacity: 1;
|
|
306
|
+
transform: scale(1);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@keyframes exit {
|
|
311
|
+
from {
|
|
312
|
+
opacity: 1;
|
|
313
|
+
transform: translateY(0) scale(1);
|
|
314
|
+
}
|
|
315
|
+
to {
|
|
316
|
+
opacity: 0;
|
|
317
|
+
transform: translateY(-2px) scale(0.97);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
</style>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue'
|
|
3
|
+
import type { FilterConfig } from './types'
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
filter: FilterConfig
|
|
7
|
+
isActive: boolean
|
|
8
|
+
count: number
|
|
9
|
+
triggerClass?: HTMLAttributes['class']
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
defineEmits<{
|
|
13
|
+
click: []
|
|
14
|
+
}>()
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
:data-active="isActive || undefined"
|
|
21
|
+
:class="cn(
|
|
22
|
+
'group inline-flex h-[32px] items-center gap-[7px] px-[12px] border-l-[0.5px] border-y-[0.5px] border-strong bg-background outline-none transition-all duration-150',
|
|
23
|
+
'text-[14px] leading-[18px] -tracking-[0.15px] font-540 text-primary',
|
|
24
|
+
'focus-visible:ring-2 focus-visible:ring-gray-200 [&:not([data-active])]:hover:bg-subtle data-[active]:bg-muted first:rounded-l-[10px] last:rounded-r-[10px] last:border-r-[0.5px]',
|
|
25
|
+
triggerClass,
|
|
26
|
+
)"
|
|
27
|
+
@click="$emit('click')"
|
|
28
|
+
>
|
|
29
|
+
<div inline-flex items-center>
|
|
30
|
+
<span text-14px leading-18px tracking--0.15px font-540 text-primary>{{ filter.label }}</span>
|
|
31
|
+
|
|
32
|
+
<AnimatePresence>
|
|
33
|
+
<Motion
|
|
34
|
+
v-if="count && filter.selectionMode === 'multiple'"
|
|
35
|
+
mt--3px ml-5px
|
|
36
|
+
:initial="{ width: 0, x: -20, scale: 0, opacity: 0 }"
|
|
37
|
+
:animate="{ width: 'auto', x: 0, scale: 1, opacity: 1 }"
|
|
38
|
+
:exit="{ width: 0, x: -20, scale: 0, opacity: 0 }"
|
|
39
|
+
:transition="{ duration: 0.15, ease: 'easeOut' }"
|
|
40
|
+
>
|
|
41
|
+
<TelaBadge variant="filled" class="py-[1px] px-[4px] rounded-[4px]" text-class="tabular-nums text-[8px]">
|
|
42
|
+
<TelaAnimatedNumber :value="count || 0" />
|
|
43
|
+
</TelaBadge>
|
|
44
|
+
</Motion>
|
|
45
|
+
</AnimatePresence>
|
|
46
|
+
</div>
|
|
47
|
+
<TelaIcon
|
|
48
|
+
name="i-ph-caret-down-bold"
|
|
49
|
+
size="12px"
|
|
50
|
+
color="icon-secondary"
|
|
51
|
+
class="shrink-0 transition-all ease-out duration-120 group-data-[active]:rotate-180"
|
|
52
|
+
/>
|
|
53
|
+
</button>
|
|
54
|
+
</template>
|