@fy-/fws-vue 2.2.65 → 2.2.67

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.
@@ -2,10 +2,13 @@
2
2
  import {
3
3
  ArrowDownIcon,
4
4
  ArrowDownTrayIcon,
5
+ ArrowsUpDownIcon,
5
6
  ArrowUpIcon,
7
+ MagnifyingGlassIcon,
8
+ XCircleIcon,
6
9
  } from '@heroicons/vue/24/solid'
7
10
  import { useStorage } from '@vueuse/core'
8
- import { onMounted, onUnmounted, ref, watch } from 'vue'
11
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
9
12
  import { useRoute } from 'vue-router'
10
13
  import { useEventBus } from '../../composables/event-bus'
11
14
  import { useRest } from '../../composables/rest'
@@ -30,6 +33,8 @@ const currentPage = ref<number>(1)
30
33
  const route = useRoute()
31
34
  const data = ref<any[]>([])
32
35
  const paging = ref<any>(undefined)
36
+ const searchTerm = ref<string>('')
37
+ const isLoading = ref<boolean>(false)
33
38
  const perPageOptions = [
34
39
  ['10', '10'],
35
40
  ['25', '25'],
@@ -68,7 +73,14 @@ const currentSort = useStorage<SortingField>(
68
73
  `${props.id}CurrentSort`,
69
74
  props.defaultSort,
70
75
  )
76
+
77
+ // Computed properties for better reactivity
78
+ const hasData = computed(() => data.value && data.value.length > 0)
79
+ const hasExportableColumns = computed(() => props.exportableColumns.length > 0)
80
+ const hasPaging = computed(() => paging.value && paging.value.page_max > 1 && paging.value.page_no)
81
+
71
82
  async function getData(page: number = 1) {
83
+ isLoading.value = true
72
84
  eventBus.emit('main-loading', true)
73
85
  if (route.query.page) page = Number.parseInt(route.query.page.toString())
74
86
  const sort: any = {}
@@ -78,20 +90,30 @@ async function getData(page: number = 1) {
78
90
  sort,
79
91
  results_per_page: perPage.value,
80
92
  page_no: page,
93
+ search: searchTerm.value || undefined, // Only include if not empty
94
+ }
95
+ try {
96
+ const r = await restFunction(props.apiPath, 'GET', requestParams, {
97
+ getBody: true,
98
+ })
99
+ currentPage.value = page
100
+ data.value = []
101
+ paging.value = undefined
102
+ if (r && r.result === 'success') {
103
+ data.value = r.data
104
+ paging.value = r.paging
105
+ eventBus.emit(`${props.id}NewData`, data.value)
106
+ }
81
107
  }
82
- const r = await restFunction(props.apiPath, 'GET', requestParams, {
83
- getBody: true,
84
- })
85
- currentPage.value = page
86
- data.value = []
87
- paging.value = undefined
88
- if (r && r.result === 'success') {
89
- data.value = r.data
90
- paging.value = r.paging
91
- eventBus.emit(`${props.id}NewData`, data.value)
108
+ catch (error) {
109
+ console.error('Error fetching data:', error)
110
+ }
111
+ finally {
112
+ isLoading.value = false
113
+ eventBus.emit('main-loading', false)
92
114
  }
93
- eventBus.emit('main-loading', false)
94
115
  }
116
+
95
117
  function sortData(key: string) {
96
118
  if (!props.sortables[key]) return
97
119
  const newSort: SortingField = {
@@ -112,7 +134,10 @@ function sortData(key: string) {
112
134
  }
113
135
  currentSort.value = { ...newSort }
114
136
  }
137
+
115
138
  function exportToCsv() {
139
+ if (!hasData.value || !hasExportableColumns.value) return
140
+
116
141
  const header = props.exportableColumns
117
142
  .map(column => props.headers[column] ?? column)
118
143
  .join(',')
@@ -149,6 +174,27 @@ function exportToCsv() {
149
174
  link.click()
150
175
  document.body.removeChild(link)
151
176
  }
177
+
178
+ function handleSearch() {
179
+ getData(1) // Reset to page 1 when searching
180
+ }
181
+
182
+ function clearSearch() {
183
+ searchTerm.value = ''
184
+ getData(1)
185
+ }
186
+
187
+ // Debounced search
188
+ let searchTimeout: number | null = null
189
+ function debouncedSearch() {
190
+ if (searchTimeout) {
191
+ clearTimeout(searchTimeout)
192
+ }
193
+ searchTimeout = window.setTimeout(() => {
194
+ handleSearch()
195
+ }, 400)
196
+ }
197
+
152
198
  watch(perPage, () => {
153
199
  getData()
154
200
  })
@@ -181,47 +227,94 @@ onUnmounted(() => {
181
227
  </script>
182
228
 
183
229
  <template>
184
- <div>
185
- <div
186
- class="flex gap-2 justify-between items-center border-b border-fv-primary-600 mb-2 pb-2"
187
- >
188
- <DefaultPaging v-if="paging" :id="`${props.id}Pages`" :items="paging" />
189
- <button
190
- v-if="exportableColumns.length && data.length"
191
- class="btn primary small"
192
- @click="exportToCsv"
193
- >
194
- <ArrowDownTrayIcon class="w-4 h-4 mr-2" />{{ $t("global_table_export") }}
195
- </button>
196
- <DefaultInput
197
- :id="`${id}PerPage`"
198
- v-model="perPage"
199
- :options="perPageOptions"
200
- :show-label="false"
201
- type="select"
202
- class="w-20"
203
- />
230
+ <div class="data-table-container bg-white dark:bg-fv-neutral-900 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden">
231
+ <!-- Table header with controls -->
232
+ <div class="table-controls p-4 border-b border-fv-neutral-200 dark:border-fv-neutral-800">
233
+ <div class="flex flex-wrap items-center justify-between gap-3">
234
+ <!-- Search input -->
235
+ <div class="search-container relative flex-grow max-w-md">
236
+ <div class="relative">
237
+ <input
238
+ v-model="searchTerm"
239
+ type="search"
240
+ class="w-full pl-10 pr-4 py-2 text-sm rounded-lg border border-fv-neutral-300 dark:border-fv-neutral-700 bg-white dark:bg-fv-neutral-800 text-fv-neutral-900 dark:text-white focus:ring-2 focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600 focus:border-fv-primary-500 dark:focus:border-fv-primary-600 transition-colors duration-200"
241
+ :placeholder="$t('search_placeholder')"
242
+ @input="debouncedSearch"
243
+ @keyup.enter="handleSearch"
244
+ >
245
+ <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
246
+ <MagnifyingGlassIcon class="w-4 h-4 text-fv-neutral-500 dark:text-fv-neutral-400" aria-hidden="true" />
247
+ </div>
248
+ <button
249
+ v-if="searchTerm"
250
+ type="button"
251
+ class="absolute inset-y-0 right-0 flex items-center pr-3 text-fv-neutral-500 hover:text-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:text-fv-neutral-200"
252
+ aria-label="Clear search"
253
+ @click="clearSearch"
254
+ >
255
+ <XCircleIcon class="w-5 h-5" aria-hidden="true" />
256
+ </button>
257
+ </div>
258
+ </div>
259
+
260
+ <!-- Export and per page settings -->
261
+ <div class="flex items-center gap-2">
262
+ <button
263
+ v-if="hasExportableColumns && hasData"
264
+ class="export-btn flex items-center justify-center gap-2 px-3 py-2 bg-fv-neutral-100 hover:bg-fv-neutral-200 text-fv-neutral-800 rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-neutral-400 dark:bg-fv-neutral-800 dark:hover:bg-fv-neutral-700 dark:text-fv-neutral-200"
265
+ @click="exportToCsv"
266
+ >
267
+ <ArrowDownTrayIcon class="w-4 h-4" aria-hidden="true" />
268
+ <span class="hidden sm:inline">{{ $t("global_table_export") }}</span>
269
+ </button>
270
+
271
+ <div class="flex items-center gap-2">
272
+ <label for="perPageSelect" class="text-sm font-medium text-fv-neutral-600 dark:text-fv-neutral-400 whitespace-nowrap hidden sm:block">
273
+ {{ $t("per_page") }}:
274
+ </label>
275
+ <DefaultInput
276
+ id="perPageSelect"
277
+ v-model="perPage"
278
+ :options="perPageOptions"
279
+ :show-label="false"
280
+ type="select"
281
+ class="w-20"
282
+ />
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ <!-- Pagination - top -->
288
+ <div v-if="hasPaging" class="mt-3">
289
+ <DefaultPaging :id="`${props.id}Pages`" :items="paging" />
290
+ </div>
291
+ </div>
292
+
293
+ <!-- Loading state -->
294
+ <div v-if="isLoading" class="flex justify-center items-center py-12">
295
+ <div class="loading-spinner w-8 h-8 border-4 border-fv-neutral-200 dark:border-fv-neutral-700 border-t-fv-primary-600 dark:border-t-fv-primary-500 rounded-full animate-spin" />
204
296
  </div>
205
297
 
298
+ <!-- Table view -->
206
299
  <div
207
- v-if="data.length"
208
- class="relative overflow-x-auto border-fv-primary-600 sm:rounded-lg"
300
+ v-else-if="hasData"
301
+ class="overflow-x-auto"
209
302
  >
210
303
  <table
211
- class="w-full text-sm text-left text-fv-neutral-500 dark:text-fv-neutral-400"
304
+ class="w-full text-sm text-left"
212
305
  >
213
306
  <thead
214
307
  v-if="showHeaders"
215
- class="text-xs text-fv-neutral-700 uppercase bg-fv-neutral-50 dark:bg-fv-neutral-800 dark:text-fv-neutral-400"
308
+ class="text-xs uppercase bg-fv-neutral-50 dark:bg-fv-neutral-800 text-fv-neutral-700 dark:text-fv-neutral-300"
216
309
  >
217
310
  <tr>
218
311
  <th
219
312
  v-for="(header, key) in headers"
220
313
  :key="key"
221
314
  scope="col"
222
- class="px-6 py-3 whitespace-nowrap"
315
+ class="px-6 py-4 whitespace-nowrap font-semibold"
223
316
  :class="{
224
- 'cursor-pointer': sortables[key],
317
+ 'cursor-pointer hover:bg-fv-neutral-100 dark:hover:bg-fv-neutral-700 transition-colors duration-200': sortables[key],
225
318
  }"
226
319
  @click="
227
320
  () => {
@@ -231,14 +324,22 @@ onUnmounted(() => {
231
324
  }
232
325
  "
233
326
  >
234
- {{ header }}
235
- <template v-if="sortables[key] && currentSort.field === key">
236
- <ArrowUpIcon
237
- v-if="currentSort.direction === 'desc'"
238
- class="inline w-3 h-3 align-top mt-0.5"
239
- />
240
- <ArrowDownIcon v-else class="inline w-3 h-3 align-top mt-0.5" />
241
- </template>
327
+ <div class="flex items-center gap-1">
328
+ {{ header }}
329
+ <span v-if="sortables[key]" class="inline-flex">
330
+ <ArrowsUpDownIcon v-if="currentSort.field !== key" class="w-4 h-4 text-fv-neutral-400 dark:text-fv-neutral-500" aria-hidden="true" />
331
+ <ArrowUpIcon
332
+ v-else-if="currentSort.direction === 'asc'"
333
+ class="w-4 h-4 text-fv-primary-600 dark:text-fv-primary-400"
334
+ aria-hidden="true"
335
+ />
336
+ <ArrowDownIcon
337
+ v-else
338
+ class="w-4 h-4 text-fv-primary-600 dark:text-fv-primary-400"
339
+ aria-hidden="true"
340
+ />
341
+ </span>
342
+ </div>
242
343
  </th>
243
344
  </tr>
244
345
  </thead>
@@ -246,15 +347,19 @@ onUnmounted(() => {
246
347
  <tr
247
348
  v-for="(row, index) in data"
248
349
  :key="index"
249
- class="bg-white border-b dark:bg-fv-neutral-900 dark:border-fv-neutral-800 hover:bg-fv-neutral-50 dark:hover:bg-fv-neutral-950"
350
+ class="bg-white border-b dark:bg-fv-neutral-900 dark:border-fv-neutral-800 hover:bg-fv-neutral-50 dark:hover:bg-fv-neutral-900 transition-colors duration-200"
250
351
  >
251
- <td v-for="(header, key) in headers" :key="key" class="px-6 py-4">
352
+ <td
353
+ v-for="(header, key) in headers"
354
+ :key="key"
355
+ class="px-6 py-4 align-middle"
356
+ >
252
357
  <slot :name="key" :value="row">
253
358
  <template v-if="row[key]">
254
359
  {{ row[key] }}
255
360
  </template>
256
361
  <template v-else>
257
- {{ $t("global_table_empty_cell") }}
362
+ <span class="text-fv-neutral-400 dark:text-fv-neutral-600">{{ $t("global_table_empty_cell") }}</span>
258
363
  </template>
259
364
  </slot>
260
365
  </td>
@@ -262,5 +367,112 @@ onUnmounted(() => {
262
367
  </tbody>
263
368
  </table>
264
369
  </div>
370
+
371
+ <!-- Empty state -->
372
+ <div v-else class="py-12 px-4 text-center">
373
+ <div class="empty-state flex flex-col items-center justify-center">
374
+ <svg class="w-16 h-16 text-fv-neutral-300 dark:text-fv-neutral-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
375
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
376
+ </svg>
377
+ <p class="text-lg font-medium text-fv-neutral-700 dark:text-fv-neutral-300">
378
+ {{ $t("no_data_found") }}
379
+ </p>
380
+ <p class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400 max-w-md mt-1">
381
+ {{ $t("try_another_search") }}
382
+ </p>
383
+ </div>
384
+ </div>
385
+
386
+ <!-- Pagination - bottom -->
387
+ <div v-if="hasPaging" class="px-4 py-3 border-t border-fv-neutral-200 dark:border-fv-neutral-800">
388
+ <DefaultPaging :id="`${props.id}Pages`" :items="paging" />
389
+ </div>
265
390
  </div>
266
391
  </template>
392
+
393
+ <style scoped>
394
+ .data-table-container {
395
+ @apply transition-all duration-300;
396
+ }
397
+
398
+ /* Responsive styles */
399
+ @media (max-width: 640px) {
400
+ .table-controls {
401
+ @apply p-3;
402
+ }
403
+
404
+ td, th {
405
+ @apply px-3 py-3;
406
+ }
407
+ }
408
+
409
+ /* Loading spinner animation */
410
+ @keyframes spin {
411
+ to {
412
+ transform: rotate(360deg);
413
+ }
414
+ }
415
+
416
+ .loading-spinner {
417
+ animation: spin 1s linear infinite;
418
+ }
419
+
420
+ /* Fade in animation for rows */
421
+ tbody tr {
422
+ animation: fadeIn 0.2s ease-out forwards;
423
+ }
424
+
425
+ @keyframes fadeIn {
426
+ from { opacity: 0.5; }
427
+ to { opacity: 1; }
428
+ }
429
+
430
+ /* Improved hover states for better interactivity */
431
+ th, td {
432
+ @apply transition-colors duration-200;
433
+ }
434
+
435
+ /* Zebra striping for better readability */
436
+ @media (prefers-reduced-motion: no-preference) {
437
+ tbody tr:nth-child(odd) {
438
+ @apply bg-fv-neutral-50 dark:bg-fv-neutral-900;
439
+ }
440
+
441
+ tbody tr:nth-child(odd):hover {
442
+ @apply bg-fv-neutral-100 dark:bg-fv-neutral-800;
443
+ }
444
+ }
445
+
446
+ /* Accessible focus styles */
447
+ button:focus-visible,
448
+ a:focus-visible {
449
+ @apply outline-none ring-2 ring-fv-primary-500 ring-offset-2 dark:ring-offset-fv-neutral-900;
450
+ }
451
+
452
+ /* Export button hover effect */
453
+ .export-btn {
454
+ @apply relative overflow-hidden;
455
+ }
456
+
457
+ .export-btn::after {
458
+ content: '';
459
+ @apply absolute inset-0 opacity-0 transition-opacity duration-200;
460
+ }
461
+
462
+ .export-btn:hover::after {
463
+ @apply opacity-10 bg-black dark:bg-white;
464
+ }
465
+
466
+ /* Additional dark mode color for better contrast */
467
+ .dark .data-table-container {
468
+ @apply bg-fv-neutral-900;
469
+ }
470
+
471
+ .dark tbody tr:nth-child(odd) {
472
+ @apply bg-fv-neutral-900;
473
+ }
474
+
475
+ .dark tbody tr:hover {
476
+ @apply bg-fv-neutral-800;
477
+ }
478
+ </style>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { FilterDataItems } from '../../types'
3
+ import { AdjustmentsHorizontalIcon, ArrowPathIcon, FunnelIcon, XMarkIcon } from '@heroicons/vue/24/solid'
3
4
  import useVuelidate from '@vuelidate/core'
4
5
  import { onMounted, onUnmounted, reactive, ref } from 'vue'
5
6
  import { useEventBus } from '../../composables/event-bus'
@@ -125,115 +126,187 @@ onUnmounted(() => {
125
126
  </script>
126
127
 
127
128
  <template>
128
- <form v-if="!hidden" @submit.prevent="() => submitForm()">
129
- <div :class="css">
130
- <div v-for="(g, i) in data" :key="`index_${i}`" class="relative">
131
- <template v-for="f in g" :key="f.uid">
132
- <template v-if="!f.isHidden">
133
- <DefaultInput
134
- v-if="
135
- ['text', 'select', 'date', 'email', 'autocomplete'].includes(
136
- f.type,
137
- )
138
- "
139
- :id="f.uid"
140
- v-model="state.formData[f.uid]"
141
- :type="f.type === 'autocomplete' ? 'text' : f.type"
142
- :label="f.label"
143
- :options="f.options ? f.options : [[]]"
144
- :error-vuelidate="v$.formData[f.uid].$errors"
145
- class="mb-2"
146
- @focus="
147
- () => {
148
- f.focused = true;
149
- $eventBus.emit('focusInput', true);
150
- }
151
- "
152
- @blur="
153
- () => {
154
- f.focused = false;
155
- $eventBus.emit('focusInput', false);
156
- }
157
- "
158
- @change="
159
- (ev: any) => {
160
- if (f.onChangeValue) {
161
- f.onChangeValue(state.formData, ev);
129
+ <div class="filter-data-wrapper mb-6">
130
+ <form v-if="!hidden" class="filter-data-form bg-white dark:bg-fv-neutral-900 rounded-lg border border-fv-neutral-200 dark:border-fv-neutral-800 shadow-sm hover:shadow-md transition-all duration-300 p-4" @submit.prevent="() => submitForm()">
131
+ <div class="flex items-center justify-between mb-4 border-b border-fv-neutral-200 dark:border-fv-neutral-800 pb-3">
132
+ <h3 class="text-lg font-medium text-fv-neutral-900 dark:text-white flex items-center">
133
+ <FunnelIcon class="w-5 h-5 mr-2 text-fv-primary-600 dark:text-fv-primary-400" aria-hidden="true" />
134
+ {{ $t("filters_title") }}
135
+ </h3>
136
+ </div>
137
+
138
+ <div :class="`${css} grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4`">
139
+ <div v-for="(g, i) in data" :key="`index_${i}`" class="relative">
140
+ <template v-for="f in g" :key="f.uid">
141
+ <template v-if="!f.isHidden">
142
+ <DefaultInput
143
+ v-if="
144
+ ['text', 'select', 'date', 'email', 'autocomplete'].includes(
145
+ f.type,
146
+ )
147
+ "
148
+ :id="f.uid"
149
+ v-model="state.formData[f.uid]"
150
+ :type="f.type === 'autocomplete' ? 'text' : f.type"
151
+ :label="f.label"
152
+ :options="f.options ? f.options : [[]]"
153
+ :error-vuelidate="v$.formData[f.uid].$errors"
154
+ @focus="
155
+ () => {
156
+ f.focused = true;
157
+ $eventBus.emit('focusInput', true);
162
158
  }
163
- }
164
- "
165
- @update:model-value="
166
- (v:any) => {
167
- if (f.autocomplete && v.length >= 2) {
168
- fDynamicOptions = [];
169
- f.autocomplete(v).then((r:any) => {
170
- fDynamicOptions = r;
171
- });
159
+ "
160
+ @blur="
161
+ () => {
162
+ f.focused = false;
163
+ $eventBus.emit('focusInput', false);
172
164
  }
173
- }
174
- "
175
- >
176
- <div
177
- v-if="f.type === 'autocomplete' && f.focused"
178
- class="absolute flex flex-col gap-2 p-2 bottom-0 translate-y-full inset-x-0 bg-fv-neutral-200 dark:bg-fv-neutral-800 border border-fv-neutral-700 z-10"
179
- >
180
- <button
181
- v-for="o in fDynamicOptions"
182
- :key="o[0]"
183
- class="flex items-center justify-between btn defaults neutral"
184
- type="button"
185
- @click.prevent="
186
- () => {
187
- f.focused = false;
188
- state.formData[f.uid] = o[0];
165
+ "
166
+ @change="
167
+ (ev: any) => {
168
+ if (f.onChangeValue) {
169
+ f.onChangeValue(state.formData, ev);
170
+ }
171
+ }
172
+ "
173
+ @update:model-value="
174
+ (v:any) => {
175
+ if (f.autocomplete && v.length >= 2) {
176
+ fDynamicOptions = [];
177
+ f.autocomplete(v).then((r:any) => {
178
+ fDynamicOptions = r;
179
+ });
189
180
  }
190
- "
181
+ }
182
+ "
183
+ >
184
+ <div
185
+ v-if="f.type === 'autocomplete' && f.focused && fDynamicOptions.length > 0"
186
+ class="absolute flex flex-col gap-2 p-2 bottom-0 translate-y-full inset-x-0 bg-white dark:bg-fv-neutral-800 border border-fv-neutral-200 dark:border-fv-neutral-700 z-10 rounded-lg shadow-lg max-h-60 overflow-y-auto"
191
187
  >
192
- {{ o[1] }} <small v-if="o[0] !== ''">({{ o[0] }})</small>
193
- </button>
194
- </div>
195
- </DefaultInput>
196
-
197
- <DefaultDateSelection
198
- v-if="f.type === 'range'"
199
- :id="f.uid"
200
- v-model="state.formData[f.uid]"
201
- :label="f.label"
202
- mode="interval"
203
- class="mb-2"
204
- />
188
+ <button
189
+ v-for="o in fDynamicOptions"
190
+ :key="o[0]"
191
+ class="flex items-center justify-between p-2 text-fv-neutral-800 dark:text-fv-neutral-200 hover:bg-fv-neutral-100 dark:hover:bg-fv-neutral-700 rounded transition-colors duration-200"
192
+ type="button"
193
+ @click.prevent="
194
+ () => {
195
+ f.focused = false;
196
+ state.formData[f.uid] = o[0];
197
+ }
198
+ "
199
+ >
200
+ <span class="font-medium">{{ o[1] }}</span>
201
+ <small v-if="o[0] !== ''" class="text-fv-neutral-500 dark:text-fv-neutral-400 ml-2">({{ o[0] }})</small>
202
+ </button>
203
+ </div>
204
+ </DefaultInput>
205
+
206
+ <DefaultDateSelection
207
+ v-if="f.type === 'range'"
208
+ :id="f.uid"
209
+ v-model="state.formData[f.uid]"
210
+ :label="f.label"
211
+ mode="interval"
212
+ />
213
+ </template>
205
214
  </template>
206
- </template>
215
+ </div>
207
216
  </div>
208
- </div>
209
217
 
210
- <div class="flex justify-between mt-2 gap-x-2">
211
- <button type="submit" class="btn defaults primary">
212
- {{ $t("filters_search_cta") }}
213
- </button>
214
- <button type="button" class="btn defaults primary" @click="hidden = true">
215
- {{ $t("hide_filters_cta") }}
216
- </button>
218
+ <div class="flex flex-wrap justify-between items-center mt-6 gap-2">
219
+ <div class="flex flex-wrap gap-2">
220
+ <button
221
+ type="submit"
222
+ class="btn-filter flex items-center justify-center gap-2 px-4 py-2 bg-fv-primary-600 hover:bg-fv-primary-700 text-white font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-primary-500 focus:ring-offset-2 dark:focus:ring-offset-fv-neutral-900"
223
+ >
224
+ <AdjustmentsHorizontalIcon class="w-4 h-4" aria-hidden="true" />
225
+ {{ $t("filters_search_cta") }}
226
+ </button>
227
+
228
+ <button
229
+ type="reset"
230
+ class="btn-reset flex items-center justify-center gap-2 px-4 py-2 bg-fv-neutral-100 hover:bg-fv-neutral-200 text-fv-neutral-800 font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-neutral-400 focus:ring-offset-2 dark:bg-fv-neutral-800 dark:hover:bg-fv-neutral-700 dark:text-fv-neutral-200 dark:focus:ring-offset-fv-neutral-900"
231
+ @click.prevent="() => { resetForm(); }"
232
+ >
233
+ <ArrowPathIcon class="w-4 h-4" aria-hidden="true" />
234
+ {{ $t("filters_clear_cta") }}
235
+ </button>
236
+ </div>
237
+
238
+ <button
239
+ type="button"
240
+ class="btn-hide flex items-center justify-center gap-2 px-4 py-2 border border-fv-neutral-300 dark:border-fv-neutral-700 bg-white hover:bg-fv-neutral-50 text-fv-neutral-700 font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-neutral-400 focus:ring-offset-2 dark:bg-fv-neutral-900 dark:hover:bg-fv-neutral-800 dark:text-fv-neutral-300 dark:focus:ring-offset-fv-neutral-900"
241
+ @click="hidden = true"
242
+ >
243
+ <XMarkIcon class="w-4 h-4" aria-hidden="true" />
244
+ {{ $t("hide_filters_cta") }}
245
+ </button>
246
+ </div>
247
+ </form>
248
+
249
+ <div v-else class="text-center">
217
250
  <button
218
- type="reset"
219
- class="btn defaults neutral"
220
- @click.prevent="
221
- () => {
222
- resetForm();
223
- }
224
- "
251
+ type="button"
252
+ class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-fv-primary-100 hover:bg-fv-primary-200 text-fv-primary-800 font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-primary-500 focus:ring-offset-2 dark:bg-fv-primary-900 dark:hover:bg-fv-primary-800 dark:text-fv-primary-200 dark:focus:ring-offset-fv-neutral-900"
253
+ @click="hidden = false"
225
254
  >
226
- {{ $t("filters_clear_cta") }}
255
+ <FunnelIcon class="w-4 h-4" aria-hidden="true" />
256
+ {{ $t("show_filters_cta") }}
227
257
  </button>
228
258
  </div>
229
- </form>
230
- <div v-else class="flex justify-between mt-2 gap-x-2">
231
- <button
232
- type="button"
233
- class="btn defaults primary !w-full flex-1 !text-center !items-center"
234
- @click="hidden = false"
235
- >
236
- {{ $t("show_filters_cta") }}
237
- </button>
238
259
  </div>
239
260
  </template>
261
+
262
+ <style scoped>
263
+ .filter-data-wrapper {
264
+ @apply transition-all duration-300;
265
+ }
266
+
267
+ .filter-data-form {
268
+ @apply animate-fadeIn;
269
+ }
270
+
271
+ @keyframes fadeIn {
272
+ from { opacity: 0; transform: translateY(-10px); }
273
+ to { opacity: 1; transform: translateY(0); }
274
+ }
275
+
276
+ .animate-fadeIn {
277
+ animation: fadeIn 0.3s ease-out forwards;
278
+ }
279
+
280
+ /* Responsive styles */
281
+ @media (max-width: 640px) {
282
+ .filter-data-form {
283
+ @apply p-3;
284
+ }
285
+ }
286
+
287
+ /* Button hover effects */
288
+ .btn-filter, .btn-reset, .btn-hide {
289
+ @apply relative overflow-hidden;
290
+ }
291
+
292
+ .btn-filter::after,
293
+ .btn-reset::after,
294
+ .btn-hide::after {
295
+ content: '';
296
+ @apply absolute inset-0 opacity-0 transition-opacity duration-200;
297
+ }
298
+
299
+ .btn-filter:hover::after {
300
+ @apply opacity-10 bg-white;
301
+ }
302
+
303
+ .btn-reset:hover::after,
304
+ .btn-hide:hover::after {
305
+ @apply opacity-10 bg-black;
306
+ }
307
+
308
+ /* Improved focus styles for accessibility */
309
+ button:focus-visible {
310
+ @apply outline-none ring-2 ring-fv-primary-500 ring-offset-2 dark:ring-offset-fv-neutral-900;
311
+ }
312
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.65",
3
+ "version": "2.2.67",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",