@fy-/fws-vue 2.2.62 → 2.2.64

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.
@@ -219,161 +219,225 @@ onMounted(async () => {
219
219
  <form
220
220
  v-if="!completed"
221
221
  class="fws-login w-full"
222
+ aria-labelledby="login-title"
222
223
  @submit.prevent="userFlow()"
223
224
  >
224
- <!-- <FyLoader id="klblogin" /> -->
225
- <div class="w-full">
225
+ <!-- Message / Header -->
226
+ <div
227
+ v-if="responseMessage"
228
+ class="fws-login__header mb-4"
229
+ >
226
230
  <h2
227
- v-if="responseMessage"
228
- class="text-lg text-fv-neutral-700 dark:text-fv-neutral-300 px-2"
231
+ id="login-title"
232
+ class="text-lg font-medium text-fv-neutral-700 dark:text-fv-neutral-300"
229
233
  >
230
234
  {{ responseMessage }}
231
235
  </h2>
232
- <template v-if="hasOauth && !showEmail">
233
- <div class="flex flex-col gap-2 px-2 justify-center py-2">
234
- <template v-for="field of responseFields" :key="field.id">
235
- <a
236
- v-if="field.type && field.type === 'oauth2' && field.button"
237
- href="javascript:void(0);"
238
- class="flex border border-fv-neutral-300 dark:border-fv-neutral-700 shadow items-center gap-2 justify-start btn neutral defaults w-full mx-auto !font-semibold"
239
- :style="`background: ${
240
- field.button['background-color']
241
- }; color: ${$getContrastingTextColor(
242
- field.button['background-color'],
243
- )}`"
244
- @click="
245
- () => {
246
- if (field.info.Button_Extra?.trigger) {
247
- doTrigger(field);
248
- }
249
- else {
250
- userFlow({ initial: true, oauth: field.id });
251
- }
252
- }
253
- "
254
- >
255
- <img
256
- :key="`${field.label}oauth`"
257
- class="h-12 w-12 block p-2 mr-3"
258
- :alt="field.info.Name"
259
- :src="field.button.logo"
260
- >
261
- <div>
262
- {{
263
- $t("user_flow_signin_with", {
264
- provider: field.name,
265
- })
266
- }}
267
- </div>
268
- </a>
269
- </template>
270
- <button
271
- type="button"
272
- class="flex items-center gap-2 justify-start btn neutral defaults w-full mx-auto !font-semibold"
273
- @click="
274
- () => {
275
- showEmail = true;
236
+ </div>
237
+
238
+ <!-- OAuth providers section -->
239
+ <div v-if="hasOauth && !showEmail" class="fws-login__oauth space-y-3">
240
+ <div
241
+ v-for="field of responseFields"
242
+ v-show="field.type && field.type === 'oauth2' && field.button"
243
+ :key="field.id"
244
+ >
245
+ <button
246
+ type="button"
247
+ class="flex w-full items-center justify-start gap-3 px-4 py-2.5 rounded-lg border border-fv-neutral-200 dark:border-fv-neutral-700
248
+ transition-all duration-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-fv-neutral-800
249
+ focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600"
250
+ :style="`background: ${
251
+ field.button['background-color']
252
+ }; color: ${$getContrastingTextColor(
253
+ field.button['background-color'],
254
+ )}`"
255
+ :aria-label="`${$t('user_flow_signin_with', { provider: field.name })}`"
256
+ @click="
257
+ () => {
258
+ if (field.info.Button_Extra?.trigger) {
259
+ doTrigger(field);
276
260
  }
277
- "
261
+ else {
262
+ userFlow({ initial: true, oauth: field.id });
263
+ }
264
+ }
265
+ "
266
+ >
267
+ <img
268
+ :key="`${field.label}oauth`"
269
+ class="h-6 w-6 flex-shrink-0"
270
+ :alt="field.info.Name"
271
+ :src="field.button.logo"
278
272
  >
279
- <EnvelopeIcon class="h-12 w-12 block p-2 mr-3" />
280
- <div>
281
- {{
282
- $t("user_flow_signin_with", {
283
- provider: $t("user_flow_provider_email_cta"),
284
- })
285
- }}
286
- </div>
287
- </button>
273
+ <span class="text-base font-medium">
274
+ {{ $t("user_flow_signin_with", { provider: field.name }) }}
275
+ </span>
276
+ </button>
277
+ </div>
278
+
279
+ <div class="relative my-6">
280
+ <div class="absolute inset-0 flex items-center">
281
+ <div class="w-full border-t border-fv-neutral-200 dark:border-fv-neutral-700" />
288
282
  </div>
289
- </template>
290
- <div
291
- v-if="forceAction || (showEmail && initial) || !initial"
292
- class="px-2 py-2"
283
+ <div class="relative flex justify-center text-sm">
284
+ <span class="px-2 bg-white dark:bg-fv-neutral-800 text-fv-neutral-500 dark:text-fv-neutral-400">
285
+ {{ $t("user_flow_or") }}
286
+ </span>
287
+ </div>
288
+ </div>
289
+
290
+ <button
291
+ type="button"
292
+ class="flex w-full items-center justify-start gap-3 px-4 py-2.5 rounded-lg border border-fv-neutral-200 dark:border-fv-neutral-700
293
+ bg-white dark:bg-fv-neutral-700 text-fv-neutral-800 dark:text-white
294
+ hover:bg-fv-neutral-50 dark:hover:bg-fv-neutral-650 transition-all duration-200
295
+ hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600"
296
+ aria-label="Sign in with email"
297
+ @click="showEmail = true"
293
298
  >
294
- <template v-if="responseFields && responseFields.length > 0">
295
- <template v-for="field of responseFields" :key="field.label">
299
+ <EnvelopeIcon class="h-6 w-6 text-fv-primary-500 dark:text-fv-primary-400 flex-shrink-0" />
300
+ <span class="text-base font-medium">
301
+ {{ $t("user_flow_signin_with", { provider: $t("user_flow_provider_email_cta") }) }}
302
+ </span>
303
+ </button>
304
+ </div>
305
+
306
+ <!-- Form fields section -->
307
+ <div
308
+ v-if="forceAction || (showEmail && initial) || !initial"
309
+ class="fws-login__form space-y-4"
310
+ >
311
+ <template v-if="responseFields && responseFields.length > 0">
312
+ <!-- Labels and text elements -->
313
+ <template v-for="field of responseFields" :key="field.label">
314
+ <div
315
+ v-if="field.type === 'label'"
316
+ class="mb-2"
317
+ >
296
318
  <h3
297
- v-if="field.type === 'label'"
298
- class="pt-2 pb-1 text-sm text-fv-neutral-500 dark:text-fv-neutral-400"
299
- :class="
319
+ class="text-sm"
320
+ :class="[
300
321
  field.style === 'error'
301
- ? 'text-sm my-2 p-1 font-semibold text-red-800 dark:text-red-300'
302
- : ''
303
- "
322
+ ? 'p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 font-medium'
323
+ : 'text-fv-neutral-600 dark:text-fv-neutral-400',
324
+ ]"
304
325
  >
305
- <a v-if="field.link" :href="field.link" class="fws-link mb-3">{{
306
- field.label
307
- }}</a>
308
- <span v-else class="mb-2" v-html="field.label" />
326
+ <a
327
+ v-if="field.link"
328
+ :href="field.link"
329
+ class="text-fv-primary-600 dark:text-fv-primary-400 hover:underline focus:outline-none focus:ring-2 focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600 rounded"
330
+ >
331
+ {{ field.label }}
332
+ </a>
333
+ <span v-else v-html="field.label" />
309
334
  </h3>
335
+ </div>
310
336
 
311
- <template v-if="field.cat === 'input'">
312
- <template
313
- v-if="
314
- field.type === 'text'
315
- || field.type === 'password'
316
- || field.type === 'email'
317
- || field.type === 'mask'
318
- || field.type === 'tel'
319
- || field.type === 'number'
320
- || field.type === 'phone'
321
- "
322
- >
323
- <DefaultInput
324
- v-if="field.name"
325
- :id="field.name"
326
- ref="inputs"
327
- v-model="formData[field.name]"
328
- :label="field.label"
329
- :mask="field.mask"
330
- class="mt-3"
331
- :placeholder="
332
- field.name === 'name' ? 'John Doe' : field.label
333
- "
334
- :error="fieldsError[field.name]"
335
- :type="field.type"
336
- :req="responseReq.includes(field.name)"
337
- :autocomplete="autocompleteValue(field.name)"
338
- />
339
- </template>
340
- </template>
341
- <template v-if="field.type === 'checkbox'">
337
+ <!-- Input fields -->
338
+ <template v-if="field.cat === 'input'">
339
+ <template
340
+ v-if="
341
+ field.type === 'text'
342
+ || field.type === 'password'
343
+ || field.type === 'email'
344
+ || field.type === 'mask'
345
+ || field.type === 'tel'
346
+ || field.type === 'number'
347
+ || field.type === 'phone'
348
+ "
349
+ >
342
350
  <DefaultInput
343
351
  v-if="field.name"
344
352
  :id="field.name"
345
- v-model:checkbox-value="formData[field.name]"
346
- class="mt-3"
353
+ ref="inputs"
354
+ v-model="formData[field.name]"
347
355
  :label="field.label"
356
+ :mask="field.mask"
357
+ :placeholder="
358
+ field.name === 'name' ? 'John Doe' : field.label
359
+ "
348
360
  :error="fieldsError[field.name]"
349
361
  :type="field.type"
350
362
  :req="responseReq.includes(field.name)"
351
- :link-icon="field.link"
363
+ :autocomplete="autocompleteValue(field.name)"
352
364
  />
353
365
  </template>
354
366
  </template>
355
- <div
356
- v-if="responseError && responseError.token"
357
- class="text-sm my-2 p-1 font-semibold text-red-800 dark:text-red-300"
358
- v-html="$t(responseError.token)"
359
- />
360
- <div v-if="responseReq.includes('password') && 0" class="reset-pwd">
361
- <a
362
- href="javascript:void(0)"
363
- @click="
364
- () => {
365
- eventBus.emit('ResetPasswordModal', true);
366
- pwdRecoverMailSent = false;
367
- }
368
- "
369
- >{{ $t("recover_pwd_link") }}</a>
370
- </div>
371
- <button class="btn primary medium mt-4">
372
- {{ $t("cta_login_next") }}
373
- </button>
367
+
368
+ <!-- Checkbox inputs -->
369
+ <template v-if="field.type === 'checkbox'">
370
+ <DefaultInput
371
+ v-if="field.name"
372
+ :id="field.name"
373
+ v-model:checkbox-value="formData[field.name]"
374
+ :label="field.label"
375
+ :error="fieldsError[field.name]"
376
+ :type="field.type"
377
+ :req="responseReq.includes(field.name)"
378
+ :link-icon="field.link"
379
+ />
380
+ </template>
374
381
  </template>
375
- </div>
382
+
383
+ <!-- Error message -->
384
+ <div
385
+ v-if="responseError && responseError.token"
386
+ class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm font-medium"
387
+ role="alert"
388
+ v-html="$t(responseError.token)"
389
+ />
390
+
391
+ <!-- Password recovery link -->
392
+ <div
393
+ v-if="responseReq.includes('password') && 0"
394
+ class="text-right my-2"
395
+ >
396
+ <button
397
+ type="button"
398
+ class="text-fv-primary-600 dark:text-fv-primary-400 text-sm hover:underline focus:outline-none focus:ring-2 focus:ring-fv-primary-500 rounded"
399
+ @click="
400
+ () => {
401
+ eventBus.emit('ResetPasswordModal', true);
402
+ pwdRecoverMailSent = false;
403
+ }
404
+ "
405
+ >
406
+ {{ $t("recover_pwd_link") }}
407
+ </button>
408
+ </div>
409
+
410
+ <!-- Submit button -->
411
+ <button
412
+ type="submit"
413
+ class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm
414
+ text-white bg-fv-primary-600 hover:bg-fv-primary-700 dark:bg-fv-primary-700 dark:hover:bg-fv-primary-800
415
+ focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600
416
+ dark:focus:ring-offset-fv-neutral-800 font-medium transition-all duration-200"
417
+ aria-label="Continue"
418
+ >
419
+ {{ $t("cta_login_next") }}
420
+ </button>
421
+ </template>
376
422
  </div>
377
423
  </form>
378
424
  </ClientOnly>
379
425
  </template>
426
+
427
+ <style scoped>
428
+ .fws-login {
429
+ @apply transition-all duration-300;
430
+ }
431
+
432
+ .fws-login__oauth button,
433
+ .fws-login__form button[type="submit"] {
434
+ @apply transition-all duration-200;
435
+ }
436
+
437
+ @media (max-width: 640px) {
438
+ .fws-login__oauth,
439
+ .fws-login__form {
440
+ @apply px-0;
441
+ }
442
+ }
443
+ </style>
@@ -187,7 +187,7 @@ defineExpose({ focus, blur, getInputRef })
187
187
  :name="id"
188
188
  :class="{
189
189
  'error': checkErrors,
190
- 'bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-lg block w-full dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white transition-all duration-200': type !== 'range',
190
+ 'bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-lg block w-full dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-300 dark:text-white transition-all duration-200': type !== 'range',
191
191
  'p-2.5': type !== 'range',
192
192
  'focus:border-fv-primary-500 dark:focus:border-fv-primary-500': !checkErrors,
193
193
  'focus:ring-2 focus:ring-fv-primary-300 dark:focus:ring-fv-primary-800 focus:ring-opacity-50': type !== 'range',
@@ -288,7 +288,7 @@ defineExpose({ focus, blur, getInputRef })
288
288
  :aria-invalid="checkErrors ? 'true' : 'false'"
289
289
  class="block p-2.5 w-full text-sm text-fv-neutral-900 bg-fv-neutral-50 rounded-lg
290
290
  border border-fv-neutral-300 focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
291
- dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400
291
+ dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-300
292
292
  dark:text-white dark:focus:ring-fv-primary-800 dark:focus:border-fv-primary-500
293
293
  transition-colors duration-200 shadow-sm"
294
294
  @focus="handleFocus"
@@ -332,7 +332,7 @@ defineExpose({ focus, blur, getInputRef })
332
332
  :aria-invalid="checkErrors ? 'true' : 'false'"
333
333
  class="block p-2.5 w-full text-sm text-fv-neutral-900 bg-fv-neutral-50
334
334
  rounded-lg border border-fv-neutral-300 focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
335
- dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400
335
+ dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-300
336
336
  dark:text-white dark:focus:ring-fv-primary-800 dark:focus:border-fv-primary-500
337
337
  transition-colors duration-200 shadow-sm min-h-[100px]"
338
338
  @focus="handleFocus"
@@ -375,7 +375,7 @@ defineExpose({ focus, blur, getInputRef })
375
375
  class="appearance-none bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm
376
376
  rounded-lg focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
377
377
  block w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
378
- dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-800
378
+ dark:placeholder-fv-neutral-300 dark:text-white dark:focus:ring-fv-primary-800
379
379
  dark:focus:border-fv-primary-500 shadow-sm transition-colors duration-200 pr-10"
380
380
  @focus="handleFocus"
381
381
  @blur="handleBlur"
@@ -577,6 +577,7 @@ input, select, textarea, input[type="range"]::-webkit-slider-thumb, input[type="
577
577
  input::placeholder,
578
578
  textarea::placeholder,
579
579
  select::placeholder {
580
- @apply text-fv-neutral-400 dark:text-fv-neutral-500;
580
+ @apply text-fv-neutral-400 dark:text-fv-neutral-300;
581
+ opacity: 0.8;
581
582
  }
582
583
  </style>
@@ -3,10 +3,11 @@ import type { Component } from 'vue'
3
3
  import {
4
4
  CheckCircleIcon,
5
5
  ExclamationTriangleIcon,
6
- LightBulbIcon,
6
+ InformationCircleIcon,
7
7
  SparklesIcon,
8
+ XMarkIcon,
8
9
  } from '@heroicons/vue/24/solid'
9
- import { onMounted, onUnmounted, ref } from 'vue'
10
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
10
11
  import { useEventBus } from '../../composables/event-bus'
11
12
  import ScaleTransition from './transitions/ScaleTransition.vue'
12
13
 
@@ -31,9 +32,12 @@ const eventBus = useEventBus()
31
32
  /** Current displayed notification */
32
33
  const currentNotif = ref<NotifProps | null>(null)
33
34
 
34
- /** Progress percentage (0 to 100) for the notifications life */
35
+ /** Progress percentage (0 to 100) for the notification's life */
35
36
  const progress = ref(0)
36
37
 
38
+ /** Is the notification paused (e.g., when hovering) */
39
+ const isPaused = ref(false)
40
+
37
41
  /** References to setTimeout / setInterval so we can clear them properly */
38
42
  let hideTimeout: ReturnType<typeof setTimeout> | null = null
39
43
  let progressInterval: ReturnType<typeof setInterval> | null = null
@@ -55,7 +59,7 @@ function onCall(data: NotifProps) {
55
59
 
56
60
  // Automatically compute an icon if none is provided
57
61
  if (!data.imgIcon) {
58
- if (data.type === 'info') data.imgIcon = LightBulbIcon
62
+ if (data.type === 'info') data.imgIcon = InformationCircleIcon
59
63
  else if (data.type === 'warning') data.imgIcon = ExclamationTriangleIcon
60
64
  else if (data.type === 'success') data.imgIcon = CheckCircleIcon
61
65
  else if (data.type === 'secret') data.imgIcon = SparklesIcon
@@ -72,7 +76,7 @@ function onCall(data: NotifProps) {
72
76
  // (B) Animate the progress bar from 0 to 100% within that time
73
77
  progress.value = 0
74
78
  progressInterval = setInterval(() => {
75
- if (currentNotif.value && data.time) {
79
+ if (currentNotif.value && data.time && !isPaused.value) {
76
80
  // update progress based on a 100ms tick
77
81
  progress.value += (100 / (data.time / 100))
78
82
  // if progress hits or exceeds 100, hide
@@ -89,6 +93,7 @@ function onCall(data: NotifProps) {
89
93
  function hideNotif() {
90
94
  currentNotif.value = null
91
95
  progress.value = 0
96
+ isPaused.value = false
92
97
 
93
98
  if (hideTimeout) {
94
99
  clearTimeout(hideTimeout)
@@ -100,6 +105,124 @@ function hideNotif() {
100
105
  }
101
106
  }
102
107
 
108
+ /** Pause the notification timer on hover */
109
+ function pauseTimer() {
110
+ isPaused.value = true
111
+
112
+ if (hideTimeout) {
113
+ clearTimeout(hideTimeout)
114
+ hideTimeout = null
115
+ }
116
+ }
117
+
118
+ /** Resume the notification timer after hover */
119
+ function resumeTimer() {
120
+ if (!currentNotif.value) return
121
+
122
+ isPaused.value = false
123
+
124
+ // Calculate remaining time based on progress
125
+ const remainingTime = currentNotif.value.time
126
+ ? Math.max(currentNotif.value.time * (1 - progress.value / 100), 1000)
127
+ : 5000
128
+
129
+ // Reset the timeout with the remaining time
130
+ hideTimeout = setTimeout(() => hideNotif(), remainingTime)
131
+ }
132
+
133
+ /** Execute CTA action if provided */
134
+ function handleCtaClick() {
135
+ if (currentNotif.value?.ctaAction) {
136
+ currentNotif.value.ctaAction()
137
+ }
138
+ }
139
+
140
+ /** Get ARIA label based on notification type */
141
+ const ariaDescribedBy = computed(() => {
142
+ if (!currentNotif.value) return ''
143
+ return `notif-${currentNotif.value.type || 'info'}`
144
+ })
145
+
146
+ /** Get background color based on notification type */
147
+ const bgColor = computed(() => {
148
+ if (!currentNotif.value) return ''
149
+
150
+ switch (currentNotif.value.type) {
151
+ case 'success':
152
+ return 'bg-green-50 dark:bg-green-900/20'
153
+ case 'warning':
154
+ return 'bg-amber-50 dark:bg-amber-900/20'
155
+ case 'secret':
156
+ return 'bg-fuchsia-50 dark:bg-fuchsia-900/20'
157
+ default: // info
158
+ return 'bg-blue-50 dark:bg-blue-900/20'
159
+ }
160
+ })
161
+
162
+ /** Get border color based on notification type */
163
+ const borderColor = computed(() => {
164
+ if (!currentNotif.value) return ''
165
+
166
+ switch (currentNotif.value.type) {
167
+ case 'success':
168
+ return 'border-green-300 dark:border-green-700'
169
+ case 'warning':
170
+ return 'border-amber-300 dark:border-amber-700'
171
+ case 'secret':
172
+ return 'border-fuchsia-300 dark:border-fuchsia-700'
173
+ default: // info
174
+ return 'border-blue-300 dark:border-blue-700'
175
+ }
176
+ })
177
+
178
+ /** Get text color based on notification type */
179
+ const textColor = computed(() => {
180
+ if (!currentNotif.value) return ''
181
+
182
+ switch (currentNotif.value.type) {
183
+ case 'success':
184
+ return 'text-green-800 dark:text-green-200'
185
+ case 'warning':
186
+ return 'text-amber-800 dark:text-amber-200'
187
+ case 'secret':
188
+ return 'text-fuchsia-800 dark:text-fuchsia-200'
189
+ default: // info
190
+ return 'text-blue-800 dark:text-blue-200'
191
+ }
192
+ })
193
+
194
+ /** Get icon color based on notification type */
195
+ const iconColor = computed(() => {
196
+ if (!currentNotif.value) return ''
197
+
198
+ switch (currentNotif.value.type) {
199
+ case 'success':
200
+ return 'text-green-500 dark:text-green-400'
201
+ case 'warning':
202
+ return 'text-amber-500 dark:text-amber-400'
203
+ case 'secret':
204
+ return 'text-fuchsia-500 dark:text-fuchsia-400'
205
+ default: // info
206
+ return 'text-blue-500 dark:text-blue-400'
207
+ }
208
+ })
209
+
210
+ /** Get progress bar color based on notification type */
211
+ const progressColor = computed(() => {
212
+ if (!currentNotif.value) return ''
213
+
214
+ switch (currentNotif.value.type) {
215
+ case 'success':
216
+ return 'bg-green-500 dark:bg-green-400'
217
+ case 'warning':
218
+ return 'bg-amber-500 dark:bg-amber-400'
219
+ case 'secret':
220
+ return 'bg-fuchsia-500 dark:bg-fuchsia-400'
221
+ default: // info
222
+ return 'bg-blue-500 dark:bg-blue-400'
223
+ }
224
+ })
225
+
103
226
  /**
104
227
  * Setup: Listen to the global event bus
105
228
  */
@@ -120,62 +243,127 @@ onUnmounted(() => {
120
243
  <div
121
244
  v-if="currentNotif !== null"
122
245
  id="base-notif"
123
- class=" mb-4 fixed bottom-4 right-8 !z-[2000] bg-fv-neutral-50/[.6] dark:bg-neutral-800/[.85] rounded-lg border overflow-hidden shadow-lg"
246
+ class="fixed bottom-4 right-4 sm:right-8 z-[2000] max-w-sm w-full sm:w-96 rounded-lg border shadow-lg overflow-hidden backdrop-blur-sm transition-all duration-300 transform"
124
247
  role="alert"
125
- :class="{
126
- 'text-fv-neutral-800 border-fv-neutral-300 dark:text-fv-neutral-400 dark:border-fv-neutral-600':
127
- currentNotif.type === 'info',
128
- 'text-red-800 border-red-300 dark:text-red-300 dark:border-red-800':
129
- currentNotif.type === 'warning',
130
- 'text-green-800 border-green-300 dark:text-green-300 dark:border-green-800':
131
- currentNotif.type === 'success',
132
- 'text-fuchsia-800 border-fuchsia-300 dark:text-fuchsia-300 dark:border-fuchsia-800':
133
- currentNotif.type === 'secret',
134
- }"
248
+ :aria-describedby="ariaDescribedBy"
249
+ :class="[bgColor, borderColor, textColor]"
250
+ @mouseenter="pauseTimer"
251
+ @mouseleave="resumeTimer"
135
252
  >
136
- <div class="relative h-[4px] bg-fv-neutral-900/[.2] rounded-full overflow-hidden ">
137
- <!-- We re-use text color (text-*) as background or define a custom color -->
253
+ <!-- Progress bar -->
254
+ <div class="relative h-1 bg-gray-200 dark:bg-gray-700 overflow-hidden">
138
255
  <div
139
- class="absolute left-0 top-0 h-full bg-current transition-[width]"
256
+ class="absolute left-0 top-0 h-full transition-[width] ease-linear"
257
+ :class="progressColor"
140
258
  :style="{ width: `${progress}%` }"
141
259
  />
142
260
  </div>
143
- <div class="p-2">
144
- <div class="flex items-center gap-2">
145
- <img
146
- v-if="currentNotif.imgSrc"
147
- class="flex-shrink-0 w-6 h-6"
148
- :src="currentNotif.imgSrc"
149
- :alt="currentNotif.title"
261
+
262
+ <div class="p-4">
263
+ <!-- Header with icon and title -->
264
+ <div class="flex items-center justify-between">
265
+ <div class="flex items-center gap-3">
266
+ <div class="flex-shrink-0" :class="[iconColor]">
267
+ <img
268
+ v-if="currentNotif.imgSrc"
269
+ class="w-6 h-6 rounded-full"
270
+ :src="currentNotif.imgSrc"
271
+ :alt="currentNotif.title"
272
+ >
273
+ <component
274
+ :is="currentNotif.imgIcon"
275
+ v-else
276
+ class="w-6 h-6"
277
+ aria-hidden="true"
278
+ />
279
+ </div>
280
+ <h3
281
+ :id="ariaDescribedBy"
282
+ class="text-base font-semibold truncate"
283
+ v-text="currentNotif.title"
284
+ />
285
+ </div>
286
+
287
+ <!-- Close button -->
288
+ <button
289
+ type="button"
290
+ class="inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-50 dark:focus:ring-offset-gray-900"
291
+ :class="iconColor"
292
+ aria-label="Close notification"
293
+ @click="hideNotif"
150
294
  >
151
- <component
152
- :is="currentNotif.imgIcon"
153
- v-else
154
- class="flex-shrink-0 w-6 h-6"
155
- />
156
- <h3 class="text-lg font-medium" v-text="currentNotif.title" />
295
+ <XMarkIcon class="h-5 w-5" aria-hidden="true" />
296
+ </button>
157
297
  </div>
158
298
 
159
- <!-- Optional content -->
299
+ <!-- Notification content -->
160
300
  <div
161
301
  v-if="currentNotif.content"
162
- class="mt-2 text-sm prose-sm prose-invert"
302
+ class="mt-2 text-sm"
303
+ :class="textColor"
163
304
  v-html="currentNotif.content"
164
305
  />
165
306
 
166
- <!-- CTA row (if you need more buttons, just extend it) -->
167
- <div class="flex justify-end gap-2 pt-3">
307
+ <!-- CTA buttons -->
308
+ <div
309
+ v-if="currentNotif.ctaText || currentNotif.ctaLink || currentNotif.ctaAction"
310
+ class="mt-3 flex justify-end gap-2"
311
+ >
312
+ <a
313
+ v-if="currentNotif.ctaLink"
314
+ :href="currentNotif.ctaLink"
315
+ class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2"
316
+ :class="{
317
+ 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500': currentNotif.type === 'info' || !currentNotif.type,
318
+ 'bg-amber-600 hover:bg-amber-700 text-white focus:ring-amber-500': currentNotif.type === 'warning',
319
+ 'bg-green-600 hover:bg-green-700 text-white focus:ring-green-500': currentNotif.type === 'success',
320
+ 'bg-fuchsia-600 hover:bg-fuchsia-700 text-white focus:ring-fuchsia-500': currentNotif.type === 'secret',
321
+ }"
322
+ >
323
+ {{ currentNotif.ctaText || $t("action_cta") }}
324
+ </a>
168
325
  <button
326
+ v-else-if="currentNotif.ctaAction"
169
327
  type="button"
170
- class="btn neutral small"
171
- aria-label="Close"
172
- @click="hideNotif"
328
+ class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2"
329
+ :class="{
330
+ 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500': currentNotif.type === 'info' || !currentNotif.type,
331
+ 'bg-amber-600 hover:bg-amber-700 text-white focus:ring-amber-500': currentNotif.type === 'warning',
332
+ 'bg-green-600 hover:bg-green-700 text-white focus:ring-green-500': currentNotif.type === 'success',
333
+ 'bg-fuchsia-600 hover:bg-fuchsia-700 text-white focus:ring-fuchsia-500': currentNotif.type === 'secret',
334
+ }"
335
+ @click="handleCtaClick"
173
336
  >
174
- <!-- i18n example, or plain text like "Dismiss" -->
175
- {{ $t("dismiss_cta") }}
337
+ {{ currentNotif.ctaText || $t("action_cta") }}
176
338
  </button>
177
339
  </div>
178
340
  </div>
179
341
  </div>
180
342
  </ScaleTransition>
181
343
  </template>
344
+
345
+ <style scoped>
346
+ /* Optional: Add animation for notifications */
347
+ @keyframes slide-in-right {
348
+ 0% {
349
+ transform: translateX(100%);
350
+ opacity: 0;
351
+ }
352
+ 100% {
353
+ transform: translateX(0);
354
+ opacity: 1;
355
+ }
356
+ }
357
+
358
+ #base-notif {
359
+ animation: slide-in-right 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
360
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
361
+ }
362
+
363
+ /* Improve dark mode */
364
+ @media (prefers-color-scheme: dark) {
365
+ #base-notif {
366
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -1px rgba(0, 0, 0, 0.1);
367
+ }
368
+ }
369
+ </style>
@@ -153,100 +153,144 @@ onMounted(() => {
153
153
  <template>
154
154
  <div
155
155
  v-if="items && items.page_max > 1 && items.page_no"
156
- class="flex items-center justify-center"
156
+ class="flex flex-col items-center justify-center"
157
157
  >
158
- <div class="paging-container">
159
- <nav aria-label="Pagination">
160
- <ul class="flex items-center -space-x-px h-8 text-sm">
161
- <li v-if="items.page_no >= 2">
158
+ <div class="paging-container w-full">
159
+ <nav aria-label="Pagination" class="mb-2 flex justify-center">
160
+ <ul class="pagination-list">
161
+ <!-- Previous Button -->
162
+ <li v-if="items.page_no >= 2" class="pagination-item md:block">
162
163
  <button
163
164
  type="button"
164
- class="flex items-center justify-center px-1.5 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 hover:bg-fv-neutral-100 hover:text-fv-neutral-700 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:bg-fv-neutral-700 dark:hover:text-white"
165
+ class="pagination-button pagination-nav-button"
166
+ :aria-label="$t('previous_paging')"
167
+ title="Previous page"
165
168
  @click="prev()"
166
169
  >
170
+ <ChevronLeftIcon class="w-5 h-5" aria-hidden="true" />
167
171
  <span class="sr-only">{{ $t("previous_paging") }}</span>
168
- <ChevronLeftIcon class="w-4 h-4" />
169
172
  </button>
170
173
  </li>
171
- <li v-if="items.page_no - 2 > 1">
174
+ <li v-else class="pagination-item invisible md:hidden">
175
+ <div class="pagination-placeholder">
176
+ <ChevronLeftIcon class="w-5 h-5 invisible" aria-hidden="true" />
177
+ </div>
178
+ </li>
179
+
180
+ <!-- First Page -->
181
+ <li v-if="items.page_no - 2 > 1" class="pagination-item hidden md:block">
172
182
  <router-link
173
- class="flex items-center justify-center px-3 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 hover:bg-fv-neutral-100 hover:text-fv-neutral-700 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:bg-fv-neutral-700 dark:hover:text-white"
183
+ class="pagination-link"
174
184
  :to="page(1)"
185
+ aria-label="Go to page 1"
175
186
  >
176
187
  1
177
188
  </router-link>
178
189
  </li>
179
- <li v-if="items.page_no - 2 > 2">
180
- <div
181
- class="flex items-center justify-center px-1.5 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400"
182
- >
183
- ...
190
+
191
+ <!-- Ellipsis after first page -->
192
+ <li v-if="items.page_no - 2 > 2" class="pagination-item hidden md:block" aria-hidden="true">
193
+ <div class="pagination-ellipsis">
194
+ <span>•••</span>
184
195
  </div>
185
196
  </li>
197
+
198
+ <!-- Pages before current page -->
186
199
  <template v-for="i in 2">
187
200
  <li
188
201
  v-if="items.page_no - (3 - i) >= 1"
189
202
  :key="`page-${items.page_no - (3 - i)}`"
203
+ class="pagination-item hidden sm:block"
190
204
  >
191
205
  <router-link
192
- class="flex items-center justify-center px-3 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 hover:bg-fv-neutral-100 hover:text-fv-neutral-700 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:bg-fv-neutral-700 dark:hover:text-white"
206
+ class="pagination-link"
193
207
  :to="page(items.page_no - (3 - i))"
208
+ :aria-label="`Go to page ${items.page_no - (3 - i)}`"
194
209
  >
195
210
  {{ items.page_no - (3 - i) }}
196
211
  </router-link>
197
212
  </li>
198
213
  </template>
199
- <li>
214
+
215
+ <!-- Current Page -->
216
+ <li class="pagination-item">
200
217
  <div
201
218
  aria-current="page"
202
- class="z-10 flex items-center justify-center px-3 h-8 leading-tight text-primary-600 border border-primary-300 bg-primary-50 dark:border-fv-neutral-700 dark:bg-fv-neutral-700 dark:text-white"
219
+ class="pagination-current"
220
+ :aria-label="`Current page, Page ${items.page_no}`"
203
221
  >
204
222
  {{ items.page_no }}
205
223
  </div>
206
224
  </li>
225
+
226
+ <!-- Pages after current page -->
207
227
  <template v-for="i in 2">
208
228
  <li
209
229
  v-if="items.page_no + i <= items.page_max"
210
230
  :key="`page-x-${items.page_no + i}`"
231
+ class="pagination-item hidden sm:block"
211
232
  >
212
233
  <router-link
213
- class="flex items-center justify-center px-3 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 hover:bg-fv-neutral-100 hover:text-fv-neutral-700 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:bg-fv-neutral-700 dark:hover:text-white"
234
+ class="pagination-link"
214
235
  :to="page(items.page_no + i)"
236
+ :aria-label="`Go to page ${items.page_no + i}`"
215
237
  >
216
238
  {{ items.page_no + i }}
217
239
  </router-link>
218
240
  </li>
219
241
  </template>
220
- <li v-if="items.page_no + 2 < items.page_max - 1">
221
- <div
222
- class="flex items-center justify-center px-1.5 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400"
223
- >
224
- ...
242
+
243
+ <!-- Ellipsis before last page -->
244
+ <li v-if="items.page_no + 2 < items.page_max - 1" class="pagination-item hidden md:block" aria-hidden="true">
245
+ <div class="pagination-ellipsis">
246
+ <span>•••</span>
225
247
  </div>
226
248
  </li>
227
- <li v-if="items.page_no + 2 < items.page_max">
249
+
250
+ <!-- Last Page -->
251
+ <li v-if="items.page_no + 2 < items.page_max" class="pagination-item hidden md:block">
228
252
  <router-link
229
- class="flex items-center justify-center px-3 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 hover:bg-fv-neutral-100 hover:text-fv-neutral-700 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:bg-fv-neutral-700 dark:hover:text-white"
253
+ class="pagination-link"
230
254
  :to="page(items.page_max)"
255
+ :aria-label="`Go to page ${items.page_max}`"
231
256
  >
232
257
  {{ items.page_max }}
233
258
  </router-link>
234
259
  </li>
235
- <li v-if="items.page_no < items.page_max">
260
+
261
+ <!-- Next Button -->
262
+ <li v-if="items.page_no < items.page_max" class="pagination-item md:block">
236
263
  <button
237
264
  type="button"
238
- class="flex items-center justify-center px-1.5 h-8 leading-tight text-fv-neutral-500 bg-white border border-fv-neutral-300 hover:bg-fv-neutral-100 hover:text-fv-neutral-700 dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:bg-fv-neutral-700 dark:hover:text-white"
265
+ class="pagination-button pagination-nav-button"
266
+ :aria-label="$t('next_paging')"
267
+ title="Next page"
239
268
  @click="next()"
240
269
  >
270
+ <ChevronRightIcon class="w-5 h-5" aria-hidden="true" />
241
271
  <span class="sr-only">{{ $t("next_paging") }}</span>
242
- <ChevronRightIcon class="w-4 h-4" />
243
272
  </button>
244
273
  </li>
274
+ <li v-else class="pagination-item invisible md:hidden">
275
+ <div class="pagination-placeholder">
276
+ <ChevronRightIcon class="w-5 h-5 invisible" aria-hidden="true" />
277
+ </div>
278
+ </li>
245
279
  </ul>
246
280
  </nav>
281
+
282
+ <!-- Mobile page indication (x of y) -->
283
+ <div class="sm:hidden text-center mb-2">
284
+ <span class="text-sm font-medium text-fv-neutral-700 dark:text-fv-neutral-200">
285
+ Page {{ items.page_no }} of {{ items.page_max }}
286
+ </span>
287
+ </div>
288
+
289
+ <!-- Results summary -->
247
290
  <p
248
291
  v-if="showLegend"
249
- class="text-xs text-fv-neutral-700 dark:text-fv-neutral-400 pt-0.5"
292
+ class="text-xs text-center text-fv-neutral-700 dark:text-fv-neutral-400"
293
+ aria-live="polite"
250
294
  >
251
295
  {{
252
296
  $t("global_paging", {
@@ -259,3 +303,65 @@ onMounted(() => {
259
303
  </div>
260
304
  </div>
261
305
  </template>
306
+
307
+ <style scoped>
308
+ .pagination-list {
309
+ @apply inline-flex items-center justify-center gap-1 shadow-sm rounded-lg;
310
+ }
311
+
312
+ .pagination-item {
313
+ @apply flex items-center justify-center;
314
+ }
315
+
316
+ .pagination-link,
317
+ .pagination-button,
318
+ .pagination-current,
319
+ .pagination-ellipsis,
320
+ .pagination-placeholder {
321
+ @apply flex items-center justify-center;
322
+ min-width: 2.25rem;
323
+ height: 2.25rem;
324
+ }
325
+
326
+ .pagination-link {
327
+ @apply px-3 py-2 rounded-md text-sm font-medium bg-white border border-fv-neutral-200
328
+ text-fv-neutral-700 hover:bg-fv-neutral-50 hover:text-fv-primary-600
329
+ focus:z-10 focus:outline-none focus:ring-2 focus:ring-fv-primary-500 focus:ring-offset-1
330
+ transition-colors duration-200
331
+ dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-200
332
+ dark:hover:bg-fv-neutral-700 dark:hover:text-white
333
+ dark:focus:ring-fv-primary-500;
334
+ }
335
+
336
+ .pagination-current {
337
+ @apply px-3 py-2 rounded-md text-sm font-bold
338
+ bg-fv-primary-100 text-fv-primary-700 border border-fv-primary-300
339
+ dark:bg-fv-primary-900 dark:text-fv-primary-100 dark:border-fv-primary-700;
340
+ }
341
+
342
+ .pagination-nav-button {
343
+ @apply p-2 rounded-md text-fv-neutral-600 bg-white border border-fv-neutral-200
344
+ hover:bg-fv-neutral-50 hover:text-fv-primary-600
345
+ focus:z-10 focus:outline-none focus:ring-2 focus:ring-fv-primary-500 focus:ring-offset-1
346
+ transition-colors duration-200
347
+ dark:bg-fv-neutral-800 dark:border-fv-neutral-700 dark:text-fv-neutral-300
348
+ dark:hover:bg-fv-neutral-700 dark:hover:text-white;
349
+ }
350
+
351
+ .pagination-ellipsis {
352
+ @apply px-2 py-1 text-fv-neutral-500 dark:text-fv-neutral-400;
353
+ }
354
+
355
+ @media (max-width: 640px) {
356
+ .pagination-list {
357
+ @apply gap-2;
358
+ }
359
+
360
+ .pagination-link,
361
+ .pagination-button,
362
+ .pagination-current {
363
+ min-width: 2rem;
364
+ height: 2rem;
365
+ }
366
+ }
367
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.62",
3
+ "version": "2.2.64",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",