@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.
@@ -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,12 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ data-filter-item
5
+ flex="~ justify-between"
6
+ items-center gap-12px
7
+ px-8px h-28px rounded-8px w-full
8
+ hover:bg-muted focus-visible:bg-muted focus:outline-none
9
+ >
10
+ <slot />
11
+ </button>
12
+ </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>