@cnamts/synapse 0.0.0-alpha.0 → 0.0.3-alpha
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/README.md +72 -2
- package/dist/design-system-v3.d.ts +234 -2
- package/dist/design-system-v3.js +1413 -4404
- package/dist/design-system-v3.umd.cjs +1 -2
- package/dist/style.css +1 -1
- package/package.json +37 -36
- package/src/components/Alert/Alert.vue +8 -8
- package/src/components/CollapsibleList/CollapsibleList.mdx +47 -0
- package/src/components/CollapsibleList/CollapsibleList.stories.ts +52 -0
- package/src/components/CollapsibleList/CollapsibleList.vue +157 -0
- package/src/components/CollapsibleList/tests/CollapsibleList.spec.ts +60 -0
- package/src/components/CollapsibleList/types.d.ts +5 -0
- package/src/components/Customs/CustomInputSelect/CustomInputSelect.mdx +42 -0
- package/src/components/Customs/CustomInputSelect/CustomInputSelect.stories.ts +154 -0
- package/src/components/Customs/CustomInputSelect/CustomInputSelect.vue +185 -0
- package/src/components/Customs/CustomInputSelect/tests/CustomInputSelect.spec.ts +216 -0
- package/src/components/Customs/CustomSelect/CustomSelect.mdx +47 -0
- package/src/components/Customs/CustomSelect/CustomSelect.stories.ts +182 -0
- package/src/components/Customs/CustomSelect/CustomSelect.vue +188 -0
- package/src/components/Customs/CustomSelect/tests/CustomSelect.spec.ts +236 -0
- package/src/components/FooterBar/A11yCompliance.ts +9 -0
- package/src/components/FooterBar/FooterBar.mdx +115 -0
- package/src/components/FooterBar/FooterBar.stories.ts +632 -0
- package/src/components/FooterBar/FooterBar.vue +330 -0
- package/src/components/FooterBar/config.ts +20 -0
- package/src/components/FooterBar/defaultSocialMediaLinks.ts +21 -0
- package/src/components/FooterBar/locales.ts +16 -0
- package/src/components/FooterBar/tests/FooterBar.spec.ts +167 -0
- package/src/components/FooterBar/tests/FooterBarConfig.spec.ts +36 -0
- package/src/components/FooterBar/tests/__snapshots__/FooterBar.spec.ts.snap +27 -0
- package/src/components/FooterBar/types.d.ts +10 -0
- package/src/components/FranceConnectBtn/FranceConnectBtn.vue +2 -2
- package/src/components/HeaderBar/HeaderBar.mdx +137 -0
- package/src/components/HeaderBar/HeaderBar.stories.ts +159 -0
- package/src/components/HeaderBar/HeaderBar.vue +238 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderComplexMenu.stories.ts +272 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderComplexMenu.vue +205 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderMenuItem/HeaderMenuItem.stories.ts +49 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderMenuItem/HeaderMenuItem.vue +51 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderMenuItem/tests/HeaderMenuItem.spec.ts +16 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderMenuItem/tests/__snapshots__/HeaderMenuItem.spec.ts.snap +3 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderMenuSection/HeaderMenuSection.stories.ts +56 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderMenuSection/HeaderMenuSection.vue +51 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderMenuSection/tests/HeaderMenuSection.spec.ts +33 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderSubMenu/HeaderSubMenu.stories.ts +137 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderSubMenu/HeaderSubMenu.vue +180 -0
- package/src/components/HeaderBar/HeaderComplexMenu/HeaderSubMenu/tests/HeaderSubMenu.spec.ts +63 -0
- package/src/components/HeaderBar/HeaderComplexMenu/conts.ts +1 -0
- package/src/components/HeaderBar/HeaderComplexMenu/locals.ts +4 -0
- package/src/components/HeaderBar/HeaderComplexMenu/tests/HeaderComplexMenu.spec.ts +129 -0
- package/src/components/HeaderBar/HeaderComplexMenu/tests/__snapshots__/HeaderComplexMenu.spec.ts.snap +18 -0
- package/src/components/HeaderBar/HeaderComplexMenu/tests/useHandleSubMenus.spec.ts +158 -0
- package/src/components/HeaderBar/HeaderComplexMenu/useHandleSubMenus.ts +49 -0
- package/src/components/HeaderBar/HeaderLogo/HeaderLogo.vue +106 -0
- package/src/components/HeaderBar/HeaderLogo/locales.ts +3 -0
- package/src/components/HeaderBar/HeaderLogo/logos/Logo-mobile.vue +117 -0
- package/src/components/HeaderBar/HeaderLogo/logos/Logo.vue +279 -0
- package/src/components/HeaderBar/HeaderLogo/tests/HeaderLogo.spec.ts +71 -0
- package/src/components/HeaderBar/HeaderMenuBtn/HeaderMenuBtn.vue +88 -0
- package/src/components/HeaderBar/HeaderMenuBtn/locals.ts +4 -0
- package/src/components/HeaderBar/consts.scss +7 -0
- package/src/components/HeaderBar/consts.ts +2 -0
- package/src/components/HeaderBar/locales.ts +3 -0
- package/src/components/HeaderBar/tests/HeaderBar.spec.ts +210 -0
- package/src/components/HeaderBar/tests/__snapshots__/HeaderBar.spec.ts.snap +50 -0
- package/src/components/HeaderBar/tests/useHeaderResponsiveMode.spec.ts +26 -0
- package/src/components/HeaderBar/tests/useScrollDirection.spec.ts +34 -0
- package/src/components/HeaderBar/useHeaderResponsiveMode.ts +25 -0
- package/src/components/HeaderBar/useScrollDirection.ts +26 -0
- package/src/components/LangBtn/LangBtn.mdx +2 -1
- package/src/components/LangBtn/LangBtn.vue +3 -3
- package/src/components/Logo/Logo.mdx +26 -0
- package/src/components/Logo/Logo.stories.ts +217 -0
- package/src/components/Logo/Logo.vue +397 -0
- package/src/components/Logo/LogoSize.ts +7 -0
- package/src/components/Logo/locales.ts +6 -0
- package/src/components/Logo/logoDimensionsMapping.ts +16 -0
- package/src/components/Logo/tests/Logo.spec.ts +75 -0
- package/src/components/Logo/types.d.ts +8 -0
- package/src/components/NotificationBar/NotificationBar.vue +5 -7
- package/src/components/PageContainer/PageContainer.vue +0 -1
- package/src/components/SocialMediaLinks/DefaultSocialMediaLinks.ts +21 -0
- package/src/components/SocialMediaLinks/SocialMediaLinks.mdx +15 -0
- package/src/components/SocialMediaLinks/SocialMediaLinks.stories.ts +72 -0
- package/src/components/SocialMediaLinks/SocialMediaLinks.vue +92 -0
- package/src/components/SocialMediaLinks/locales.ts +3 -0
- package/src/components/SocialMediaLinks/tests/DefaultSocialMediaLinks.spec.ts +21 -0
- package/src/components/SocialMediaLinks/tests/SocialMediaLinks.spec.ts +89 -0
- package/src/components/SocialMediaLinks/tests/__snapshots__/SocialMediaLinks.spec.ts.snap +24 -0
- package/src/components/SocialMediaLinks/types.d.ts +5 -0
- package/src/components/index.ts +6 -0
- package/src/directives/clickOutside.ts +24 -0
- package/src/temp/TestDTComponent.vue +6 -10
- package/src/temp/gridsTests.vue +0 -4
- package/src/utils/propValidator/index.ts +20 -0
- package/src/utils/propValidator/tests/propValidator.spec.ts +40 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { expect, describe, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import CollapsibleList from '../CollapsibleList.vue'
|
|
5
|
+
|
|
6
|
+
import { vuetify } from '@tests/unit/setup'
|
|
7
|
+
|
|
8
|
+
describe('CollapsibleList', () => {
|
|
9
|
+
it('renders correctly', async () => {
|
|
10
|
+
const wrapper = mount(CollapsibleList, {
|
|
11
|
+
global: {
|
|
12
|
+
plugins: [vuetify],
|
|
13
|
+
},
|
|
14
|
+
propsData: {
|
|
15
|
+
listTitle: 'Santé',
|
|
16
|
+
items: [
|
|
17
|
+
{
|
|
18
|
+
text: 'Mon espace santé',
|
|
19
|
+
href: 'https://www.ameli.fr/assure/sante/mon-espace-sante',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
text: 'Accomplir les bons gestes',
|
|
23
|
+
href: 'https://www.ameli.fr/assure/sante/bons-gestes',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(wrapper.find('h4').text()).toBe('Santé')
|
|
30
|
+
expect(wrapper.findAll('a')).toHaveLength(2)
|
|
31
|
+
expect(wrapper.findAll('a').at(0)?.text()).toBe('Mon espace santé')
|
|
32
|
+
expect(wrapper.findAll('a').at(1)?.text()).toBe('Accomplir les bons gestes')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders correctly with in mobile mode', () => {
|
|
36
|
+
const wrapper = mount(CollapsibleList, {
|
|
37
|
+
global: {
|
|
38
|
+
plugins: [vuetify],
|
|
39
|
+
},
|
|
40
|
+
propsData: {
|
|
41
|
+
listTitle: 'Santé',
|
|
42
|
+
items: [
|
|
43
|
+
{
|
|
44
|
+
text: 'Mon espace santé',
|
|
45
|
+
href: 'https://www.ameli.fr/assure/sante/mon-espace-sante',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
text: 'Accomplir les bons gestes',
|
|
49
|
+
href: 'https://www.ameli.fr/assure/sante/bons-gestes',
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
wrapper.vm.$vuetify.display.name = 'xs'
|
|
56
|
+
wrapper.vm.$vuetify.display.smAndDown = true
|
|
57
|
+
|
|
58
|
+
expect(wrapper.find('.vd-collapse-list-mobile')).toBeTruthy()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Canvas, Meta, Controls, Story, Source } from '@storybook/blocks';
|
|
2
|
+
import * as CustomInputSelectStories from "./CustomInputSelect.stories.ts";
|
|
3
|
+
|
|
4
|
+
<Meta of={CustomInputSelectStories} />
|
|
5
|
+
|
|
6
|
+
# CustomSelect
|
|
7
|
+
|
|
8
|
+
Le composant `CustomInputSelect` est utilisé pour proposer une alternative au `v-select` de Vuetify qui ne respecte pas les règles RGAA.
|
|
9
|
+
|
|
10
|
+
<Canvas of={CustomInputSelectStories.Default} />
|
|
11
|
+
|
|
12
|
+
<Story of={CustomInputSelectStories.Info} />
|
|
13
|
+
|
|
14
|
+
# API
|
|
15
|
+
|
|
16
|
+
<Controls of={CustomInputSelectStories.Default} />
|
|
17
|
+
|
|
18
|
+
# Exemple d'utilisation
|
|
19
|
+
|
|
20
|
+
<Source dark code={`
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import CustomInputSelect from '@/components/Customs/CustomInputSelect/CustomInputSelect.vue'
|
|
23
|
+
import { ref } from 'vue'
|
|
24
|
+
|
|
25
|
+
const value = ref('fr')
|
|
26
|
+
const items = ['fr', 'en', 'co']
|
|
27
|
+
const ariaLabel = 'Choix de la langue'
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<main>
|
|
32
|
+
<div class="mt-12 ml-12">
|
|
33
|
+
<CustomInputSelect
|
|
34
|
+
v-model="value"
|
|
35
|
+
:items="items"
|
|
36
|
+
:label="ariaLabel"
|
|
37
|
+
aria-label="Choix de la langue"
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
</main>
|
|
41
|
+
</template>
|
|
42
|
+
`} />
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
2
|
+
import CustomInputSelect from './CustomInputSelect.vue'
|
|
3
|
+
import { VBtn, VMenu, VList, VListItem, VListItemTitle } from 'vuetify/components'
|
|
4
|
+
import { ref } from 'vue'
|
|
5
|
+
import Alert from '../../Alert/Alert.vue'
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/CustomInputSelect',
|
|
9
|
+
component: CustomInputSelect,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'fullscreen',
|
|
12
|
+
controls: { exclude: ['selectedValue'] },
|
|
13
|
+
},
|
|
14
|
+
argTypes: {
|
|
15
|
+
selectedValue: { control: 'text' },
|
|
16
|
+
items: { control: 'object' },
|
|
17
|
+
errorMessages: { control: 'object' },
|
|
18
|
+
},
|
|
19
|
+
} as Meta<typeof CustomInputSelect>
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof meta>
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
items: [
|
|
27
|
+
{ text: 'Option 1', value: '1' },
|
|
28
|
+
{ text: 'Option 2', value: '2' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
render: (args) => {
|
|
32
|
+
return {
|
|
33
|
+
components: { CustomInputSelect, VBtn, VMenu, VList, VListItem, VListItemTitle },
|
|
34
|
+
setup() {
|
|
35
|
+
return { args }
|
|
36
|
+
},
|
|
37
|
+
template: `
|
|
38
|
+
<div class="d-flex flex-wrap align-center pa-4">
|
|
39
|
+
<CustomInputSelect
|
|
40
|
+
v-bind="args"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
<br/><br/><br/><br/>
|
|
44
|
+
`,
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const Outlined: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
items: [
|
|
52
|
+
{ text: 'Option 1', value: '1' },
|
|
53
|
+
{ text: 'Option 2', value: '2' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
render: (args) => {
|
|
57
|
+
return {
|
|
58
|
+
components: { CustomInputSelect, VBtn, VMenu, VList, VListItem, VListItemTitle },
|
|
59
|
+
setup() {
|
|
60
|
+
return { args }
|
|
61
|
+
},
|
|
62
|
+
template: `
|
|
63
|
+
<div class="d-flex flex-wrap align-center pa-4">
|
|
64
|
+
<CustomInputSelect
|
|
65
|
+
v-bind="args"
|
|
66
|
+
outlined
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
`,
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const withError: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
items: [
|
|
77
|
+
{ text: 'Option 1', value: '1' },
|
|
78
|
+
{ text: 'Option 2', value: '2' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
render: (args) => {
|
|
82
|
+
return {
|
|
83
|
+
components: { CustomInputSelect, VBtn, VMenu, VList, VListItem, VListItemTitle },
|
|
84
|
+
setup() {
|
|
85
|
+
const errorMessages = ref([])
|
|
86
|
+
const triggerError = () => {
|
|
87
|
+
// @ts-expect-error test error message
|
|
88
|
+
errorMessages.value = ['This is a test error message']
|
|
89
|
+
}
|
|
90
|
+
return { args, errorMessages, triggerError }
|
|
91
|
+
},
|
|
92
|
+
template: `
|
|
93
|
+
<div class="d-flex flex-wrap align-center pa-4">
|
|
94
|
+
<CustomInputSelect
|
|
95
|
+
v-bind="args"
|
|
96
|
+
:error-messages="errorMessages"
|
|
97
|
+
/>
|
|
98
|
+
<VBtn @click="triggerError">
|
|
99
|
+
Trigger Error
|
|
100
|
+
</VBtn>
|
|
101
|
+
</div>
|
|
102
|
+
`,
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const withCustomKey: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
items: [
|
|
110
|
+
{ customKey: 'Option 1', value: '1' },
|
|
111
|
+
{ customKey: 'Option 2', value: '2' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
render: (args) => {
|
|
115
|
+
return {
|
|
116
|
+
components: { CustomInputSelect, VBtn, VMenu, VList, VListItem, VListItemTitle },
|
|
117
|
+
setup() {
|
|
118
|
+
return { args }
|
|
119
|
+
},
|
|
120
|
+
template: `
|
|
121
|
+
<div class="d-flex flex-wrap align-center pa-4">
|
|
122
|
+
<CustomInputSelect
|
|
123
|
+
v-bind="args"
|
|
124
|
+
outlined
|
|
125
|
+
text-key="customKey"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
`,
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const Info: Story = {
|
|
134
|
+
render: (args) => {
|
|
135
|
+
return {
|
|
136
|
+
components: { Alert },
|
|
137
|
+
setup() {
|
|
138
|
+
return { args }
|
|
139
|
+
},
|
|
140
|
+
template: `
|
|
141
|
+
<Alert v-model="args.modelValue" :type="args.type" :variant="tonal" :closable="false">
|
|
142
|
+
<template #default>
|
|
143
|
+
<b>Format des items :</b>
|
|
144
|
+
<ul>
|
|
145
|
+
<li>- Si les items passés en props sont des objets, le composant les utilisera directement.</li>
|
|
146
|
+
<li>- Si les items sont un tableau de string, le composant les utilisera directement.</li>
|
|
147
|
+
</ul>
|
|
148
|
+
</template>
|
|
149
|
+
</Alert>
|
|
150
|
+
`,
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
tags: ['!dev'],
|
|
154
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { mdiMenuDown } from '@mdi/js'
|
|
3
|
+
import { ref, watch, computed, type PropType } from 'vue'
|
|
4
|
+
import { VIcon, VList, VListItem, VListItemTitle } from 'vuetify/components'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
modelValue: {
|
|
8
|
+
type: [Object, String],
|
|
9
|
+
default: null,
|
|
10
|
+
},
|
|
11
|
+
items: {
|
|
12
|
+
type: Array,
|
|
13
|
+
default: () => [],
|
|
14
|
+
},
|
|
15
|
+
label: {
|
|
16
|
+
type: String,
|
|
17
|
+
default: 'Sélectionnez une option',
|
|
18
|
+
},
|
|
19
|
+
errorMessages: {
|
|
20
|
+
type: [String, Array] as PropType<string | readonly string[]>,
|
|
21
|
+
default: () => [],
|
|
22
|
+
},
|
|
23
|
+
required: {
|
|
24
|
+
type: Boolean,
|
|
25
|
+
default: false,
|
|
26
|
+
},
|
|
27
|
+
menuId: {
|
|
28
|
+
type: String,
|
|
29
|
+
default: 'custom-select-menu',
|
|
30
|
+
},
|
|
31
|
+
outlined: {
|
|
32
|
+
type: Boolean,
|
|
33
|
+
default: false,
|
|
34
|
+
},
|
|
35
|
+
textKey: {
|
|
36
|
+
type: String,
|
|
37
|
+
default: 'text',
|
|
38
|
+
},
|
|
39
|
+
valueKey: {
|
|
40
|
+
type: String,
|
|
41
|
+
default: 'value',
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits(['update:modelValue'])
|
|
46
|
+
|
|
47
|
+
const isOpen = ref(false)
|
|
48
|
+
const selectedItem = ref<Record<string, unknown > | string | null>(props.modelValue)
|
|
49
|
+
|
|
50
|
+
const toggleMenu = () => {
|
|
51
|
+
isOpen.value = !isOpen.value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const closeList = () => {
|
|
55
|
+
isOpen.value = false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const inputId = ref(`custom-input-select-${Math.random().toString(36).substring(7)}`)
|
|
59
|
+
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
61
|
+
const selectItem = (item: any) => {
|
|
62
|
+
selectedItem.value = item
|
|
63
|
+
emit('update:modelValue', item)
|
|
64
|
+
isOpen.value = false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const getItemText = (item: unknown) => {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
69
|
+
return (item as Record<string, any>)[props.textKey]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const selectedItemText = computed(() => {
|
|
73
|
+
if (selectedItem.value && typeof selectedItem.value === 'object') {
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
75
|
+
return (selectedItem.value as Record<string, any>)[props.textKey]
|
|
76
|
+
}
|
|
77
|
+
return props.label
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
watch(() => props.modelValue, (newValue) => {
|
|
81
|
+
selectedItem.value = newValue
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const buttonClass = computed(() => {
|
|
85
|
+
return props.outlined ? 'v-btn v-btn--density-default v-btn--size-default v-btn--variant-outlined' : 'text-color'
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const formattedItems = computed(() => {
|
|
89
|
+
return props.items.map((item) => {
|
|
90
|
+
if (typeof item === 'string') {
|
|
91
|
+
return { [props.textKey]: item, [props.valueKey]: item }
|
|
92
|
+
}
|
|
93
|
+
return item
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<v-input
|
|
100
|
+
:id="inputId"
|
|
101
|
+
v-model="selectedItem"
|
|
102
|
+
:label="props.label"
|
|
103
|
+
:title="props.label"
|
|
104
|
+
role="menu"
|
|
105
|
+
:error-messages="errorMessages"
|
|
106
|
+
:required="required"
|
|
107
|
+
>
|
|
108
|
+
<div
|
|
109
|
+
ref="menu"
|
|
110
|
+
v-click-outside="closeList"
|
|
111
|
+
:class="['custom-select', buttonClass, 'primary']"
|
|
112
|
+
role="menu"
|
|
113
|
+
tabindex="0"
|
|
114
|
+
@click="toggleMenu"
|
|
115
|
+
@keydown.enter.prevent="toggleMenu"
|
|
116
|
+
@keydown.space.prevent="toggleMenu"
|
|
117
|
+
>
|
|
118
|
+
<span>{{ selectedItemText }}</span>
|
|
119
|
+
<VIcon> {{ mdiMenuDown }}</VIcon>
|
|
120
|
+
</div>
|
|
121
|
+
<VList
|
|
122
|
+
v-if="isOpen"
|
|
123
|
+
class="v-list"
|
|
124
|
+
:style="`max-width: ${$refs.menu ? $refs.menu.getBoundingClientRect().width : 0}px;`"
|
|
125
|
+
:aria-label="props.label"
|
|
126
|
+
:title="props.label"
|
|
127
|
+
@keydown.esc.prevent="isOpen = false"
|
|
128
|
+
>
|
|
129
|
+
<VListItem
|
|
130
|
+
v-for="(item, index) in formattedItems"
|
|
131
|
+
:key="index"
|
|
132
|
+
:ref="'options-' + index"
|
|
133
|
+
role="option"
|
|
134
|
+
class="v-list-item"
|
|
135
|
+
:aria-selected="selectedItem === item"
|
|
136
|
+
:tabindex="index + 1"
|
|
137
|
+
@click="selectItem(item)"
|
|
138
|
+
>
|
|
139
|
+
<VListItemTitle>
|
|
140
|
+
{{ getItemText(item) }}
|
|
141
|
+
</VListItemTitle>
|
|
142
|
+
</VListItem>
|
|
143
|
+
</VList>
|
|
144
|
+
</v-input>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<style scoped lang="scss">
|
|
148
|
+
@use '@/assets/tokens.scss';
|
|
149
|
+
|
|
150
|
+
.v-input {
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
position: relative;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.v-list {
|
|
156
|
+
position: absolute;
|
|
157
|
+
top: 36px;
|
|
158
|
+
width: 100%;
|
|
159
|
+
z-index: 1;
|
|
160
|
+
background-color: white;
|
|
161
|
+
min-width: fit-content;
|
|
162
|
+
max-width: 150px;
|
|
163
|
+
padding: 0;
|
|
164
|
+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.12), 0 2px 10px rgba(0, 0, 0, 0.08);
|
|
165
|
+
border-radius: 4px;
|
|
166
|
+
overflow-y: auto;
|
|
167
|
+
max-height: 300px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.v-list-item:hover {
|
|
171
|
+
background-color: rgba(0, 0, 0, 0.04);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.v-list-item[aria-selected='true'] {
|
|
175
|
+
background-color: rgba(0, 0, 0, 0.08);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.v-btn {
|
|
179
|
+
color: tokens.$blue-base;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.text-color {
|
|
183
|
+
color: tokens.$blue-base;
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { expect, describe, it } from 'vitest'
|
|
3
|
+
import CustomSelect from '../CustomInputSelect.vue'
|
|
4
|
+
import { vuetify } from '@tests/unit/setup'
|
|
5
|
+
|
|
6
|
+
describe('CustomInputSelect.vue', () => {
|
|
7
|
+
it('renders the component with default props', () => {
|
|
8
|
+
const wrapper = mount(CustomSelect, {
|
|
9
|
+
global: {
|
|
10
|
+
plugins: [vuetify],
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
expect(wrapper.exists()).toBe(true)
|
|
14
|
+
expect(wrapper.find('.custom-select').text()).toBe('Sélectionnez une option')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('toggles the menu when clicked', async () => {
|
|
18
|
+
const wrapper = mount(CustomSelect, {
|
|
19
|
+
global: {
|
|
20
|
+
plugins: [vuetify],
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
await wrapper.find('.custom-select').trigger('click')
|
|
24
|
+
expect(wrapper.find('.v-list').exists()).toBe(true)
|
|
25
|
+
await wrapper.find('.custom-select').trigger('click')
|
|
26
|
+
expect(wrapper.find('.v-list').exists()).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('selects an item when clicked', async () => {
|
|
30
|
+
const items = [{ text: 'Option 1', value: '1' }, { text: 'Option 2', value: '2' }]
|
|
31
|
+
const wrapper = mount(CustomSelect, {
|
|
32
|
+
props: { items },
|
|
33
|
+
global: {
|
|
34
|
+
plugins: [vuetify],
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
await wrapper.find('.custom-select').trigger('click')
|
|
38
|
+
const firstItem = wrapper.findAll('.v-list-item').at(0)
|
|
39
|
+
if (firstItem) {
|
|
40
|
+
await firstItem.trigger('click')
|
|
41
|
+
}
|
|
42
|
+
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([{ text: 'Option 1', value: '1' }])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('closes the menu when an item is selected', async () => {
|
|
46
|
+
const items = [{ text: 'Option 1', value: '1' }, { text: 'Option 2', value: '2' }]
|
|
47
|
+
const wrapper = mount(CustomSelect, {
|
|
48
|
+
props: { items },
|
|
49
|
+
global: {
|
|
50
|
+
plugins: [vuetify],
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
await wrapper.find('.custom-select').trigger('click')
|
|
54
|
+
const firstItem = wrapper.findAll('.v-list-item').at(0)
|
|
55
|
+
if (firstItem) {
|
|
56
|
+
await firstItem.trigger('click')
|
|
57
|
+
}
|
|
58
|
+
expect(wrapper.find('.v-list').exists()).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('displays the selected item text', async () => {
|
|
62
|
+
const items = [{ text: 'Option 1', value: '1' }, { text: 'Option 2', value: '2' }]
|
|
63
|
+
const wrapper = mount(CustomSelect, {
|
|
64
|
+
props: { items, modelValue: { text: 'Option 1', value: '1' } },
|
|
65
|
+
global: {
|
|
66
|
+
plugins: [vuetify],
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
expect(wrapper.find('.custom-select').text()).toContain('Option 1')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('closes the menu on escape key press', async () => {
|
|
73
|
+
const items = [{ text: 'Option 1', value: '1' }, { text: 'Option 2', value: '2' }]
|
|
74
|
+
const wrapper = mount(CustomSelect, {
|
|
75
|
+
props: { items },
|
|
76
|
+
global: {
|
|
77
|
+
plugins: [vuetify],
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
await wrapper.find('.custom-select').trigger('click')
|
|
81
|
+
await wrapper.find('.v-list').trigger('keydown.esc')
|
|
82
|
+
expect(wrapper.find('.v-list').exists()).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('renders error messages when provided', () => {
|
|
86
|
+
const errorMessages = ['Error 1']
|
|
87
|
+
const wrapper = mount(CustomSelect, {
|
|
88
|
+
props: { errorMessages },
|
|
89
|
+
global: {
|
|
90
|
+
plugins: [vuetify],
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
expect(wrapper.find('.v-messages__message').text()).toContain('Error 1')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not render error messages when not provided', () => {
|
|
97
|
+
const wrapper = mount(CustomSelect, {
|
|
98
|
+
global: {
|
|
99
|
+
plugins: [vuetify],
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
expect(wrapper.find('.v-messages__message').exists()).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not render the label when not provided', () => {
|
|
106
|
+
const wrapper = mount(CustomSelect, {
|
|
107
|
+
global: {
|
|
108
|
+
plugins: [vuetify],
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
expect(wrapper.find('label').exists()).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns the correct item text using getItemText', () => {
|
|
115
|
+
const wrapper = mount(CustomSelect, {
|
|
116
|
+
props: { textKey: 'text' },
|
|
117
|
+
global: {
|
|
118
|
+
plugins: [vuetify],
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
const item = { text: 'Option 1', value: '1' }
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
123
|
+
const instance = wrapper.vm as any
|
|
124
|
+
expect(instance.getItemText(item)).toBe('Option 1')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('returns default text when selectedItem is null', () => {
|
|
128
|
+
const wrapper = mount(CustomSelect, {
|
|
129
|
+
global: {
|
|
130
|
+
plugins: [vuetify],
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
134
|
+
const instance = wrapper.vm as any
|
|
135
|
+
expect(instance.selectedItemText).toBe('Sélectionnez une option')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('returns the correct text when selectedItem is an object', async () => {
|
|
139
|
+
const wrapper = mount(CustomSelect, {
|
|
140
|
+
props: { modelValue: { text: 'Option 1', value: '1' }, textKey: 'text' },
|
|
141
|
+
global: {
|
|
142
|
+
plugins: [vuetify],
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
146
|
+
const instance = wrapper.vm as any
|
|
147
|
+
await wrapper.setProps({ modelValue: { text: 'Option 1', value: '1' } })
|
|
148
|
+
expect(instance.selectedItemText).toBe('Option 1')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('formats items correctly', () => {
|
|
152
|
+
const items = ['Option 1', 'Option 2']
|
|
153
|
+
const wrapper = mount(CustomSelect, {
|
|
154
|
+
props: { items, textKey: 'text', valueKey: 'value' },
|
|
155
|
+
global: {
|
|
156
|
+
plugins: [vuetify],
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
160
|
+
const formattedItems = (wrapper.vm as any).formattedItems
|
|
161
|
+
expect(formattedItems).toEqual([
|
|
162
|
+
{ text: 'Option 1', value: 'Option 1' },
|
|
163
|
+
{ text: 'Option 2', value: 'Option 2' },
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('applies the correct button class when outlined is true', () => {
|
|
168
|
+
const wrapper = mount(CustomSelect, {
|
|
169
|
+
props: { outlined: true },
|
|
170
|
+
global: {
|
|
171
|
+
plugins: [vuetify],
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
expect(wrapper.find('.custom-select').classes()).toContain('v-btn--variant-outlined')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('does not apply the outlined button class when outlined is false', () => {
|
|
178
|
+
const wrapper = mount(CustomSelect, {
|
|
179
|
+
props: { outlined: false },
|
|
180
|
+
global: {
|
|
181
|
+
plugins: [vuetify],
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
expect(wrapper.find('.custom-select').classes()).not.toContain('v-btn--variant-outlined')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('updates selectedItem when v-model changes', async () => {
|
|
188
|
+
const wrapper = mount(CustomSelect, {
|
|
189
|
+
props: { modelValue: { text: 'Option 1', value: '1' }, textKey: 'text' },
|
|
190
|
+
global: {
|
|
191
|
+
plugins: [vuetify],
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
195
|
+
const instance = wrapper.vm as any
|
|
196
|
+
expect(instance.selectedItem).toEqual({ text: 'Option 1', value: '1' })
|
|
197
|
+
|
|
198
|
+
await wrapper.setProps({ modelValue: { text: 'Option 2', value: '2' } })
|
|
199
|
+
expect(instance.selectedItem).toEqual({ text: 'Option 2', value: '2' })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('emits update:modelValue when selectedItem changes', async () => {
|
|
203
|
+
const wrapper = mount(CustomSelect, {
|
|
204
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
205
|
+
props: { modelValue: null as any, textKey: 'text' },
|
|
206
|
+
global: {
|
|
207
|
+
plugins: [vuetify],
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a generic type
|
|
211
|
+
const instance = wrapper.vm as any
|
|
212
|
+
instance.selectItem({ text: 'Option 1', value: '1' })
|
|
213
|
+
await wrapper.vm.$nextTick()
|
|
214
|
+
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([{ text: 'Option 1', value: '1' }])
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Canvas, Meta, Controls, Story, Source } from '@storybook/blocks';
|
|
2
|
+
import * as CustomSelectStories from "./CustomSelect.stories";
|
|
3
|
+
|
|
4
|
+
<Meta of={CustomSelectStories} />
|
|
5
|
+
|
|
6
|
+
# CustomSelect
|
|
7
|
+
|
|
8
|
+
Le composant `CustomSelect` est utilisé pour proposer une alternative au `v-select` de Vuetify qui ne respecte pas les règles d'accessibilité RGAA.
|
|
9
|
+
|
|
10
|
+
<Canvas of={CustomSelectStories.Default} />
|
|
11
|
+
|
|
12
|
+
<Story of={CustomSelectStories.Info} />
|
|
13
|
+
|
|
14
|
+
# API
|
|
15
|
+
|
|
16
|
+
<Controls of={CustomSelectStories.Default} />
|
|
17
|
+
|
|
18
|
+
# Exemple d'utilisation
|
|
19
|
+
|
|
20
|
+
<Source dark code={`
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import { ref } from 'vue'
|
|
23
|
+
import CustomSelect from '@/components/Customs/CustomSelect/CustomSelect.vue'
|
|
24
|
+
|
|
25
|
+
const selectedValue = ref(undefined)
|
|
26
|
+
|
|
27
|
+
const items = ref([
|
|
28
|
+
{ text: 'Option 1', value: '1' },
|
|
29
|
+
{ text: 'Option 2', value: '2' },
|
|
30
|
+
])
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<main class="ma-12">
|
|
35
|
+
SelectedValue: {{ selectedValue }}
|
|
36
|
+
<div class="d-flex flex-wrap align-center">
|
|
37
|
+
<custom-select
|
|
38
|
+
v-model="selectedValue"
|
|
39
|
+
:items="items"
|
|
40
|
+
text-key="text"
|
|
41
|
+
outlined
|
|
42
|
+
required
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
</main>
|
|
46
|
+
</template>
|
|
47
|
+
`} />
|