@fy-/fws-vue 2.2.2 → 2.2.4

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.
@@ -72,9 +72,9 @@ const rules = {
72
72
  required,
73
73
  ageValidator: props.force18
74
74
  ? helpers.withMessage(
75
- translate('fws_under_18_error_message'),
76
- ageValidator,
77
- )
75
+ translate('fws_under_18_error_message'),
76
+ ageValidator,
77
+ )
78
78
  : undefined,
79
79
  },
80
80
  AcceptedTerms: {
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { onMounted, onUnmounted, ref } from 'vue'
2
+ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
3
3
  import { useEventBus } from '../../composables/event-bus'
4
4
  import DefaultModal from './DefaultModal.vue'
5
5
 
@@ -7,34 +7,53 @@ const eventBus = useEventBus()
7
7
  const title = ref<string | null>(null)
8
8
  const desc = ref<string | null>(null)
9
9
  const onConfirm = ref<Function | null>(null)
10
+ const isOpen = ref<boolean>(false)
11
+ const modalRef = ref<HTMLElement | null>(null)
12
+ let previouslyFocusedElement: HTMLElement | null = null
13
+
10
14
  interface ConfirmModalData {
11
15
  title: string
12
16
  desc: string
13
17
  onConfirm: Function
14
18
  }
19
+
15
20
  async function _onConfirm() {
16
21
  if (onConfirm.value) {
17
22
  await onConfirm.value()
18
23
  }
19
24
  resetConfirm()
20
25
  }
26
+
21
27
  function resetConfirm() {
22
28
  title.value = null
23
29
  desc.value = null
24
30
  onConfirm.value = null
31
+ isOpen.value = false
25
32
  eventBus.emit('confirmModal', false)
33
+ if (previouslyFocusedElement) {
34
+ previouslyFocusedElement.focus()
35
+ }
26
36
  }
37
+
27
38
  function showConfirm(data: ConfirmModalData) {
28
39
  title.value = data.title
29
40
  desc.value = data.desc
30
41
  onConfirm.value = data.onConfirm
42
+ isOpen.value = true
31
43
  eventBus.emit('confirmModal', true)
44
+ nextTick(() => {
45
+ previouslyFocusedElement = document.activeElement as HTMLElement
46
+ if (modalRef.value) {
47
+ modalRef.value.focus()
48
+ }
49
+ })
32
50
  }
33
51
 
34
52
  onMounted(() => {
35
53
  eventBus.on('resetConfirm', resetConfirm)
36
54
  eventBus.on('showConfirm', showConfirm)
37
55
  })
56
+
38
57
  onUnmounted(() => {
39
58
  eventBus.off('resetConfirm', resetConfirm)
40
59
  eventBus.off('showConfirm', showConfirm)
@@ -42,25 +61,40 @@ onUnmounted(() => {
42
61
  </script>
43
62
 
44
63
  <template>
45
- <DefaultModal id="confirm">
64
+ <DefaultModal
65
+ id="confirm"
66
+
67
+ ref="modalRef"
68
+ >
46
69
  <div
47
70
  class="relative bg-fv-neutral-200 rounded-lg shadow dark:bg-fv-neutral-900"
71
+ :aria-labelledby="title ? 'confirm-modal-title' : undefined"
72
+ :aria-describedby="desc ? 'confirm-modal-desc' : undefined"
73
+ aria-modal="true"
74
+ role="dialog"
75
+ tabindex="-1"
48
76
  >
49
- <div class="p-1.5 lg:p-5 text-center">
77
+ <div
78
+ class="p-1.5 lg:p-5 text-center max-h-[80vh] overflow-y-auto cool-scroll"
79
+ >
80
+ <h2
81
+ v-if="title"
82
+ id="confirm-modal-title"
83
+ class="text-xl font-semibold text-fv-neutral-900 dark:text-white"
84
+ >
85
+ {{ title }}
86
+ </h2>
50
87
  <p
51
- class="mb-3 !text-left prose prose-invert prose-sm !min-w-full"
52
- v-html="
53
- desc ? `<h2>${title}</h2>${desc}` : `<h2>${title}</h2>`
54
- "
88
+ v-if="desc"
89
+ id="confirm-modal-desc"
90
+ class="mb-3 text-left prose prose-invert prose-sm min-w-full"
91
+ v-html="desc"
55
92
  />
56
93
  <div class="flex justify-between gap-3 mt-4">
57
94
  <button class="btn danger defaults" @click="_onConfirm()">
58
95
  {{ $t("confirm_modal_cta_confirm") }}
59
96
  </button>
60
- <button
61
- class="btn neutral defaults"
62
- @click="$eventBus.emit('confirmModal', false)"
63
- >
97
+ <button class="btn neutral defaults" @click="resetConfirm()">
64
98
  {{ $t("confirm_modal_cta_cancel") }}
65
99
  </button>
66
100
  </div>
@@ -5,7 +5,7 @@ import {
5
5
  TransitionRoot,
6
6
  } from '@headlessui/vue'
7
7
  import { XCircleIcon } from '@heroicons/vue/24/solid'
8
- import { h, onMounted, onUnmounted, ref } from 'vue'
8
+ import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
9
9
  import { useEventBus } from '../../composables/event-bus'
10
10
 
11
11
  const props = withDefaults(
@@ -28,12 +28,19 @@ const props = withDefaults(
28
28
  const eventBus = useEventBus()
29
29
 
30
30
  const isOpen = ref<boolean>(false)
31
+ const modalRef = ref<HTMLElement | null>(null)
32
+ let previouslyFocusedElement: HTMLElement | null = null
33
+
31
34
  function setModal(value: boolean) {
32
35
  if (value === true) {
33
36
  if (props.onOpen) props.onOpen()
37
+ previouslyFocusedElement = document.activeElement as HTMLElement
34
38
  }
35
39
  if (value === false) {
36
40
  if (props.onClose) props.onClose()
41
+ if (previouslyFocusedElement) {
42
+ previouslyFocusedElement.focus()
43
+ }
37
44
  }
38
45
  isOpen.value = value
39
46
  }
@@ -41,9 +48,17 @@ function setModal(value: boolean) {
41
48
  onMounted(() => {
42
49
  eventBus.on(`${props.id}Modal`, setModal)
43
50
  })
51
+
44
52
  onUnmounted(() => {
45
53
  eventBus.off(`${props.id}Modal`, setModal)
46
54
  })
55
+
56
+ watch(isOpen, async (newVal) => {
57
+ if (newVal) {
58
+ await nextTick()
59
+ modalRef.value?.focus()
60
+ }
61
+ })
47
62
  </script>
48
63
 
49
64
  <template>
@@ -61,9 +76,14 @@ onUnmounted(() => {
61
76
  :open="isOpen"
62
77
  class="fixed inset-0 overflow-y-auto"
63
78
  style="z-index: 40"
79
+ aria-modal="true"
80
+ role="dialog"
81
+ :aria-labelledby="title ? `${props.id}-title` : undefined"
64
82
  @close="setModal"
65
83
  >
66
84
  <DialogPanel
85
+ ref="modalRef"
86
+ tabindex="-1"
67
87
  class="flex absolute backdrop-blur-[8px] inset-0 flex-col items-center justify-center min-h-screen text-fv-neutral-800 dark:text-fv-neutral-300 bg-fv-neutral-900/[.20] dark:bg-fv-neutral-50/[.20]"
68
88
  style="z-index: 41"
69
89
  >
@@ -78,11 +98,13 @@ onUnmounted(() => {
78
98
  <slot name="before" />
79
99
  <h2
80
100
  v-if="title"
101
+ :id="`${props.id}-title`"
81
102
  class="text-xl font-semibold text-fv-neutral-900 dark:text-white"
82
103
  v-html="title"
83
104
  />
84
105
  <button
85
106
  class="text-fv-neutral-400 bg-transparent hover:bg-fv-neutral-200 hover:text-fv-neutral-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-fv-neutral-600 dark:hover:text-white"
107
+ aria-label="Close modal"
86
108
  @click="setModal(false)"
87
109
  >
88
110
  <component :is="closeIcon" class="w-7 h-7" />
@@ -58,6 +58,20 @@ const model = computed({
58
58
  },
59
59
  })
60
60
 
61
+ /**
62
+ * Compute aria-describedby IDs if help or error exist
63
+ */
64
+ const describedByIds = computed(() => {
65
+ const ids: any[] = []
66
+ if (props.help) {
67
+ ids.push(`help_tags_${props.id}`)
68
+ }
69
+ if (props.error) {
70
+ ids.push(`error_tags_${props.id}`)
71
+ }
72
+ return ids.join(' ')
73
+ })
74
+
61
75
  /**
62
76
  * Watch the model to see if maxTags is reached
63
77
  */
@@ -216,21 +230,30 @@ function handlePaste(e: ClipboardEvent) {
216
230
  <!-- Optional label -->
217
231
  <label
218
232
  v-if="label"
233
+ :id="`label_tags_${id}`"
219
234
  :for="`tags_${id}`"
220
235
  class="block text-sm font-medium dark:text-white"
221
236
  >
222
237
  {{ label }}
223
- <!-- optional help text -->
224
- <span v-if="help" class="ml-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-300">{{ help }}</span>
238
+ <!-- Optional help text -->
239
+ <span
240
+ v-if="help"
241
+ :id="`help_tags_${id}`"
242
+ class="ml-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-300"
243
+ >
244
+ {{ help }}
245
+ </span>
225
246
  </label>
226
247
 
227
248
  <div
228
- class="tags-input" :class="[
249
+ class="tags-input"
250
+ :class="[
229
251
  $props.error ? 'error' : '',
230
252
  isMaxReached ? 'pointer-events-none opacity-75' : '',
231
253
  ]"
232
254
  role="textbox"
233
- :aria-label="label || 'Tags input'"
255
+ :aria-labelledby="`label_tags_${id}`"
256
+ :aria-describedby="describedByIds || undefined"
234
257
  :aria-invalid="$props.error ? 'true' : 'false'"
235
258
  @click="focusInput"
236
259
  @keydown.delete.prevent="removeLastTag"
@@ -240,6 +263,7 @@ function handlePaste(e: ClipboardEvent) {
240
263
  <span
241
264
  v-for="(tag, index) in model"
242
265
  :key="`${tag}-${index}`"
266
+ role="listitem"
243
267
  class="tag"
244
268
  :class="{
245
269
  red: maxLenghtPerTag > 0 && tag.length > maxLenghtPerTag,
@@ -250,7 +274,7 @@ function handlePaste(e: ClipboardEvent) {
250
274
  <button
251
275
  type="button"
252
276
  class="flex items-center"
253
- aria-label="Remove tag"
277
+ :aria-label="`Remove tag ${tag}`"
254
278
  @click.prevent="removeTag(index)"
255
279
  >
256
280
  <svg
@@ -275,17 +299,26 @@ function handlePaste(e: ClipboardEvent) {
275
299
  :id="`tags_${id}`"
276
300
  ref="textInput"
277
301
  contenteditable="true"
302
+ tabindex="0"
278
303
  class="input"
279
304
  :placeholder="isMaxReached
280
305
  ? 'Max tags reached'
281
306
  : 'Type or paste and press Enter...'"
307
+ :aria-placeholder="isMaxReached
308
+ ? 'Max tags reached'
309
+ : 'Type or paste and press Enter...'"
282
310
  @input="handleInput"
283
311
  @paste.prevent="handlePaste"
284
312
  />
285
313
  </div>
286
314
 
287
315
  <!-- Inline error display if needed -->
288
- <p v-if="$props.error" class="text-xs text-red-500 mt-1">
316
+ <p
317
+ v-if="$props.error"
318
+ :id="`error_tags_${id}`"
319
+ class="text-xs text-red-500 mt-1"
320
+ aria-live="assertive"
321
+ >
289
322
  {{ $props.error }}
290
323
  </p>
291
324
 
@@ -346,6 +379,15 @@ function handlePaste(e: ClipboardEvent) {
346
379
  @apply bg-fv-neutral-400 dark:bg-fv-neutral-900;
347
380
  }
348
381
 
382
+ /* Increase the clickable target for remove buttons (improves mobile accessibility) */
383
+ .tag button {
384
+ min-width: 44px;
385
+ min-height: 44px;
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ }
390
+
349
391
  /* The editable input area for new tags */
350
392
  .input {
351
393
  @apply flex-grow min-w-[100px] outline-none border-none break-words;
package/index.ts CHANGED
@@ -8,18 +8,33 @@ import CmsArticleSingle from './components/fws/CmsArticleSingle.vue'
8
8
  import DataTable from './components/fws/DataTable.vue'
9
9
  import FilterData from './components/fws/FilterData.vue'
10
10
  import UserData from './components/fws/UserData.vue'
11
+ // Components/FWS
12
+ import UserFlow from './components/fws/UserFlow.vue'
13
+ import UserOAuth2 from './components/fws/UserOAuth2.vue'
14
+ import UserProfile from './components/fws/UserProfile.vue'
15
+
16
+ import UserProfileStrict from './components/fws/UserProfileStrict.vue'
11
17
  import { ClientOnly } from './components/ssr/ClientOnly'
12
18
  import DefaultBreadcrumb from './components/ui/DefaultBreadcrumb.vue'
13
19
  import DefaultConfirm from './components/ui/DefaultConfirm.vue'
14
-
15
20
  import DefaultDropdown from './components/ui/DefaultDropdown.vue'
21
+
16
22
  import DefaultDropdownLink from './components/ui/DefaultDropdownLink.vue'
17
23
  import DefaultGallery from './components/ui/DefaultGallery.vue'
24
+ // Components/UI
25
+ import DefaultInput from './components/ui/DefaultInput.vue'
26
+ import DefaultLoader from './components/ui/DefaultLoader.vue'
27
+ import DefaultModal from './components/ui/DefaultModal.vue'
28
+ import DefaultNotif from './components/ui/DefaultNotif.vue'
29
+ import DefaultPaging from './components/ui/DefaultPaging.vue'
30
+ import DefaultSidebar from './components/ui/DefaultSidebar.vue'
31
+ import DefaultTagInput from './components/ui/DefaultTagInput.vue'
18
32
  import CollapseTransition from './components/ui/transitions/CollapseTransition.vue'
19
33
  import ExpandTransition from './components/ui/transitions/ExpandTransition.vue'
20
-
21
34
  import FadeTransition from './components/ui/transitions/FadeTransition.vue'
22
35
  import ScaleTransition from './components/ui/transitions/ScaleTransition.vue'
36
+ // Components/UI/Transitions
37
+ import SlideTransition from './components/ui/transitions/SlideTransition.vue'
23
38
  import { useEventBus } from './composables/event-bus'
24
39
  import { useRest } from './composables/rest'
25
40
  import { useSeo } from './composables/seo'
@@ -44,21 +59,6 @@ import {
44
59
  useUserCheckAsyncSimple,
45
60
  useUserStore,
46
61
  } from './stores/user'
47
- // Components/UI/Transitions
48
- import SlideTransition from './components/ui/transitions/SlideTransition.vue'
49
- // Components/UI
50
- import DefaultInput from './components/ui/DefaultInput.vue'
51
- import DefaultLoader from './components/ui/DefaultLoader.vue'
52
- import DefaultModal from './components/ui/DefaultModal.vue'
53
- import DefaultNotif from './components/ui/DefaultNotif.vue'
54
- import DefaultPaging from './components/ui/DefaultPaging.vue'
55
- import DefaultSidebar from './components/ui/DefaultSidebar.vue'
56
- import DefaultTagInput from './components/ui/DefaultTagInput.vue'
57
- // Components/FWS
58
- import UserFlow from './components/fws/UserFlow.vue'
59
- import UserOAuth2 from './components/fws/UserOAuth2.vue'
60
- import UserProfile from './components/fws/UserProfile.vue'
61
- import UserProfileStrict from './components/fws/UserProfileStrict.vue'
62
62
  // Css
63
63
  import './style.css'
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",
@@ -27,20 +27,20 @@
27
27
  "typings": "index.ts",
28
28
  "types": "index.ts",
29
29
  "peerDependencies": {
30
- "@fy-/fws-js": "^0.0.x",
31
- "@fy-/fws-types": "^0.0.x",
30
+ "@fy-/fws-js": "^x.x.x",
31
+ "@fy-/fws-types": "^x.x.x",
32
32
  "@unhead/schema-org": "1.9.x",
33
33
  "@unhead/ssr": "^1.9.x",
34
34
  "@unhead/vue": "^1.9.x",
35
- "@vuelidate/core": "^2.0.x",
36
- "@vuelidate/validators": "^2.0.x",
37
- "@vueuse/core": "^10.x.x",
35
+ "@vuelidate/core": "^2.x.x",
36
+ "@vuelidate/validators": "^2.x.x",
37
+ "@vueuse/core": "^x.x.x",
38
38
  "mitt": "^3.0.x",
39
- "pinia": "2.x.x",
40
- "timeago.js": "^4.0.x",
41
- "vue": "^3.3.x",
42
- "vue-picture-cropper": "^0.7.x",
43
- "vue-router": "^4.1.x",
44
- "vue-tailwind-datepicker": "^1.7.x"
39
+ "pinia": "x.x.x",
40
+ "timeago.js": "^x.x.x",
41
+ "vue": "^x.x.x",
42
+ "vue-picture-cropper": "^0.x.x",
43
+ "vue-router": "^4.x.x",
44
+ "vue-tailwind-datepicker": "^1.x.x"
45
45
  }
46
46
  }
@@ -8,8 +8,8 @@ export interface ServerRouterState {
8
8
  results: Record<number, any | undefined>
9
9
  }
10
10
 
11
- export const useServerRouter = defineStore({
12
- id: 'routerStore',
11
+ export const useServerRouter = defineStore('routerStore', {
12
+
13
13
  state: () =>
14
14
  ({
15
15
  _router: null,
package/stores/user.ts CHANGED
@@ -10,8 +10,7 @@ export interface UserStore {
10
10
  user: User | null
11
11
  }
12
12
 
13
- export const useUserStore = defineStore({
14
- id: 'userStore',
13
+ export const useUserStore = defineStore('userStore', {
15
14
  state: (): UserStore => ({
16
15
  user: null,
17
16
  }),