diff --git a/src/components/SearchComponent.vue b/src/components/SearchComponent.vue index 037cc677..8cf2e554 100644 --- a/src/components/SearchComponent.vue +++ b/src/components/SearchComponent.vue @@ -95,19 +95,21 @@ const clear = () => { diff --git a/src/components/forms/bouquet/BouquetForm.vue b/src/components/forms/bouquet/BouquetForm.vue index 1029cbd2..7cb4587a 100644 --- a/src/components/forms/bouquet/BouquetForm.vue +++ b/src/components/forms/bouquet/BouquetForm.vue @@ -119,6 +119,7 @@ onMounted(() => {
{
{ v-bind="themeAttrs" class="fr-select" :aria-invalid="errors.theme && isSubmitted ? true : undefined" - :aria-describedby=" - errors.theme && isSubmitted ? 'errors-theme' : undefined - " + aria-errormessage="errors-theme" @change="subtheme = ''" >
{ v-model:groups-model="datasetsGroups" label="Regroupement" description="Rechercher ou créer un regroupement (100 caractères maximum). Un regroupement contient un ou plusieurs jeux de données." + :error-message="getErrorMessage('group')" />
@@ -191,6 +220,16 @@ onMounted(() => { .fr-fieldset { margin: 30px 0; } +.fr-fieldset.availability[aria-invalid='true'] { + border: none; + outline: 2px solid var(--border-plain-error); +} +#input-availability:focus { + outline-style: solid; +} +fieldset legend { + inline-size: fit-content; +} textarea { height: 150px; } diff --git a/src/components/forms/dataset/DatasetPropertiesTextFields.vue b/src/components/forms/dataset/DatasetPropertiesTextFields.vue index ba536f29..25d74de7 100644 --- a/src/components/forms/dataset/DatasetPropertiesTextFields.vue +++ b/src/components/forms/dataset/DatasetPropertiesTextFields.vue @@ -7,6 +7,17 @@ const datasetProperties = defineModel('datasetProperties-model', { default: {} }) +defineProps({ + errorTitle: { + type: String, + default: '' + }, + errorPurpose: { + type: String, + default: '' + } +}) + const { topicsName } = useTopicsConf() @@ -15,11 +26,23 @@ const { topicsName } = useTopicsConf() +

+ Décrivez l'indicateur ou l'objet géographique correspondant. Par + exemple : « Taux d'imperméabilisation des sols » +

+
@@ -42,6 +65,13 @@ const { topicsName } = useTopicsConf() class="fr-input" type="text" aria-describedby="purpose-description" + aria-errormessage="errors-purpose" + :aria-invalid="!!errorPurpose" + /> +
diff --git a/src/components/forms/dataset/SelectDataset.vue b/src/components/forms/dataset/SelectDataset.vue index 34ec5d8f..1fd7ec92 100644 --- a/src/components/forms/dataset/SelectDataset.vue +++ b/src/components/forms/dataset/SelectDataset.vue @@ -20,6 +20,14 @@ const props = defineProps({ alreadySelectedDatasets: { type: Array, default: [] + }, + isInvalid: { + type: Boolean, + default: false + }, + errorMessageId: { + type: String, + default: 'errors-availability' } }) @@ -85,6 +93,8 @@ const clear = () => { no-options-text="Aucun jeu de données trouvé, précisez ou élargissez votre recherche." :aria="{ 'aria-describedby': 'dataset-description', + 'aria-errormessage': `${errorMessageId}`, + 'aria-invalid': `${isInvalid ? true : undefined}`, // useless or unsupported https://github.com/vueform/multiselect/issues/436 'aria-labelledby': null, 'aria-multiselectable': null, diff --git a/src/store/SearchStore.js b/src/store/SearchStore.js index 01013823..660e15c3 100644 --- a/src/store/SearchStore.js +++ b/src/store/SearchStore.js @@ -28,7 +28,7 @@ export const useSearchStore = defineStore('search', { ].map((page) => { page += 1 return { - label: page, + label: page.toString(), href: '#', title: `Page ${page}` } diff --git a/src/utils/form.ts b/src/utils/form.ts new file mode 100644 index 00000000..ba127a8a --- /dev/null +++ b/src/utils/form.ts @@ -0,0 +1,114 @@ +import type { ComputedRef, Ref } from 'vue' +import { useTopicsConf } from './config' + +type FormErrorMessage = { inputName: string; message: string } + +const { topicsName } = useTopicsConf() + +export type FormErrorMessagesMap = Map< + FormErrorMessage['inputName'], + FormErrorMessage['message'] +> + +// define error messages for all inputs here +const errorMessages = [ + { + inputName: 'bouquetId', + message: `Veuillez sélectionner un ${topicsName}.` + }, + { + inputName: 'group', + message: 'Le nom du regroupement est limité à 100 caractères.' + }, + { inputName: 'title', message: 'Le libellé doit être renseigné.' }, + { + inputName: 'purpose', + message: "La raison d'utilisation ne doit pas être vide." + }, + { + inputName: 'availability', + message: 'Un jeu de données ou une disponibilité doit être sélectionné.' + }, + { + inputName: 'availabilityUrl', + message: 'Une URL doit être renseignée.' + } +] as const + +// create a union type of available input errors +export type AllowedInput = (typeof errorMessages)[number]['inputName'] + +interface UseFormOptions { + validateFields: () => void + onSuccess: () => void | Promise + errorSummaryRef?: Ref<{ $el: HTMLElement } | null> + isValid?: Ref +} + +export function useForm( + formErrors: Ref, + options?: UseFormOptions +): { + formErrorMessagesMap: ComputedRef + sortedErrors: ComputedRef + getErrorMessage: (inputName: AllowedInput) => string + isSubmitted: Ref + handleSubmit: () => void +} { + const isSubmitted = ref(false) + + const formErrorMessagesMap = computed(() => { + return errorMessages.reduce( + (acc, error) => acc.set(error.inputName, error.message), + new Map() + ) + }) + + const sortedErrors = computed(() => { + return Array.from(formErrorMessagesMap.value.keys()).filter((key) => + formErrors.value.includes(key) + ) + }) + + const getErrorMessage = (inputName: AllowedInput): string => { + if (formErrors.value.includes(inputName)) + return formErrorMessagesMap.value.get(inputName) + return '' + } + + const handleSubmit = async () => { + // reset error fields + formErrors.value = [] + isSubmitted.value = true + + // check input fields + options?.validateFields() + + // handle error summary + if (formErrors.value.length > 0 && options?.errorSummaryRef?.value) { + const errorSummaryTitle = options.errorSummaryRef.value.$el.querySelector( + '#error-summary-title' + ) as HTMLHeadingElement | null + + if (errorSummaryTitle) { + await nextTick() + errorSummaryTitle.focus() + } + return + } + if (options?.isValid !== undefined && !options.isValid.value) { + return + } + // submit if no error + isSubmitted.value = false + await options?.onSuccess() + } + + return { + formErrorMessagesMap, + sortedErrors, + getErrorMessage, + isSubmitted, + handleSubmit + } +} diff --git a/src/views/bouquets/BouquetsListView.vue b/src/views/bouquets/BouquetsListView.vue index 3f67df98..4e5b1a59 100644 --- a/src/views/bouquets/BouquetsListView.vue +++ b/src/views/bouquets/BouquetsListView.vue @@ -169,7 +169,6 @@ watch( v-model="selectedQuery" :is-filter="true" :search-label="`Filtrer les ${topicsName}s`" - :label="`Filtrer les ${topicsName}s`" :search-endpoint="router.resolve({ name: topicsSlug }).href" @update:model-value="search" /> diff --git a/src/views/datasets/DatasetsListView.vue b/src/views/datasets/DatasetsListView.vue index e594decc..d4a9d78f 100644 --- a/src/views/datasets/DatasetsListView.vue +++ b/src/views/datasets/DatasetsListView.vue @@ -122,7 +122,7 @@ const computeUrlQuery = ( } } -const onSelectTopic = (topicId: string) => { +const onSelectTopic = (topicId: string | number) => { router.push({ path: '/datasets', query: computeUrlQuery({ @@ -132,7 +132,7 @@ const onSelectTopic = (topicId: string) => { }) } -const onSelectOrganization = (orgId: string) => { +const onSelectOrganization = (orgId: string | number) => { router.push({ path: '/datasets', query: computeUrlQuery({ @@ -259,14 +259,12 @@ onMounted(() => {

diff --git a/vite.config.mts b/vite.config.mts index c9b353c6..3e2f6da8 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -35,7 +35,13 @@ export default defineConfig(({ mode }) => { base: '/', plugins: [ vueDevTools(), - vue(), + vue({ + template: { + compilerOptions: { + isCustomElement: (tag) => ['search'].includes(tag) + } + } + }), AutoImport({ include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/], imports: [