Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d350e12c6 | |||
| c6acaace27 | |||
| 927c7c3c70 | |||
| bf0aa92497 | |||
| 88dd76a0e4 | |||
| cc04114f89 | |||
| f456ea4ddf | |||
| 77364daa67 | |||
| 1ab7b2427a | |||
| 82ecc9cfe2 | |||
| 65d9060e26 | |||
| ec4c157226 |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npx vitest:*)",
|
||||
"Bash(sed -i \"s|from ''../../../app/components/malio/Checkbox.vue''|from ''../../../app/components/malio/checkbox/Checkbox.vue''|\" .playground/pages/composant/checkbox.vue)",
|
||||
"Bash(sed -i \"s|from ''../../../app/components/malio/RadioButton.vue''|from ''../../../app/components/malio/radio/RadioButton.vue''|\" .playground/pages/composant/radioButton.vue)",
|
||||
"Bash(sed -i \"s|from ''../../../app/components/malio/Time.vue''|from ''../../../app/components/malio/time/Time.vue''|\" .playground/pages/composant/time.vue)",
|
||||
"Bash(sed -i \"s|from ''../../../app/components/malio/InputTextArea.vue''|from ''../../../app/components/malio/input/InputTextArea.vue''|\" .playground/pages/composant/inputTextArea.vue)",
|
||||
"Bash(npx nuxi:*)",
|
||||
"Bash(mkdir -p button input select checkbox radio time)",
|
||||
"Bash(mv buttonIcon.story.vue button/)",
|
||||
"Bash(mv inputText.story.vue inputAmount.story.vue inputNumber.story.vue inputPassword.story.vue inputTextArea.story.vue inputUpload.story.vue input/)",
|
||||
"Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)",
|
||||
"Bash(mv inputCheckbox.story.vue checkbox/)",
|
||||
"Bash(npx eslint *)",
|
||||
"Bash(echo \"LINT EXIT: $?\")",
|
||||
"Bash(git commit *)",
|
||||
"mcp__chrome__navigate_page",
|
||||
"mcp__chrome__take_snapshot",
|
||||
"mcp__chrome__click",
|
||||
"mcp__chrome__evaluate_script"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
---
|
||||
name: creating-malio-component
|
||||
description: Use when creating a new UI component in the @malio/layer-ui Nuxt layer — covers component, tests, playground page, and Histoire story
|
||||
---
|
||||
|
||||
# Creating a Malio Component
|
||||
|
||||
## Overview
|
||||
|
||||
Step-by-step process for creating a component in `@malio/layer-ui`. Each component requires 6 deliverables : le `.vue`, les tests, la page playground, la story Histoire, la mise à jour du CHANGELOG, et la mise à jour du `COMPONENTS.md`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Création d'un nouveau composant dans `app/components/malio/`
|
||||
- Ajout d'une variante d'un composant existant (ex: InputPassword basé sur InputText)
|
||||
|
||||
## Workflow
|
||||
|
||||
```dot
|
||||
digraph create_component {
|
||||
rankdir=TB;
|
||||
"1. Lire les fichiers de référence" -> "2. Créer le composant .vue";
|
||||
"2. Créer le composant .vue" -> "3. Créer les tests .test.ts";
|
||||
"3. Créer les tests .test.ts" -> "4. npm run test + npm run lint";
|
||||
"4. npm run test + npm run lint" -> "Tests OK?" [shape=diamond];
|
||||
"Tests OK?" -> "5. Créer la page playground" [label="oui"];
|
||||
"Tests OK?" -> "3. Créer les tests .test.ts" [label="non, corriger"];
|
||||
"5. Créer la page playground" -> "6. Créer la story Histoire";
|
||||
"6. Créer la story Histoire" -> "7. Mettre à jour CHANGELOG.md";
|
||||
"7. Mettre à jour CHANGELOG.md" -> "8. Mettre à jour COMPONENTS.md";
|
||||
}
|
||||
```
|
||||
|
||||
## Étapes
|
||||
|
||||
### 1. Lire les fichiers de référence
|
||||
|
||||
Identifier le composant le plus proche comme base (ex: `InputText.vue` pour `InputPassword.vue`). Lire :
|
||||
- Le composant de référence : `app/components/malio/<Ref>.vue`
|
||||
- Ses tests : `app/components/malio/<Ref>.test.ts`
|
||||
|
||||
### 2. Créer le composant `.vue`
|
||||
|
||||
**Fichier :** `app/components/malio/<NomComposant>.vue`
|
||||
|
||||
**Checklist obligatoire :**
|
||||
|
||||
| Élément | Pattern |
|
||||
|---------|---------|
|
||||
| `defineOptions` | `{ name: 'Malio<Nom>', inheritAttrs: false }` |
|
||||
| Props | `defineProps<T>()` + `withDefaults()` — props communes : `id`, `label`, `modelValue`, `inputClass`, `labelClass`, `groupClass`, `disabled`, `readonly`, `hint`, `error`, `success` |
|
||||
| Contrôlé / non-contrôlé | `isControlled = computed(() => props.modelValue !== undefined)` + `localValue` en fallback |
|
||||
| Classes CSS | Fusionnées via `twMerge()` pour permettre l'override consommateur |
|
||||
| Accessibilité | `aria-invalid`, `aria-describedby`, `label[for]` lié à `input[id]` |
|
||||
| Icônes | `Icon as IconifyIcon` depuis `@iconify/vue` (pas `@nuxt/icon`) |
|
||||
| ID généré | `useId()` + prefix unique (ex: `malio-input-password-${generatedId}`) |
|
||||
|
||||
### 3. Créer les tests `.test.ts`
|
||||
|
||||
**Fichier :** `app/components/malio/<NomComposant>.test.ts` (colocalisé)
|
||||
|
||||
**Pattern de montage :**
|
||||
|
||||
```ts
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
import MonComposant from './MonComposant.vue'
|
||||
|
||||
const ComposantForTest = MonComposant as DefineComponent<MonComposantProps>
|
||||
|
||||
const mountComponent = (props: MonComposantProps = {}) =>
|
||||
mount(ComposantForTest, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
IconifyIcon: {
|
||||
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Tests minimum à couvrir :**
|
||||
- Rendu initial avec valeur
|
||||
- Rendu du label
|
||||
- Emit `update:modelValue`
|
||||
- Props `disabled`, `readonly`
|
||||
- États `error`, `success`, `hint` (messages + classes CSS)
|
||||
- Accessibilité (`aria-invalid`, `label[for]` / `input[id]`)
|
||||
- Comportements spécifiques au composant
|
||||
|
||||
**Attention stub IconifyIcon :** Le stub basé sur le nom `IconifyIcon` ne remplace pas toujours le vrai composant `@iconify/vue`. Pour tester les props du composant Icon (ex: `icon`), utiliser `findComponent` avec l'import réel :
|
||||
|
||||
```ts
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
// ...
|
||||
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||
expect(iconComponent.props('icon')).toBe('mdi:eye-outline')
|
||||
```
|
||||
|
||||
### 4. Vérification
|
||||
|
||||
```bash
|
||||
npm run test # Tous les tests passent
|
||||
npm run lint # Pas d'erreurs
|
||||
```
|
||||
|
||||
### 5. Créer la page playground
|
||||
|
||||
**Fichier :** `.playground/pages/composant/<categorie>/<nomComposant>.vue` (camelCase, dans le sous-dossier de catégorie)
|
||||
|
||||
La page devient automatiquement une route Nuxt (`/composant/<categorie>/<nomComposant>`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant/<categorie>/<nomComposant>`. Inclure des variantes représentatives dans une grille :
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Titre variante</h2>
|
||||
<MalioMonComposant ... />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Variantes typiques :** simple, avec label, désactivé, readonly, hint, erreur, succès, validation dynamique.
|
||||
|
||||
### 6. Créer la story Histoire
|
||||
|
||||
**Fichier :** `app/story/<nomComposant>.story.vue` (camelCase)
|
||||
|
||||
**Structure :**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Story title="Category/Name">
|
||||
<!-- Variantes avec v-model et valeurs initiales -->
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<docs lang="md">
|
||||
# MalioNomComposant
|
||||
Description courte.
|
||||
## Props détaillées
|
||||
<!-- Documenter chaque prop : type, description, défaut, comportement -->
|
||||
## Comportement
|
||||
## Accessibilité
|
||||
## Events
|
||||
</docs>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MalioMonComposant from '../components/malio/MonComposant.vue'
|
||||
// refs pour chaque variante avec valeurs initiales
|
||||
</script>
|
||||
```
|
||||
|
||||
**Important : initial state avec variantes.** La story doit contenir des exemples visuels directement visibles (pas un composant vide). Chaque variante a un `v-model` avec une `ref` initialisée. Variantes typiques à inclure :
|
||||
- Simple (avec label)
|
||||
- Sans icône (`display-icon="false"`) si applicable
|
||||
- Avec hint
|
||||
- Désactivé (avec valeur pré-remplie)
|
||||
- Readonly (avec valeur pré-remplie)
|
||||
- Erreur (avec valeur + message d'erreur)
|
||||
- Succès (avec valeur + message de succès)
|
||||
|
||||
### 7. Mettre à jour le CHANGELOG
|
||||
|
||||
**Fichier :** `CHANGELOG.md` à la racine du projet.
|
||||
|
||||
Ajouter une ligne dans la section `### Added` de la version courante. Le numéro de ticket se trouve dans le nom de la branche Git (ex: branche `feat/MUI-8-composant-password` → ticket `MUI-8`).
|
||||
|
||||
**Format :**
|
||||
- Avec numéro de ticket : `* [#MUI-8] Création d'un composant mot de passe`
|
||||
- Sans numéro de ticket : `* Création d'un composant textarea`
|
||||
|
||||
Pour extraire le numéro de ticket depuis la branche courante :
|
||||
```bash
|
||||
git branch --show-current | grep -oP '(MUI-\d+|\d{3,})' | head -1
|
||||
```
|
||||
|
||||
### 8. Mettre à jour COMPONENTS.md
|
||||
|
||||
**Fichier :** `COMPONENTS.md` à la racine du projet.
|
||||
|
||||
Ce fichier sert de documentation de référence pour les projets qui consomment `@malio/layer-ui`. Il est lu par Claude dans les projets consommateurs pour connaître les composants disponibles et leurs props.
|
||||
|
||||
**Ajouter une section pour le nouveau composant** en suivant le format existant :
|
||||
|
||||
```markdown
|
||||
## MalioNomComposant
|
||||
|
||||
Description courte du composant.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
|
||||
\`\`\`vue
|
||||
<MalioNomComposant v-model="val" label="Exemple" />
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
**Checklist :**
|
||||
- Toutes les props documentées avec type, défaut et description
|
||||
- Events listés
|
||||
- Slots listés si applicable
|
||||
- 2-5 exemples d'utilisation couvrant les cas courants (simple, avec options, disabled, erreur)
|
||||
- Section placée par ordre logique (inputs ensemble, boutons ensemble, etc.)
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
Cette section est alimentée au fur et à mesure des retours utilisateur et des problèmes rencontrés. **Si un retour ou un bug est identifié lors de la création d'un composant, ajouter une ligne dans ce tableau.**
|
||||
|
||||
| Erreur | Solution |
|
||||
|--------|----------|
|
||||
| Stub IconifyIcon ne fonctionne pas dans les tests | Utiliser `findComponent(IconifyIcon)` avec l'import réel pour tester les props |
|
||||
| Oubli de `inheritAttrs: false` | Toujours dans `defineOptions` — sinon les attrs se dupliquent |
|
||||
| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) |
|
||||
| Padding input pas ajusté avec icône | Ajouter `!pr-10` (ou équivalent) quand une icône est présente à droite |
|
||||
| Story sans initial state | Toujours initialiser les `ref` avec des valeurs pour que les variantes soient visibles dès le chargement |
|
||||
| CHANGELOG oublié | Toujours ajouter la ligne dans `### Added` avant de commit |
|
||||
| COMPONENTS.md pas mis à jour | Ajouter la doc du composant dans `COMPONENTS.md` — c'est la référence pour les projets consommateurs |
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-screen">
|
||||
<MalioSidebar :sections="navSections">
|
||||
<template #logo>
|
||||
<NuxtLink to="/">
|
||||
<img src="/LOGO_MALIO.png" alt="Malio">
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #logo-collapsed>
|
||||
<NuxtLink to="/">
|
||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</MalioSidebar>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {navSections} from '../playground.nav'
|
||||
</script>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) — défaut</h2>
|
||||
<MalioAccordion v-model="multiple">
|
||||
<MalioAccordionItem title="Prix" value="prix">
|
||||
<p>Slider de prix ici…</p>
|
||||
</MalioAccordionItem>
|
||||
<MalioAccordionItem title="Catégorie" value="cat">
|
||||
<p>Liste de checkboxes ici…</p>
|
||||
</MalioAccordionItem>
|
||||
<MalioAccordionItem title="Marque" value="marque">
|
||||
<p>Recherche + liste ici…</p>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
<p class="mt-2 text-sm text-gray-500">Ouverts : {{ multiple }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
|
||||
<MalioAccordion v-model="single" mode="single">
|
||||
<MalioAccordionItem title="Question 1" value="q1">
|
||||
<p>Réponse 1</p>
|
||||
</MalioAccordionItem>
|
||||
<MalioAccordionItem title="Question 2" value="q2">
|
||||
<p>Réponse 2</p>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
<p class="mt-2 text-sm text-gray-500">Ouvert : {{ single }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Non contrôlé + defaultOpen</h2>
|
||||
<MalioAccordion>
|
||||
<MalioAccordionItem title="Section A" value="a" :default-open="true">
|
||||
<p>Ouverte au montage</p>
|
||||
</MalioAccordionItem>
|
||||
<MalioAccordionItem title="Section B" value="b">
|
||||
<p>Fermée au montage</p>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
|
||||
<MalioAccordion>
|
||||
<MalioAccordionItem title="Active" value="ok">
|
||||
<p>Contenu accessible</p>
|
||||
</MalioAccordionItem>
|
||||
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
|
||||
<p>Inaccessible</p>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const multiple = ref<string[]>(['prix'])
|
||||
const single = ref('q1')
|
||||
</script>
|
||||
@@ -1,112 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Primary</h2>
|
||||
<div class="grid grid-cols-2 items-start gap-3">
|
||||
<p class="text-xs font-semibold text-m-muted">Default</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Default</p>
|
||||
<MalioButton label="Valider" />
|
||||
<MalioButton label="Enregistrer" />
|
||||
<p class="text-xs font-semibold text-m-muted">Hover</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Hover</p>
|
||||
<MalioButton label="Valider" button-class="bg-m-btn-primary-hover" />
|
||||
<MalioButton label="Valider" button-class="bg-m-btn-primary-hover" />
|
||||
<p class="text-xs font-semibold text-m-muted">Active</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Active</p>
|
||||
<MalioButton label="Valider" button-class="bg-m-btn-primary-active" />
|
||||
<MalioButton label="Valider" button-class="bg-m-btn-primary-active" />
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled</p>
|
||||
<MalioButton label="Valider" disabled />
|
||||
<MalioButton label="Valider" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Secondary</h2>
|
||||
<div class="grid grid-cols-2 items-start gap-3">
|
||||
<p class="text-xs font-semibold text-m-muted">Default</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Default + icon</p>
|
||||
<MalioButton label="Modifier" variant="secondary" />
|
||||
<MalioButton label="Modifier" variant="secondary" icon-name="mdi:pencil" icon-position="left" />
|
||||
<p class="text-xs font-semibold text-m-muted">Hover</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Hover + icon</p>
|
||||
<MalioButton label="Modifier" variant="secondary" button-class="bg-m-btn-secondary-hover" />
|
||||
<MalioButton label="Modifier" variant="secondary" button-class="bg-m-btn-secondary-hover" icon-name="mdi:pencil" icon-position="left" />
|
||||
<p class="text-xs font-semibold text-m-muted">Active</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Active + icon</p>
|
||||
<MalioButton label="Modifier" variant="secondary" button-class="bg-m-btn-secondary-active" />
|
||||
<MalioButton label="Modifier" variant="secondary" button-class="bg-m-btn-secondary-active" icon-name="mdi:pencil" icon-position="left" />
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled + icon</p>
|
||||
<MalioButton label="Modifier" variant="secondary" disabled />
|
||||
<MalioButton label="Modifier" variant="secondary" disabled icon-name="mdi:pencil" icon-position="left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Tertiary</h2>
|
||||
<div class="grid grid-cols-2 items-start gap-3">
|
||||
<p class="text-xs font-semibold text-m-muted">Default</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Default</p>
|
||||
<MalioButton label="Voir plus" variant="tertiary" />
|
||||
<MalioButton label="Afficher" variant="tertiary" />
|
||||
<p class="text-xs font-semibold text-m-muted">Hover</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Hover</p>
|
||||
<MalioButton label="Voir plus" variant="tertiary" button-class="border-m-btn-primary-hover text-m-btn-primary-hover" />
|
||||
<MalioButton label="Afficher" variant="tertiary" button-class="border-m-btn-primary-hover text-m-btn-primary-hover" />
|
||||
<p class="text-xs font-semibold text-m-muted">Active</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Active</p>
|
||||
<MalioButton label="Voir plus" variant="tertiary" button-class="border-m-btn-primary-active text-m-btn-primary-active" />
|
||||
<MalioButton label="Afficher" variant="tertiary" button-class="border-m-btn-primary-active text-m-btn-primary-active" />
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled</p>
|
||||
<MalioButton label="Voir plus" variant="tertiary" disabled />
|
||||
<MalioButton label="Afficher" variant="tertiary" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Danger</h2>
|
||||
<div class="grid grid-cols-2 items-start gap-3">
|
||||
<p class="text-xs font-semibold text-m-muted">Default</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Default</p>
|
||||
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
|
||||
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:cancel-bold" icon-position="left" />
|
||||
<p class="text-xs font-semibold text-m-muted">Hover</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Hover</p>
|
||||
<MalioButton label="Supprimer" variant="danger" button-class="bg-m-btn-danger-hover" icon-name="mdi:trash" icon-position="left" />
|
||||
<MalioButton label="Supprimer" variant="danger" button-class="bg-m-btn-danger-hover" icon-name="mdi:cancel-bold" icon-position="left" />
|
||||
<p class="text-xs font-semibold text-m-muted">Active</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Active</p>
|
||||
<MalioButton label="Supprimer" variant="danger" button-class="bg-m-btn-danger-active" icon-name="mdi:trash" icon-position="left" />
|
||||
<MalioButton label="Supprimer" variant="danger" button-class="bg-m-btn-danger-active" icon-name="mdi:cancel-bold" icon-position="left" />
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled</p>
|
||||
<p class="text-xs font-semibold text-m-muted">Disabled</p>
|
||||
<MalioButton label="Supprimer" variant="danger" disabled icon-name="mdi:trash" icon-position="left" />
|
||||
<MalioButton label="Supprimer" variant="danger" disabled icon-name="mdi:cancel-bold" icon-position="left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Avec icône</h2>
|
||||
<div class="grid grid-cols-2 items-start gap-3">
|
||||
<MalioButton label="Valider" icon-name="mdi:check" />
|
||||
<MalioButton label="Icône à gauche" icon-name="mdi:arrow-left" icon-position="left" />
|
||||
<MalioButton label="Modifier" variant="secondary" icon-name="mdi:pencil-outline" />
|
||||
<MalioButton label="Annuler" variant="tertiary" icon-name="mdi:close" />
|
||||
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash-can-outline" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Largeur personnalisée</h2>
|
||||
<div class="grid grid-cols-2 items-start gap-3">
|
||||
<MalioButton label="Pleine largeur" button-class="w-full" />
|
||||
<MalioButton label="Compact" button-class="w-auto px-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,155 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<div class="flex gap-4">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left"
|
||||
aria-label="Retour"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
aria-label="Modifier"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Icônes variées</h2>
|
||||
<div class="flex gap-4">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:download"
|
||||
aria-label="Télécharger"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:bell-outline"
|
||||
aria-label="Notifications"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:cog-outline"
|
||||
aria-label="Paramètres"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:view-grid-outline"
|
||||
aria-label="Grille"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:format-list-bulleted"
|
||||
aria-label="Liste"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:folder-outline"
|
||||
aria-label="Dossier"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<div class="flex gap-4">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left"
|
||||
aria-label="Retour"
|
||||
disabled
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
aria-label="Modifier"
|
||||
disabled
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Taille personnalisée</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left"
|
||||
aria-label="Petit"
|
||||
:icon-size="16"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left"
|
||||
aria-label="Normal"
|
||||
:icon-size="24"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left"
|
||||
aria-label="Grand"
|
||||
:icon-size="32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Ghost</h2>
|
||||
<div class="flex gap-4">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left"
|
||||
aria-label="Retour"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
aria-label="Modifier"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:cancel-bold"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Ghost désactivé</h2>
|
||||
<div class="flex gap-4">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left"
|
||||
aria-label="Retour"
|
||||
variant="ghost"
|
||||
disabled
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
aria-label="Modifier"
|
||||
variant="ghost"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec événement click</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:plus"
|
||||
aria-label="Incrémenter"
|
||||
@click="counter++"
|
||||
/>
|
||||
<span class="text-lg font-semibold">{{ counter }}</span>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:minus"
|
||||
aria-label="Décrémenter"
|
||||
@click="counter--"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const counter = ref(0)
|
||||
</script>
|
||||
+1
-1
@@ -87,7 +87,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioCheckbox from '../../../../app/components/malio/checkbox/Checkbox.vue'
|
||||
import MalioCheckbox from '../../../app/components/malio/Checkbox.vue'
|
||||
const simpleValue = ref(false)
|
||||
const checkedValue = ref(true)
|
||||
const hintValue = ref(false)
|
||||
@@ -1,92 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const filtreNom = ref('')
|
||||
const filtreVille = ref<string | number | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ key: 'nom', label: 'Nom' },
|
||||
{ key: 'prenom', label: 'Prénom' },
|
||||
{ key: 'ville', label: 'Ville' },
|
||||
{ key: 'montant', label: 'Montant' },
|
||||
]
|
||||
|
||||
const allItems = [
|
||||
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
|
||||
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
|
||||
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
|
||||
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
|
||||
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
|
||||
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
|
||||
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
|
||||
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
|
||||
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
|
||||
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
|
||||
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
|
||||
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
|
||||
{ id: 13, nom: 'Roux', prenom: 'Hugo', ville: 'Paris', montant: 2800 },
|
||||
{ id: 14, nom: 'David', prenom: 'Léa', ville: 'Lyon', montant: 670 },
|
||||
{ id: 15, nom: 'Bertrand', prenom: 'Lucas', ville: 'Marseille', montant: 1950 },
|
||||
]
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return allItems.filter((item) => {
|
||||
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
|
||||
if (filtreVille.value && item.ville !== filtreVille.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (page.value - 1) * perPage.value
|
||||
return filteredItems.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">DataTable avec filtres et pagination</h2>
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="paginatedItems"
|
||||
:total-items="filteredItems.length"
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #header-nom>
|
||||
<input
|
||||
v-model="filtreNom"
|
||||
type="text"
|
||||
placeholder="Nom"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 outline-none text-[20px]"
|
||||
>
|
||||
</template>
|
||||
|
||||
<template #header-ville>
|
||||
<select
|
||||
:value="filtreVille ?? ''"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-[20px] outline-none"
|
||||
@change="filtreVille = ($event.target as HTMLSelectElement).value || null"
|
||||
>
|
||||
<option value="">Ville</option>
|
||||
<option value="Paris">Paris</option>
|
||||
<option value="Lyon">Lyon</option>
|
||||
<option value="Marseille">Marseille</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-montant="{ item }">
|
||||
<strong>{{ item.montant }} €</strong>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,132 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">MalioDate</h1>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[480px] space-y-3">
|
||||
<h2 class="font-semibold">Large (480px)</h2>
|
||||
<MalioDate
|
||||
v-model="value"
|
||||
label="Date de naissance"
|
||||
hint="Clique pour ouvrir le calendrier"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date (saisie clavier)"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
||||
@click="value = '2026-12-25'"
|
||||
>
|
||||
Forcer le 25/12/2026
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-1.5"
|
||||
@click="value = null"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">ERP (396px)</h2>
|
||||
<MalioDate
|
||||
v-model="erpValue"
|
||||
label="Date du rendez-vous"
|
||||
hint="Largeur cible ERP"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO) : <code>{{ erpValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDate
|
||||
v-model="bounded"
|
||||
label="Date bornée"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +30 jours"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">Readonly (readonly vide)</h2>
|
||||
<MalioDate
|
||||
label="Date de naissance (readonly vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">Readonly (readonly rempli)</h2>
|
||||
<MalioDate
|
||||
v-model="readonlyFilledDate"
|
||||
label="Date de naissance (readonly rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">markedDates + @month-change</h2>
|
||||
<MalioDate
|
||||
v-model="markedValue"
|
||||
label="Calendrier avec statuts par jour"
|
||||
hint="Jours verts = validés, rouges = à corriger"
|
||||
:marked-dates="markedDates"
|
||||
@month-change="onMonthChange"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Mois affiché : <code>{{ shownMonth }}</code></p>
|
||||
<p class="mt-1 text-m-success">● success : {{ successDays.join(', ') }}</p>
|
||||
<p class="text-m-danger">● danger : {{ dangerDays.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const readonlyFilledDate = ref<string | null>('2026-06-15')
|
||||
const value = ref<string | null>(null)
|
||||
const erpValue = ref<string | null>(null)
|
||||
const bounded = ref<string | null>(null)
|
||||
const editableValue = ref<string | null>(null)
|
||||
|
||||
// Démo markedDates : quelques jours du mois courant marqués success / danger.
|
||||
const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`
|
||||
const successDays = [`${ym}-05`, `${ym}-06`, `${ym}-12`]
|
||||
const dangerDays = [`${ym}-09`, `${ym}-20`]
|
||||
const markedDates = computed<Record<string, 'success' | 'danger'>>(() => ({
|
||||
...Object.fromEntries(successDays.map(d => [d, 'success' as const])),
|
||||
...Object.fromEntries(dangerDays.map(d => [d, 'danger' as const])),
|
||||
}))
|
||||
const markedValue = ref<string | null>(null)
|
||||
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
||||
const shownMonth = ref('—')
|
||||
const onMonthChange = ({month, year}: {month: number, year: number}) => {
|
||||
shownMonth.value = `${monthsLong[month]} ${year} (month=${month})`
|
||||
}
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">MalioDateRange</h1>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[480px] space-y-3">
|
||||
<h2 class="font-semibold">Large (480px)</h2>
|
||||
<MalioDateRange
|
||||
v-model="value"
|
||||
label="Période"
|
||||
hint="Clique deux fois pour définir une plage"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Début : <code>{{ value?.start ?? 'null' }}</code></p>
|
||||
<p>Fin : <code>{{ value?.end ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
||||
@click="value = {start: '2026-12-20', end: '2026-12-31'}"
|
||||
>
|
||||
Forcer 20→31/12/2026
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-1.5"
|
||||
@click="value = null"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">ERP (396px)</h2>
|
||||
<MalioDateRange
|
||||
v-model="erpValue"
|
||||
label="Période"
|
||||
hint="Largeur cible ERP"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Début : <code>{{ erpValue?.start ?? 'null' }}</code></p>
|
||||
<p>Fin : <code>{{ erpValue?.end ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDateRange
|
||||
v-model="bounded"
|
||||
label="Plage bornée"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +30 jours"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
type RangeValue = {start: string; end: string}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const value = ref<RangeValue | null>(null)
|
||||
const erpValue = ref<RangeValue | null>(null)
|
||||
const bounded = ref<RangeValue | null>(null)
|
||||
</script>
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">MalioDateWeek</h1>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[480px] space-y-3">
|
||||
<h2 class="font-semibold">Large (480px)</h2>
|
||||
<MalioDateWeek
|
||||
v-model="value"
|
||||
label="Semaine"
|
||||
hint="Clique un jour ou un n° de semaine"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur : <code>{{ value ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
||||
@click="value = '2026-W52'"
|
||||
>
|
||||
Forcer 2026-W52
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-1.5"
|
||||
@click="value = null"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">ERP (396px)</h2>
|
||||
<MalioDateWeek
|
||||
v-model="erpValue"
|
||||
label="Semaine"
|
||||
hint="Largeur cible ERP"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDateWeek
|
||||
v-model="bounded"
|
||||
label="Semaine bornée"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +60 jours"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
|
||||
|
||||
const value = ref<string | null>(null)
|
||||
const erpValue = ref<string | null>(null)
|
||||
const bounded = ref<string | null>(null)
|
||||
</script>
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">MalioDateTime</h1>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[480px] space-y-3">
|
||||
<h2 class="font-semibold">Large (480px)</h2>
|
||||
<MalioDateTime
|
||||
v-model="value"
|
||||
label="Date et heure du rendez-vous"
|
||||
hint="Choisis un jour puis une heure"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDateTime
|
||||
v-model="editableValue"
|
||||
label="Date et heure (saisie clavier)"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA HH:MM ou utilise le calendrier"
|
||||
@update:valid="editableValid = $event"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur éditable (ISO naïf) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||
<p>
|
||||
Saisie valide :
|
||||
<code :class="editableValid ? 'text-m-success' : 'text-m-danger'">{{ editableValid }}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
||||
@click="value = '2026-12-25T09:30:00'"
|
||||
>
|
||||
Forcer le 25/12/2026 09:30
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-1.5"
|
||||
@click="value = null"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">ERP (396px)</h2>
|
||||
<MalioDateTime
|
||||
v-model="erpValue"
|
||||
label="Date et heure du rendez-vous"
|
||||
hint="Largeur cible ERP"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO naïf) : <code>{{ erpValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDateTime
|
||||
v-model="bounded"
|
||||
label="Créneau borné"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +30 jours"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const value = ref<string | null>(null)
|
||||
const erpValue = ref<string | null>(null)
|
||||
const bounded = ref<string | null>('2026-05-20T14:30:00')
|
||||
const editableValue = ref<string | null>(null)
|
||||
const editableValid = ref(true)
|
||||
</script>
|
||||
@@ -1,294 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">Champs désactivés (disabled)</h1>
|
||||
<p class="text-sm text-m-muted">
|
||||
Tous les champs de formulaire dans leur état <code>disabled</code>, vides puis remplis.
|
||||
Règles : texte + label grisés, <code>cursor-not-allowed</code>, et <strong>aucune affordance
|
||||
interactive</strong> (pas de bouton « + », pas de croix « x », pas de chevron, pas d'œil ;
|
||||
tags et valeurs grisés).
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputText
|
||||
label="Référence (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputText
|
||||
model-value="Commande #A-2048"
|
||||
label="Référence (rempli)"
|
||||
icon-name="mdi:lock-outline"
|
||||
icon-size="20"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputEmail (addable → pas de « + »)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputEmail
|
||||
label="Adresse email (vide)"
|
||||
:addable="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
model-value="contact@malio.fr"
|
||||
label="Adresse email (rempli)"
|
||||
:addable="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputPhone (addable → pas de « + »)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputPhone
|
||||
label="Téléphone (vide)"
|
||||
:addable="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
label="Téléphone (rempli)"
|
||||
:addable="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputPassword (pas d'œil)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputPassword
|
||||
label="Mot de passe (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputPassword
|
||||
model-value="motdepasse123"
|
||||
label="Mot de passe (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputAmount
|
||||
label="Montant (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
model-value="1250.00"
|
||||
label="Montant (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputNumber</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputNumber
|
||||
label="Quantité (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputNumber
|
||||
model-value="42"
|
||||
label="Quantité (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputTextArea
|
||||
label="Description (vide)"
|
||||
:size="3"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
model-value="Ce texte est désactivé et ne peut pas être modifié."
|
||||
label="Description (rempli)"
|
||||
:size="3"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputUpload (pas de croix)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputUpload
|
||||
label="Fichier (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputUpload
|
||||
model-value="document.pdf"
|
||||
label="Fichier (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete (pas de chevron)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputAutocomplete
|
||||
label="Pays (vide)"
|
||||
:options="countryOptions"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputAutocomplete
|
||||
model-value="de"
|
||||
label="Pays (rempli)"
|
||||
:options="countryOptions"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioSelect (pas de chevron)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioSelect
|
||||
label="Catégorie (vide)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune sélection"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="'a'"
|
||||
label="Catégorie (rempli)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune sélection"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox — version tags (grisés)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioSelectCheckbox
|
||||
label="Catégories (vide)"
|
||||
:options="categoryOptions"
|
||||
:display-tag="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="['a', 'b', 'c']"
|
||||
label="Catégories (rempli)"
|
||||
:options="categoryOptions"
|
||||
:display-tag="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDate (pas de croix)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDate
|
||||
label="Date de naissance (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioDate
|
||||
model-value="2026-06-15"
|
||||
label="Date de naissance (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateTime
|
||||
label="Date et heure (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioDateTime
|
||||
model-value="2026-12-25T09:30:00"
|
||||
label="Date et heure (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateRange
|
||||
label="Période (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioDateRange
|
||||
:model-value="rangeValue"
|
||||
label="Période (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateWeek
|
||||
label="Semaine (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioDateWeek
|
||||
model-value="2026-W52"
|
||||
label="Semaine (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioTimePicker (pas de croix)</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioTimePicker
|
||||
label="Heure (vide)"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioTimePicker
|
||||
model-value="14:30"
|
||||
label="Heure (rempli)"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
type Option = {label: string; value: string | number}
|
||||
|
||||
const countryOptions: Option[] = [
|
||||
{label: 'France', value: 'fr'},
|
||||
{label: 'Belgique', value: 'be'},
|
||||
{label: 'Canada', value: 'ca'},
|
||||
{label: 'Suisse', value: 'ch'},
|
||||
{label: 'Luxembourg', value: 'lu'},
|
||||
{label: 'Allemagne', value: 'de'},
|
||||
]
|
||||
|
||||
const categoryOptions: Option[] = [
|
||||
{label: 'Catégorie A', value: 'a'},
|
||||
{label: 'Catégorie B', value: 'b'},
|
||||
{label: 'Catégorie C', value: 'c'},
|
||||
]
|
||||
|
||||
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
|
||||
</script>
|
||||
@@ -1,276 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">Champs en lecture seule (readonly)</h1>
|
||||
<p class="text-sm text-m-muted">
|
||||
Tous les champs de formulaire dans leur état <code>readonly</code>, vides puis remplis.
|
||||
Règles : bordure noire même vide, label et icône gris quand vide → noir quand rempli,
|
||||
pas de focus bleu ni de grossissement.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputText
|
||||
label="Référence (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputText
|
||||
model-value="Commande #A-2048"
|
||||
label="Référence (rempli)"
|
||||
icon-name="mdi:lock-outline"
|
||||
icon-size="20"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputEmail</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputEmail
|
||||
label="Adresse email (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
model-value="contact@malio.fr"
|
||||
label="Adresse email (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputAmount
|
||||
label="Montant (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
model-value="1250.00"
|
||||
label="Montant (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputAutocomplete
|
||||
label="Pays (vide)"
|
||||
:options="countryOptions"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputAutocomplete
|
||||
model-value="de"
|
||||
label="Pays (rempli)"
|
||||
icon-name="mdi:lock-outline"
|
||||
icon-position="left"
|
||||
:options="countryOptions"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputPassword</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputPassword
|
||||
label="Mot de passe (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputPassword
|
||||
model-value="motdepasse123"
|
||||
label="Mot de passe (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputTextArea
|
||||
label="Description (vide)"
|
||||
:size="3"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
model-value="Ce texte est en lecture seule et ne peut pas être modifié."
|
||||
label="Description (rempli)"
|
||||
:size="3"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputPhone</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputPhone
|
||||
label="Téléphone (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
label="Téléphone (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputUpload</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputUpload
|
||||
label="Fichier (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputUpload
|
||||
model-value="document.pdf"
|
||||
label="Fichier (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioSelect</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioSelect
|
||||
label="Catégorie (readonly vide)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="'a'"
|
||||
label="Catégorie (readonly rempli)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioSelectCheckbox
|
||||
label="Catégories (readonly vide)"
|
||||
:options="categoryOptions"
|
||||
:display-tag="true"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="['a']"
|
||||
label="Catégories (readonly rempli)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune selection"
|
||||
:display-tag="true"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDate</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDate
|
||||
label="Date de naissance (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDate
|
||||
model-value="2026-06-15"
|
||||
label="Date de naissance (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateTime
|
||||
label="Date et heure (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDateTime
|
||||
model-value="2026-12-25T09:30:00"
|
||||
label="Date et heure (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateRange
|
||||
label="Période (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDateRange
|
||||
:model-value="rangeValue"
|
||||
label="Période (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateWeek
|
||||
label="Semaine (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDateWeek
|
||||
model-value="2026-W52"
|
||||
label="Semaine (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioTimePicker</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioTimePicker
|
||||
label="Heure (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioTimePicker
|
||||
model-value="14:30"
|
||||
label="Heure (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
type Option = {label: string; value: string | number}
|
||||
|
||||
const countryOptions: Option[] = [
|
||||
{label: 'France', value: 'fr'},
|
||||
{label: 'Belgique', value: 'be'},
|
||||
{label: 'Canada', value: 'ca'},
|
||||
{label: 'Suisse', value: 'ch'},
|
||||
{label: 'Luxembourg', value: 'lu'},
|
||||
{label: 'Allemagne', value: 'de'},
|
||||
]
|
||||
|
||||
const categoryOptions: Option[] = [
|
||||
{label: 'Catégorie A', value: 'a'},
|
||||
{label: 'Catégorie B', value: 'b'},
|
||||
]
|
||||
|
||||
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
|
||||
</script>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const drawerRight = ref(false)
|
||||
const drawerLeft = ref(false)
|
||||
const drawerForm = ref(false)
|
||||
const drawerFixedFooter = ref(false)
|
||||
const drawerNoDismiss = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Drawer droite (défaut)</h2>
|
||||
<MalioButton label="Ouvrir à droite" @click="drawerRight = true" />
|
||||
<MalioDrawer v-model="drawerRight">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Détails</h2>
|
||||
</template>
|
||||
<p class="text-m-text">Contenu du drawer. Échap, clic backdrop et croix le ferment.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Drawer gauche</h2>
|
||||
<MalioButton label="Ouvrir à gauche" variant="secondary" @click="drawerLeft = true" />
|
||||
<MalioDrawer v-model="drawerLeft" side="left">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Navigation</h2>
|
||||
</template>
|
||||
<p class="text-m-text">Ce drawer glisse depuis la gauche.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
|
||||
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
||||
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<MalioInputText label="Nom" />
|
||||
<MalioInputText label="Prénom" />
|
||||
<MalioInputText label="Email" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
|
||||
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Footer fixe avec contenu long</h2>
|
||||
<MalioButton label="Ouvrir (contenu long)" variant="tertiary" @click="drawerFixedFooter = true" />
|
||||
<MalioDrawer v-model="drawerFixedFooter">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
||||
</template>
|
||||
<!-- Pas de hack : le footer est hors zone scrollable, seul le body défile -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<p v-for="n in 12" :key="n" class="text-m-text">
|
||||
Paragraphe {{ n }} — contenu long pour forcer le scroll et montrer que seul le body défile, le footer restant fixé en bas.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
|
||||
<MalioButton label="Ouvrir" variant="danger" @click="drawerNoDismiss = true" />
|
||||
<MalioDrawer v-model="drawerNoDismiss" :dismissable="false" :close-on-escape="false">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
|
||||
</template>
|
||||
<p class="text-m-text">Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="w-[1348px]">
|
||||
<div class="flex items-center justify-between mt-[46px]">
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
/>
|
||||
<h1 class="text-[32px] text-m-primary font-bold">Filtres</h1>
|
||||
</div>
|
||||
<MalioButton
|
||||
label="Filtres"
|
||||
variant="tertiary"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
button-class="w-[184px] px-2 py-2 justify-start text-black gap-4"
|
||||
@click="drawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MalioDrawer
|
||||
v-model="drawerOpen"
|
||||
side="right"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between gap-4 py-7"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">Filtres</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<MalioAccordionItem title="Type de camion" value="camion">
|
||||
<div class="flex flex-col gap-6">
|
||||
<MalioCheckbox v-model="semiBenne" label="Semi Benne" />
|
||||
<MalioCheckbox v-model="benne" label="Benne" />
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Date à Date" value="date">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||
<span>Du</span>
|
||||
<MalioDate v-model="dateDebut"/>
|
||||
<span>Au</span>
|
||||
<MalioDate v-model="dateFin"/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
label="Réinitialiser"
|
||||
variant="tertiary"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFiltres"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Voir les résultats"
|
||||
variant="primary"
|
||||
button-class="w-[170px]"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
const semiBenne = ref(false)
|
||||
const benne = ref(false)
|
||||
|
||||
const dateDebut = ref<string | null>(null)
|
||||
const dateFin = ref<string | null>(null)
|
||||
|
||||
function resetFiltres() {
|
||||
semiBenne.value = false
|
||||
benne.value = false
|
||||
dateDebut.value = null
|
||||
dateFin.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -1,323 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="w-[1348px]">
|
||||
<div class="flex gap-3 mt-[46px]">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
/>
|
||||
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
|
||||
</div>
|
||||
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
|
||||
<MalioInputText
|
||||
label="Nom du client (Entreprise)"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Nom du contact principal"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Prénom du contact principal"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-model="multiselectValue"
|
||||
error="test"
|
||||
label="Catégorie"
|
||||
:options="[
|
||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||
]"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-for="(_, index) in phones"
|
||||
:key="index"
|
||||
v-model="phones[index]"
|
||||
label="Téléphone"
|
||||
add-icon-name="mdi:plus"
|
||||
:addable="phones.length === 1"
|
||||
@add="addPhoneInput"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
label="Email"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="distributeur"
|
||||
value=""
|
||||
label="Distributeur / Courtier"
|
||||
:options="[
|
||||
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'},
|
||||
{label: 'Distributeur', value: 'Distributeur'},
|
||||
{label: 'Courtier', value: 'Courtier'},
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="nomCourtier"
|
||||
value=""
|
||||
label="Nom du courtier"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="nomDistributeur"
|
||||
value=""
|
||||
label="Nom du distributeur"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton label="Valider" variant="primary"/>
|
||||
</div>
|
||||
<div class="mt-[60px]">
|
||||
<MalioTabList :tabs="tabs" v-model="tabsValue">
|
||||
<template #information>
|
||||
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
|
||||
<MalioInputText v-model="concurrent" label="Concurrent"/>
|
||||
<MalioDate
|
||||
v-model="dateCreation"
|
||||
label="Date création"
|
||||
/>
|
||||
<MalioInputText label="Nombre de salariés" />
|
||||
<MalioInputAmount label="CA"/>
|
||||
<MalioInputText label="Dirigeant" />
|
||||
<MalioInputText label="Résultat" />
|
||||
</div>
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton label="Valider" variant="primary"/>
|
||||
</div>
|
||||
</template>
|
||||
<template #adresses>
|
||||
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer l'adresse"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@click="onDeleteAdresse"
|
||||
/>
|
||||
<MalioCheckbox label="Prospect" groupClass="self-center"/>
|
||||
<MalioCheckbox label="Adresse de livraison" groupClass="self-center"/>
|
||||
<MalioCheckbox label="Facturation" groupClass="self-center"/>
|
||||
<MalioSelectCheckbox
|
||||
v-model="multiselectValue"
|
||||
label="Catégorie"
|
||||
:options="[
|
||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
label="Pays"
|
||||
v-model="pays"
|
||||
:options="[
|
||||
{label: 'France', value: 'France'},
|
||||
{label: 'Espagne', value: 'Espagne'}
|
||||
]"/>
|
||||
<MalioInputText v-model="codePostal" label="Code postal" />
|
||||
<MalioSelect
|
||||
v-model="ville"
|
||||
label="Ville"
|
||||
:options="villeOptions"
|
||||
:no-options-text="villeNoOptionsText"
|
||||
/>
|
||||
<MalioInputAutocomplete
|
||||
v-model="adresse"
|
||||
label="Adresse"
|
||||
:options="adresseOptions"
|
||||
:loading="adresseLoading"
|
||||
:min-search-length="2"
|
||||
:no-results-text="adresseNoResultsText"
|
||||
:min-search-text="adresseMinSearchText"
|
||||
@search="onSearchAdresse"
|
||||
/>
|
||||
<MalioInputText label="Adresse complémentaire"/>
|
||||
<div class="flex justify-between">
|
||||
<MalioCheckbox
|
||||
v-for="dep in departements"
|
||||
:key="dep"
|
||||
v-model="departementsSelected[dep]"
|
||||
:label="dep"
|
||||
group-class="w-auto self-center"
|
||||
/>
|
||||
</div>
|
||||
<MalioSelect label="Contact" :options="[]"/>
|
||||
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||
</div>
|
||||
<div class="mt-12 flex justify-center gap-6">
|
||||
<MalioButton label="Nouvelle Adresse" variant="secondary" icon-name="mdi:add-bold" icon-position="left"/>
|
||||
<MalioButton label="Valider" variant="primary"/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import MalioDate from "../../../../app/components/malio/date/Date.vue";
|
||||
|
||||
type Commune = {
|
||||
nom: string
|
||||
code: string
|
||||
codesPostaux: string[]
|
||||
}
|
||||
|
||||
type BanFeature = {
|
||||
properties: {
|
||||
label: string
|
||||
id: string
|
||||
name: string
|
||||
housenumber?: string
|
||||
street?: string
|
||||
postcode: string
|
||||
citycode: string
|
||||
city: string
|
||||
}
|
||||
}
|
||||
|
||||
const multiselectValue = ref<Array<string | number>>([])
|
||||
const distributeur = ref<string>('')
|
||||
const phones = ref<string[]>([''])
|
||||
const nomDistributeur = ref<string>('')
|
||||
const nomCourtier = ref<string>('')
|
||||
|
||||
function addPhoneInput() {
|
||||
phones.value.push('')
|
||||
}
|
||||
|
||||
function onDeleteAdresse() {
|
||||
console.log('Supprimer cette adresse')
|
||||
}
|
||||
|
||||
const departements = ['86', '17', '82']
|
||||
const departementsSelected = ref<Record<string, boolean>>({86: false, 17: false, 82: false})
|
||||
|
||||
const pays = ref<string>('France')
|
||||
const codePostal = ref<string>('')
|
||||
const ville = ref<string | number | null>(null)
|
||||
const villeOptions = ref<Array<{label: string; value: string}>>([])
|
||||
const villeLoading = ref(false)
|
||||
|
||||
const villeNoOptionsText = computed(() => {
|
||||
if (villeLoading.value) return 'Chargement…'
|
||||
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir un code postal (5 chiffres)'
|
||||
return 'Aucune ville pour ce code postal'
|
||||
})
|
||||
|
||||
let villeFetchId = 0
|
||||
watch(codePostal, async (cp) => {
|
||||
ville.value = null
|
||||
villeOptions.value = []
|
||||
adresse.value = null
|
||||
adresseOptions.value = []
|
||||
if (!/^\d{5}$/.test(cp)) {
|
||||
villeLoading.value = false
|
||||
return
|
||||
}
|
||||
const requestId = ++villeFetchId
|
||||
villeLoading.value = true
|
||||
try {
|
||||
const response = await fetch(`https://geo.api.gouv.fr/communes?codePostal=${cp}`)
|
||||
const data = await response.json() as Commune[]
|
||||
if (requestId !== villeFetchId) return
|
||||
villeOptions.value = data.map(c => ({label: c.nom, value: c.code}))
|
||||
} catch (err) {
|
||||
if (requestId !== villeFetchId) return
|
||||
villeOptions.value = []
|
||||
console.error('Erreur lors du chargement des villes', err)
|
||||
} finally {
|
||||
if (requestId === villeFetchId) villeLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const adresse = ref<string | number | null>(null)
|
||||
const adresseOptions = ref<Array<{label: string; value: string}>>([])
|
||||
const adresseLoading = ref(false)
|
||||
|
||||
const adresseMinSearchText = computed(() => {
|
||||
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
|
||||
return 'Tapez au moins 3 caractères'
|
||||
})
|
||||
const adresseNoResultsText = computed(() => {
|
||||
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
|
||||
return 'Aucune adresse trouvée'
|
||||
})
|
||||
|
||||
let adresseFetchId = 0
|
||||
const onSearchAdresse = async (query: string) => {
|
||||
if (!/^\d{5}$/.test(codePostal.value) || query.length < 3) {
|
||||
adresseOptions.value = []
|
||||
adresseLoading.value = false
|
||||
return
|
||||
}
|
||||
const requestId = ++adresseFetchId
|
||||
adresseLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
postcode: codePostal.value,
|
||||
type: 'housenumber',
|
||||
})
|
||||
const response = await fetch(`https://api-adresse.data.gouv.fr/search/?${params.toString()}`)
|
||||
const data = await response.json() as {features: BanFeature[]}
|
||||
if (requestId !== adresseFetchId) return
|
||||
adresseOptions.value = data.features.map(f => ({
|
||||
label: f.properties.name,
|
||||
value: f.properties.name,
|
||||
}))
|
||||
} catch (err) {
|
||||
if (requestId !== adresseFetchId) return
|
||||
adresseOptions.value = []
|
||||
console.error('Erreur lors du chargement des adresses', err)
|
||||
} finally {
|
||||
if (requestId === adresseFetchId) adresseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tabsValue = ref('information')
|
||||
const concurrent = ref('')
|
||||
const dateCreation = ref<string | null>(null)
|
||||
|
||||
const informationValid = computed(() => concurrent.value.trim().length > 0)
|
||||
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
key: 'information',
|
||||
label: 'Information',
|
||||
icon: 'mdi:account-outline',
|
||||
},
|
||||
{
|
||||
key: 'contacts',
|
||||
label: 'Contacts',
|
||||
icon: 'mdi:account-box-plus-outline',
|
||||
disabled: !informationValid.value,
|
||||
},
|
||||
{
|
||||
key: 'adresses',
|
||||
label: 'Adresses',
|
||||
icon: 'mdi:map-marker-outline',
|
||||
disabled: !informationValid.value,
|
||||
},
|
||||
{
|
||||
key: 'transport',
|
||||
label: 'Transport',
|
||||
icon: 'mdi:truck-delivery-outline',
|
||||
disabled: !informationValid.value || !adressesValid.value,
|
||||
},
|
||||
{
|
||||
key: 'comptabilité',
|
||||
label: 'Comptabilité',
|
||||
icon: 'mdi:bank-circle-outline',
|
||||
disabled: !informationValid.value || !adressesValid.value,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
@@ -1,202 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple (statique)</h2>
|
||||
<MalioInputAutocomplete
|
||||
v-model="simpleValue"
|
||||
label="Pays"
|
||||
:options="staticOptions"
|
||||
local-filter
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec icône à gauche</h2>
|
||||
<MalioInputAutocomplete
|
||||
v-model="leftIconValue"
|
||||
label="Recherche"
|
||||
icon-name="mdi:magnify"
|
||||
icon-position="left"
|
||||
:options="staticOptions"
|
||||
local-filter
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 md:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Branché sur une API (simulé)</h2>
|
||||
<p class="mb-3 text-sm text-m-muted">
|
||||
Le parent écoute l'event <code>search</code> et alimente <code>options</code> + <code>loading</code>.
|
||||
Tapez au moins 2 caractères.
|
||||
</p>
|
||||
<MalioInputAutocomplete
|
||||
v-model="apiValue"
|
||||
label="Client"
|
||||
:options="apiOptions"
|
||||
:loading="apiLoading"
|
||||
:min-search-length="2"
|
||||
icon-name="mdi:magnify"
|
||||
icon-position="left"
|
||||
@search="onSearchApi"
|
||||
@select="onSelectApi"
|
||||
/>
|
||||
<p v-if="apiSelected" class="mt-2 text-sm text-m-muted">
|
||||
Sélection : <code>{{ apiSelected.label }} (id={{ apiSelected.value }})</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2>
|
||||
<MalioInputAutocomplete
|
||||
v-model="createValue"
|
||||
label="Catégorie"
|
||||
:options="staticOptions"
|
||||
allow-create
|
||||
hint="Taper Entrée pour créer une nouvelle valeur"
|
||||
@create="onCreate"
|
||||
/>
|
||||
<p v-if="createdItems.length > 0" class="mt-2 text-sm text-m-muted">
|
||||
Créés : {{ createdItems.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputAutocomplete
|
||||
model-value="fr"
|
||||
label="Pays"
|
||||
:options="staticOptions"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||
<MalioInputAutocomplete
|
||||
model-value="fr"
|
||||
label="Pays"
|
||||
:options="staticOptions"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputAutocomplete
|
||||
label="Pays (readonly vide)"
|
||||
:options="staticOptions"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputAutocomplete
|
||||
v-model="readonlyFilledAutocomplete"
|
||||
label="Pays (readonly rempli)"
|
||||
:options="staticOptions"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputAutocomplete
|
||||
v-model="hintValue"
|
||||
label="Pays"
|
||||
:options="staticOptions"
|
||||
hint="Sélectionne un pays dans la liste"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputAutocomplete
|
||||
model-value="fr"
|
||||
label="Pays"
|
||||
:options="staticOptions"
|
||||
error="Sélection invalide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputAutocomplete
|
||||
model-value="fr"
|
||||
label="Pays"
|
||||
:options="staticOptions"
|
||||
success="Sélection valide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Liste vide</h2>
|
||||
<MalioInputAutocomplete
|
||||
v-model="emptyValue"
|
||||
label="Recherche"
|
||||
:options="[]"
|
||||
no-results-text="Aucun élément disponible"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
type Option = {label: string; value: string | number}
|
||||
|
||||
const staticOptions: Option[] = [
|
||||
{label: 'France', value: 'fr'},
|
||||
{label: 'Belgique', value: 'be'},
|
||||
{label: 'Canada', value: 'ca'},
|
||||
{label: 'Suisse', value: 'ch'},
|
||||
{label: 'Luxembourg', value: 'lu'},
|
||||
{label: 'Allemagne', value: 'de'},
|
||||
{label: 'Espagne', value: 'es'},
|
||||
{label: 'Italie', value: 'it'},
|
||||
]
|
||||
|
||||
const readonlyFilledAutocomplete = ref<string | number | null>('de')
|
||||
const simpleValue = ref<string | number | null>(null)
|
||||
const leftIconValue = ref<string | number | null>(null)
|
||||
const createValue = ref<string | number | null>(null)
|
||||
const hintValue = ref<string | number | null>(null)
|
||||
const emptyValue = ref<string | number | null>(null)
|
||||
|
||||
const createdItems = ref<string[]>([])
|
||||
const onCreate = (value: string) => {
|
||||
createdItems.value.push(value)
|
||||
}
|
||||
|
||||
const apiValue = ref<string | number | null>(null)
|
||||
const apiOptions = ref<Option[]>([])
|
||||
const apiLoading = ref(false)
|
||||
const apiSelected = ref<Option | null>(null)
|
||||
|
||||
const fakeClients: Option[] = [
|
||||
{label: 'Yuno Malio', value: 1},
|
||||
{label: 'Yuna Corp', value: 2},
|
||||
{label: 'Yum Foods', value: 3},
|
||||
{label: 'Yumi Studio', value: 4},
|
||||
{label: 'Acme Inc.', value: 5},
|
||||
{label: 'Globex Corp', value: 6},
|
||||
{label: 'Initech', value: 7},
|
||||
{label: 'Soylent Corp', value: 8},
|
||||
]
|
||||
|
||||
const onSearchApi = async (query: string) => {
|
||||
apiLoading.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
apiOptions.value = fakeClients.filter(c =>
|
||||
c.label.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
apiLoading.value = false
|
||||
}
|
||||
|
||||
const onSelectApi = (option: Option | null) => {
|
||||
apiSelected.value = option
|
||||
}
|
||||
</script>
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioInputEmail />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||
<MalioInputEmail
|
||||
v-model="emailValue"
|
||||
label="Adresse email"
|
||||
name="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
|
||||
<div class="space-y-3">
|
||||
<MalioInputEmail
|
||||
v-for="(email, index) in emails"
|
||||
:key="index"
|
||||
v-model="emails[index]"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="emails.push('')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
|
||||
<MalioInputEmail
|
||||
label="Adresse email"
|
||||
icon-position="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputEmail
|
||||
label="Adresse email"
|
||||
:icon-name="''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputEmail
|
||||
model-value="contact@malio.fr"
|
||||
disabled
|
||||
label="Adresse email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||
<MalioInputEmail
|
||||
model-value="readonly@malio.fr"
|
||||
readonly
|
||||
label="Adresse email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputEmail
|
||||
label="Adresse email (readonly vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputEmail
|
||||
v-model="readonlyFilledEmail"
|
||||
label="Adresse email (readonly rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputEmail
|
||||
label="Adresse email"
|
||||
hint="ex: prenom.nom@malio.fr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputEmail
|
||||
model-value="pas-un-email"
|
||||
label="Adresse email"
|
||||
error="Adresse email invalide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputEmail
|
||||
model-value="contact@malio.fr"
|
||||
label="Adresse email"
|
||||
success="Adresse email valide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Validation dynamique</h2>
|
||||
<MalioInputEmail
|
||||
v-model="dynamicEmail"
|
||||
label="Adresse email"
|
||||
hint="Saisir une adresse au format prenom@domaine.tld"
|
||||
:error="dynamicError"
|
||||
:success="dynamicSuccess"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Email obligatoire</h2>
|
||||
<MalioInputEmail
|
||||
v-model="requiredEmail"
|
||||
label="Email obligatoire"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Email normalisé (minuscules)</h2>
|
||||
<MalioInputEmail
|
||||
v-model="lowercaseEmail"
|
||||
label="Email normalisé (minuscules)"
|
||||
:lowercase="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const readonlyFilledEmail = ref('contact@malio.fr')
|
||||
const emailValue = ref('')
|
||||
const emails = ref<string[]>([''])
|
||||
const dynamicEmail = ref('')
|
||||
const requiredEmail = ref('')
|
||||
const lowercaseEmail = ref('')
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
|
||||
const dynamicError = computed(() => {
|
||||
if (!dynamicEmail.value) return ''
|
||||
return isDynamicValid.value ? '' : 'Adresse email invalide'
|
||||
})
|
||||
const dynamicSuccess = computed(() => {
|
||||
if (!dynamicEmail.value) return ''
|
||||
return isDynamicValid.value ? 'Adresse email valide' : ''
|
||||
})
|
||||
</script>
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioInputPassword />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||
<MalioInputPassword
|
||||
v-model="passwordValue"
|
||||
label="Mot de passe"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputPassword
|
||||
label="Mot de passe"
|
||||
:display-icon="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputPassword
|
||||
model-value="motdepasse123"
|
||||
disabled
|
||||
label="Mot de passe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||
<MalioInputPassword
|
||||
model-value="lectureseule"
|
||||
readonly
|
||||
label="Mot de passe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputPassword
|
||||
label="Mot de passe (readonly vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputPassword
|
||||
v-model="readonlyFilledPassword"
|
||||
label="Mot de passe (readonly rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputPassword
|
||||
label="Mot de passe"
|
||||
hint="8 caractères minimum"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputPassword
|
||||
model-value="abc"
|
||||
label="Mot de passe"
|
||||
error="Le mot de passe est trop court"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputPassword
|
||||
model-value="motdepasse123"
|
||||
label="Mot de passe"
|
||||
success="Mot de passe valide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Validation dynamique</h2>
|
||||
<MalioInputPassword
|
||||
v-model="dynamicPassword"
|
||||
label="Mot de passe"
|
||||
hint="8 caractères minimum, 1 majuscule, 1 chiffre"
|
||||
:error="dynamicError"
|
||||
:success="dynamicSuccess"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const readonlyFilledPassword = ref('motdepasse123')
|
||||
const passwordValue = ref('')
|
||||
const dynamicPassword = ref('')
|
||||
|
||||
const passwordRegex = /^(?=.*[A-Z])(?=.*\d).{8,}$/
|
||||
const isDynamicValid = computed(() => passwordRegex.test(dynamicPassword.value))
|
||||
const dynamicError = computed(() => {
|
||||
if (!dynamicPassword.value) return ''
|
||||
return isDynamicValid.value ? '' : 'Mot de passe invalide'
|
||||
})
|
||||
const dynamicSuccess = computed(() => {
|
||||
if (!dynamicPassword.value) return ''
|
||||
return isDynamicValid.value ? 'Mot de passe valide' : ''
|
||||
})
|
||||
</script>
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioInputPhone />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||
<MalioInputPhone
|
||||
v-model="phoneValue"
|
||||
label="Téléphone"
|
||||
name="phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputPhone
|
||||
v-model="phoneAddable"
|
||||
label="Téléphone"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Icône à droite (sans bouton +)</h2>
|
||||
<MalioInputPhone
|
||||
label="Téléphone"
|
||||
icon-position="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputPhone
|
||||
label="Téléphone"
|
||||
:icon-name="''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec masque français</h2>
|
||||
<MalioInputPhone
|
||||
v-model="phoneFrench"
|
||||
label="Téléphone (FR)"
|
||||
mask="+33 # ## ## ## ##"
|
||||
hint="Saisir uniquement les chiffres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé (avec addable)</h2>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
addable
|
||||
disabled
|
||||
label="Téléphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (avec addable)</h2>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
addable
|
||||
readonly
|
||||
label="Téléphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputPhone
|
||||
label="Téléphone (readonly vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputPhone
|
||||
v-model="readonlyFilledPhone"
|
||||
label="Téléphone (readonly rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputPhone
|
||||
label="Téléphone"
|
||||
hint="Format international recommandé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputPhone
|
||||
model-value="abc"
|
||||
label="Téléphone"
|
||||
error="Numéro de téléphone invalide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
label="Téléphone"
|
||||
success="Numéro valide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 md:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Cas ERP — liste de téléphones (max 2)</h2>
|
||||
<p class="mb-3 text-sm text-m-muted">
|
||||
Le bouton + s'affiche sur le dernier champ tant que la liste contient moins de {{ MAX_PHONES }} numéros.
|
||||
</p>
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioInputPhone
|
||||
v-for="(phone, index) in phones"
|
||||
:key="index"
|
||||
v-model="phones[index]"
|
||||
:label="`Téléphone ${index + 1}`"
|
||||
:addable="index === phones.length - 1 && phones.length < MAX_PHONES"
|
||||
@add="addPhone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const readonlyFilledPhone = ref('+33 6 12 34 56 78')
|
||||
const phoneValue = ref('')
|
||||
const phoneAddable = ref('')
|
||||
const phoneFrench = ref('')
|
||||
const addClicks = ref(0)
|
||||
|
||||
const onAdd = () => {
|
||||
addClicks.value++
|
||||
}
|
||||
|
||||
const MAX_PHONES = 2
|
||||
const phones = ref<string[]>([''])
|
||||
|
||||
const addPhone = () => {
|
||||
if (phones.value.length < MAX_PHONES) {
|
||||
phones.value.push('')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 p-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioInputRichText
|
||||
v-model="simpleValue"
|
||||
label="Note"
|
||||
placeholder="Écrire ici…"
|
||||
/>
|
||||
<pre class="mt-3 overflow-auto rounded bg-m-bg p-2 text-xs">{{ simpleValue }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec contenu initial + hint</h2>
|
||||
<MalioInputRichText
|
||||
v-model="hintValue"
|
||||
label="Description"
|
||||
hint="Tu peux mettre en forme avec la barre d'outils"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputRichText
|
||||
v-model="errorValue"
|
||||
label="Compte-rendu"
|
||||
error="Le compte-rendu doit faire au moins 20 caractères"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputRichText
|
||||
v-model="successValue"
|
||||
label="Compte-rendu"
|
||||
success="Compte-rendu validé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||
<MalioInputRichText
|
||||
v-model="readonlyValue"
|
||||
label="Note (lecture seule)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Disabled</h2>
|
||||
<MalioInputRichText
|
||||
v-model="disabledValue"
|
||||
label="Note (désactivée)"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Affichage seul (editable=false)</h2>
|
||||
<MalioInputRichText
|
||||
:model-value="readonlyValue"
|
||||
:editable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sortie HTML</h2>
|
||||
<MalioInputRichText
|
||||
v-model="htmlValue"
|
||||
label="Article"
|
||||
output-format="html"
|
||||
min-height="200px"
|
||||
placeholder="Tape ici, la sortie sera en HTML…"
|
||||
/>
|
||||
<pre class="mt-3 overflow-auto rounded bg-m-bg p-2 text-xs">{{ htmlValue }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioInputRichText from '../../../../app/components/malio/input/InputRichText.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const hintValue = ref('## Titre\n\nUn paragraphe avec du **gras**, de l\'*italique* et un [lien](https://malio.fr).')
|
||||
const errorValue = ref('Trop court')
|
||||
const successValue = ref('Tout est bon de mon côté.')
|
||||
const readonlyValue = ref('## Compte-rendu\n\n- Point 1\n- Point 2\n\n> Citation importante')
|
||||
const disabledValue = ref('Contenu indisponible.')
|
||||
const htmlValue = ref('<p>Contenu <strong>riche</strong>.</p>')
|
||||
</script>
|
||||
@@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioInputUpload label="Fichier" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec label et v-model</h2>
|
||||
<MalioInputUpload
|
||||
v-model="uploadValue"
|
||||
label="Téléverser un document"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Clearable (croix pour vider)</h2>
|
||||
<MalioInputUpload
|
||||
v-model="clearableUpload"
|
||||
label="Téléverser un document"
|
||||
clearable
|
||||
@clear="onClearUpload"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ clearableUpload || '(aucun)' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
||||
<MalioInputUpload
|
||||
label="Document PDF"
|
||||
accept=".pdf"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputUpload
|
||||
model-value="document.pdf"
|
||||
disabled
|
||||
label="Fichier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputUpload
|
||||
label="Fichier (readonly vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputUpload
|
||||
v-model="readonlyFilledUpload"
|
||||
label="Fichier (readonly rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputUpload
|
||||
label="Fichier"
|
||||
hint="Formats acceptés : PDF, DOC, DOCX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputUpload
|
||||
model-value="image.bmp"
|
||||
label="Fichier"
|
||||
error="Format non supporté"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputUpload
|
||||
model-value="rapport.pdf"
|
||||
label="Fichier"
|
||||
success="Fichier valide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Validation dynamique</h2>
|
||||
<MalioInputUpload
|
||||
v-model="dynamicUpload"
|
||||
label="Document PDF"
|
||||
accept=".pdf"
|
||||
hint="Seuls les fichiers PDF sont acceptés"
|
||||
:error="dynamicError"
|
||||
:success="dynamicSuccess"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const readonlyFilledUpload = ref('document.pdf')
|
||||
const uploadValue = ref('')
|
||||
const dynamicUpload = ref('')
|
||||
const clearableUpload = ref('rapport-2026.pdf')
|
||||
|
||||
const onClearUpload = () => {
|
||||
clearableUpload.value = ''
|
||||
}
|
||||
|
||||
const dynamicError = computed(() => {
|
||||
if (!dynamicUpload.value) return ''
|
||||
return dynamicUpload.value.endsWith('.pdf') ? '' : 'Seuls les fichiers PDF sont acceptés'
|
||||
})
|
||||
const dynamicSuccess = computed(() => {
|
||||
if (!dynamicUpload.value) return ''
|
||||
return dynamicUpload.value.endsWith('.pdf') ? 'Fichier PDF valide' : ''
|
||||
})
|
||||
</script>
|
||||
-32
@@ -14,17 +14,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<div class="mt-2 rounded border p-3 text-sm">
|
||||
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputAmount
|
||||
@@ -47,23 +36,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputAmount
|
||||
label="Montant (readonly vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="readonlyFilledAmount"
|
||||
label="Montant (readonly rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
|
||||
<div class="mt-4">
|
||||
@@ -85,8 +57,4 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const readonlyFilledAmount = ref('1250.00')
|
||||
const bigValue = ref('1234567.89')
|
||||
</script>
|
||||
-28
@@ -108,33 +108,6 @@
|
||||
icon-size="20"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputText
|
||||
label="Référence (readonly vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputText
|
||||
v-model="readonlyFilledValue"
|
||||
label="Référence (readonly rempli)"
|
||||
icon-name="mdi:lock-outline"
|
||||
icon-size="20"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Champ obligatoire</h2>
|
||||
<MalioInputText
|
||||
label="Champ obligatoire"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
|
||||
<MalioInputText
|
||||
@@ -181,7 +154,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
const readonlyFilledValue = ref('Commande #A-2048')
|
||||
const nameValue = ref('')
|
||||
const searchValue = ref('')
|
||||
const codeValue = ref('')
|
||||
+1
-21
@@ -61,25 +61,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioInputTextArea
|
||||
label="Description (readonly vide)"
|
||||
:readonly="true"
|
||||
:size="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioInputTextArea
|
||||
v-model="readonlyFilledTextArea"
|
||||
label="Description (readonly rempli)"
|
||||
:readonly="true"
|
||||
:size="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Resize avec limites</h2>
|
||||
<MalioInputTextArea
|
||||
@@ -111,9 +92,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioInputTextArea from '../../../../app/components/malio/input/InputTextArea.vue'
|
||||
import MalioInputTextArea from '../../../app/components/malio/InputTextArea.vue'
|
||||
|
||||
const readonlyFilledTextArea = ref('Ce texte est en lecture seule et ne peut pas être modifié.')
|
||||
const hintValue = ref('')
|
||||
const iconValue = ref('')
|
||||
const errorValue = ref('abc')
|
||||
@@ -1,74 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MalioButton from "../../../../app/components/malio/button/Button.vue";
|
||||
|
||||
const modalBase = ref(false)
|
||||
const modalForm = ref(false)
|
||||
const modalLong = ref(false)
|
||||
const modalNoDismiss = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Modal simple</h2>
|
||||
<MalioButton label="Ouvrir" @click="modalBase = true" />
|
||||
<MalioModal v-model="modalBase" headerClass="py-7 px-[25px]" footerClass="flex justify-center pt-8">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Marquer comme vu ?</h2>
|
||||
</template>
|
||||
<template #footer>
|
||||
<MalioButton label="Valider"/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
|
||||
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="modalForm = true" />
|
||||
<MalioModal v-model="modalForm" modal-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<MalioInputText label="Nom" />
|
||||
<MalioInputText label="Prénom" />
|
||||
<MalioInputText label="Email" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="modalForm = false" />
|
||||
<MalioButton label="Enregistrer" button-class="flex-1" @click="modalForm = false" />
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Contenu long (body scrollable)</h2>
|
||||
<MalioButton label="Ouvrir" variant="tertiary" @click="modalLong = true" />
|
||||
<MalioModal v-model="modalLong">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p v-for="n in 20" :key="n" class="text-m-text">
|
||||
Paragraphe {{ n }} — contenu long pour forcer le scroll interne ; le header et le footer restent fixes.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton label="Accepter" button-class="w-full" @click="modalLong = false" />
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
|
||||
<MalioButton label="Ouvrir" variant="danger" @click="modalNoDismiss = true" />
|
||||
<MalioModal v-model="modalNoDismiss" :dismissable="false" :close-on-escape="false">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
|
||||
</template>
|
||||
<p class="text-m-text">Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -93,7 +93,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioRadioButton from '../../../../app/components/malio/radio/RadioButton.vue'
|
||||
import MalioRadioButton from '../../../app/components/malio/RadioButton.vue'
|
||||
|
||||
const options = [
|
||||
{label: 'Option 1', value: 'option1'},
|
||||
-52
@@ -82,49 +82,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sélection obligatoire</h2>
|
||||
<MalioSelect
|
||||
v-model="requiredValue"
|
||||
:options="options"
|
||||
label="Sélection obligatoire"
|
||||
:required="true"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
|
||||
<MalioSelect
|
||||
v-model="shortListValue"
|
||||
:options="shortOptions"
|
||||
label="Civilite"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
|
||||
<MalioSelect
|
||||
v-model="readonlyEmptyValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
|
||||
<MalioSelect
|
||||
v-model="readonlyFilledValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 md:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
||||
<MalioSelect
|
||||
@@ -164,11 +121,6 @@ const options = [
|
||||
{label: 'Portugal', value: 'pt'},
|
||||
]
|
||||
|
||||
const shortOptions = [
|
||||
{label: 'Monsieur', value: 'M'},
|
||||
{label: 'Madame', value: 'Mme'},
|
||||
]
|
||||
|
||||
const longOptions = [
|
||||
...options,
|
||||
{label: 'Pays-Bas', value: 'nl'},
|
||||
@@ -184,7 +136,6 @@ const longOptions = [
|
||||
{label: 'Republique tcheque', value: 'cz'},
|
||||
]
|
||||
|
||||
const requiredValue = ref<string | number | null>(null)
|
||||
const basicValue = ref<string | number | null>(null)
|
||||
const labelValue = ref<string | number | null>(null)
|
||||
const selectedValue = ref<string | number | null>('fr')
|
||||
@@ -193,9 +144,6 @@ const errorValue = ref<string | number | null>(null)
|
||||
const successValue = ref<string | number | null>('be')
|
||||
const disabledValue = ref<string | number | null>('ca')
|
||||
const emptyValue = ref<string | number | null>(null)
|
||||
const shortListValue = ref<string | number | null>(null)
|
||||
const longListValue = ref<string | number | null>(null)
|
||||
const bottomValue = ref<string | number | null>(null)
|
||||
const readonlyEmptyValue = ref<string | number | null>(null)
|
||||
const readonlyFilledValue = ref<string | number | null>('fr')
|
||||
</script>
|
||||
@@ -1,218 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="basicValue"
|
||||
:options="options"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec tag</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="labelValue"
|
||||
:options="options"
|
||||
:display-tag="true"
|
||||
empty-option-label=" "
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec tag + label</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="labelValue1"
|
||||
:options="options"
|
||||
:display-tag="true"
|
||||
label="Pays"
|
||||
empty-option-label=" "
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="labelValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Valeur preselectionnee</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Hint</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="hintValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
hint="Choisissez votre pays"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="errorValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
error="Ce champ est obligatoire"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succes</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="successValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
success="Selection validee"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Desactive</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="disabledValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
disabled
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans options</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="emptyValue"
|
||||
label="Pays"
|
||||
empty-option-label="Aucun pays disponible"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Tout sélectionner</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectAllValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
:display-select-all="true"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Tout sélectionner (label custom)</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectAllCustomValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
:display-select-all="true"
|
||||
select-all-label="Cocher tout"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="readonlyEmptyValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="readonlyFilledValue"
|
||||
:options="options"
|
||||
label="Pays"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 md:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
||||
<MalioSelectCheckbox
|
||||
v-model="longListValue"
|
||||
:options="longOptions"
|
||||
label="Pays"
|
||||
hint="Permet de verifier la scrollbar"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 md:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Ouverture en bas de page</h2>
|
||||
<div class="h-64" />
|
||||
<MalioSelectCheckbox
|
||||
v-model="bottomValue"
|
||||
:options="longOptions"
|
||||
label="Ouverture adaptative"
|
||||
hint="A ouvrir pres du bas de la page"
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const options = [
|
||||
{label: 'France', value: 'fr'},
|
||||
{label: 'Belgique', value: 'be'},
|
||||
{label: 'Suisse', value: 'ch'},
|
||||
{label: 'Canada', value: 'ca'},
|
||||
{label: 'Allemagne', value: 'de'},
|
||||
{label: 'Espagne', value: 'es'},
|
||||
{label: 'Italie', value: 'it'},
|
||||
{label: 'Portugal', value: 'pt'},
|
||||
]
|
||||
|
||||
const longOptions = [
|
||||
...options,
|
||||
{label: 'Pays-Bas', value: 'nl'},
|
||||
{label: 'Suede', value: 'se'},
|
||||
{label: 'Norvege', value: 'no'},
|
||||
{label: 'Danemark', value: 'dk'},
|
||||
{label: 'Finlande', value: 'fi'},
|
||||
{label: 'Autriche', value: 'at'},
|
||||
{label: 'Irlande', value: 'ie'},
|
||||
{label: 'Grece', value: 'gr'},
|
||||
{label: 'Pologne', value: 'pl'},
|
||||
{label: 'Hongrie', value: 'hu'},
|
||||
{label: 'Republique tcheque', value: 'cz'},
|
||||
]
|
||||
|
||||
const basicValue = ref<Array<string | number>>([])
|
||||
const labelValue = ref<Array<string | number>>([])
|
||||
const labelValue1 = ref<Array<string | number>>([])
|
||||
const selectedValue = ref<Array<string | number>>(['fr'])
|
||||
const hintValue = ref<Array<string | number>>([])
|
||||
const errorValue = ref<Array<string | number>>([])
|
||||
const successValue = ref<Array<string | number>>(['be'])
|
||||
const disabledValue = ref<Array<string | number>>(['ca'])
|
||||
const emptyValue = ref<Array<string | number>>([])
|
||||
const selectAllValue = ref<Array<string | number>>([])
|
||||
const selectAllCustomValue = ref<Array<string | number>>([])
|
||||
const longListValue = ref<Array<string | number>>([])
|
||||
const bottomValue = ref<Array<string | number>>([])
|
||||
const readonlyEmptyValue = ref<Array<string | number>>([])
|
||||
const readonlyFilledValue = ref<Array<string | number>>(['fr'])
|
||||
</script>
|
||||
@@ -1,131 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-8" style="height: calc(100vh - 100px)">
|
||||
<MalioSidebar
|
||||
v-model="collapsed1"
|
||||
:sections="sectionsShort"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio" />
|
||||
</template>
|
||||
<template #logo-collapsed>
|
||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
|
||||
</template>
|
||||
</MalioSidebar>
|
||||
|
||||
<MalioSidebar
|
||||
v-model="collapsed2"
|
||||
:sections="sectionsLong"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio" />
|
||||
</template>
|
||||
<template #logo-collapsed>
|
||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
|
||||
</template>
|
||||
</MalioSidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const collapsed1 = ref(false)
|
||||
const collapsed2 = ref(false)
|
||||
|
||||
const sectionsShort = [
|
||||
{
|
||||
label: 'LOGISTIQUE / TRANSPORT',
|
||||
icon: 'mdi:truck-delivery',
|
||||
items: [
|
||||
{label: 'Réception / Expédition', to: '/reception'},
|
||||
{label: 'Validation expédition', to: '/validation'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'COMMERCIAL',
|
||||
icon: 'mdi:handshake',
|
||||
items: [
|
||||
{label: 'Répertoire fournisseurs', to: '/fournisseurs'},
|
||||
{label: 'Répertoire clients', to: '/clients'},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const sectionsLong = [
|
||||
{
|
||||
label: 'LOGISTIQUE / TRANSPORT',
|
||||
icon: 'mdi:truck-delivery',
|
||||
items: [
|
||||
{label: 'Réception / Expédition', to: '/reception'},
|
||||
{label: 'Validation expédition', to: '/validation'},
|
||||
{label: 'Voyage', to: '/voyage'},
|
||||
{label: 'Ticket de pesée', to: '/pesee'},
|
||||
{label: 'Bon de réception', to: '/bon-reception'},
|
||||
{label: "Bon d'expédition", to: '/bon-expedition'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'USINE / PRODUCTION',
|
||||
icon: 'mdi:factory',
|
||||
items: [
|
||||
{label: 'Fabrication en cours', to: '/fabrication'},
|
||||
{label: 'Liste des fabrications', to: '/fabrications'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'COMMERCIAL',
|
||||
icon: 'mdi:handshake',
|
||||
items: [
|
||||
{label: 'Répertoire fournisseurs', to: '/fournisseurs'},
|
||||
{label: 'Compagnie fournisseurs', to: '/compagnie-fournisseurs'},
|
||||
{label: 'Répertoire clients', to: '/clients'},
|
||||
{label: 'Contrats en cours', to: '/contrats'},
|
||||
{label: 'Commissions Clients', to: '/commissions'},
|
||||
{label: 'Attribution expédition', to: '/attribution'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'PRIX',
|
||||
icon: 'mdi:tag',
|
||||
items: [
|
||||
{label: "Prix d'achat/vente", to: '/prix-achat'},
|
||||
{label: "Prix d'achat spécifiques", to: '/prix-specifiques'},
|
||||
{label: 'Prix de ventes clients', to: '/prix-vente'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'FACTURATION',
|
||||
icon: 'mdi:receipt',
|
||||
items: [
|
||||
{label: 'Expéditions à facturer', to: '/expeditions-facturer'},
|
||||
{label: 'Factures', to: '/factures'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'TECHNIQUE',
|
||||
icon: 'mdi:cog',
|
||||
items: [
|
||||
{label: 'Répertoire prestataires', to: '/prestataires'},
|
||||
{label: 'Répertoire transporteurs', to: '/transporteurs'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SUIVI HEURES',
|
||||
icon: 'mdi:clock-outline',
|
||||
items: [
|
||||
{label: 'Heure Usine', to: '/heure-usine'},
|
||||
{label: 'Heure Extras', to: '/heure-extras'},
|
||||
{label: 'Heure Ferme', to: '/heure-ferme'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ADMINISTRATION',
|
||||
icon: 'mdi:shield-account',
|
||||
items: [
|
||||
{label: 'Catalogue produits', to: '/catalogue'},
|
||||
{label: 'Éditer étiquettes', to: '/etiquettes'},
|
||||
{label: 'Organisation catégorie', to: '/organisation'},
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple (3 sites) + event change</h2>
|
||||
<MalioSiteSelector v-model="simpleValue" :sites="sites" @change="onSiteChange" />
|
||||
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ simpleValue }}</code></p>
|
||||
<p class="mt-1 text-sm text-gray-600">Dernier event <code>change</code> : <code>{{ lastChange }}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Deux sites</h2>
|
||||
<MalioSiteSelector v-model="twoValue" :sites="sitesTwo" />
|
||||
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ twoValue }}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Cinq sites (largeur proportionnelle)</h2>
|
||||
<MalioSiteSelector v-model="fiveValue" :sites="sitesFive" />
|
||||
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ fiveValue }}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Non contrôlé (sans v-model)</h2>
|
||||
<MalioSiteSelector :sites="sites" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Largeur contrainte</h2>
|
||||
<div class="w-[480px]">
|
||||
<MalioSiteSelector v-model="constrainedValue" :sites="sites" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const sites = [
|
||||
{ id: 'chatellerault', name: 'Châtellerault', color: '#0055ff' },
|
||||
{ id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a' },
|
||||
{ id: 'pommevic', name: 'Pommevic', color: '#dc2626' },
|
||||
]
|
||||
|
||||
const sitesTwo = [
|
||||
{ id: 'nord', name: 'Usine Nord', color: '#7c3aed' },
|
||||
{ id: 'sud', name: 'Usine Sud', color: '#ea580c' },
|
||||
]
|
||||
|
||||
const sitesFive = [
|
||||
{ id: 's1', name: 'Site 1', color: '#0ea5e9' },
|
||||
{ id: 's2', name: 'Site 2', color: '#14b8a6' },
|
||||
{ id: 's3', name: 'Site 3', color: '#f59e0b' },
|
||||
{ id: 's4', name: 'Site 4', color: '#ec4899' },
|
||||
{ id: 's5', name: 'Site 5', color: '#6366f1' },
|
||||
]
|
||||
|
||||
const simpleValue = ref('chatellerault')
|
||||
const twoValue = ref('nord')
|
||||
const fiveValue = ref('s3')
|
||||
const constrainedValue = ref('saint-jean')
|
||||
const lastChange = ref<string>('—')
|
||||
|
||||
function onSiteChange(site: { id: string; name: string; color: string }) {
|
||||
lastChange.value = JSON.stringify(site)
|
||||
}
|
||||
</script>
|
||||
@@ -1,202 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-1 text-xl font-bold">Bac à sable (tous les cas)</h2>
|
||||
<p class="mb-4 text-sm text-m-muted">
|
||||
Règle les paramètres puis <strong>redimensionne le cadre en pointillés</strong>
|
||||
(poignée en bas à droite) pour voir le nombre d'onglets s'adapter et les flèches apparaître.
|
||||
</p>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-end gap-4 text-sm">
|
||||
<label class="flex flex-col gap-1">Nb onglets : {{ sbCount }}
|
||||
<input
|
||||
v-model.number="sbCount"
|
||||
type="range"
|
||||
min="1"
|
||||
max="15"
|
||||
class="w-40"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">maxVisibleTabs (0 = auto)
|
||||
<input
|
||||
v-model.number="sbMax"
|
||||
type="number"
|
||||
min="0"
|
||||
max="15"
|
||||
class="w-20 rounded border px-2 py-1"
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="sbIcons"
|
||||
type="checkbox"
|
||||
> Icônes
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="sbLong"
|
||||
type="checkbox"
|
||||
> Labels longs
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="resize-x overflow-hidden rounded border-2 border-dashed border-m-muted p-3"
|
||||
style="width: 100%; min-width: 280px;"
|
||||
>
|
||||
<MalioTabList
|
||||
v-model="sbValue"
|
||||
:tabs="sbTabs"
|
||||
:max-visible-tabs="sbMaxProp"
|
||||
>
|
||||
<template
|
||||
v-for="t in sbTabs"
|
||||
#[t.key]
|
||||
:key="t.key"
|
||||
>
|
||||
<p class="p-4">Contenu : {{ t.label }}</p>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioTabList v-model="simpleValue" :tabs="tabs">
|
||||
<template #qualimat><p class="p-4">Contenu Qualimat</p></template>
|
||||
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||
<template #comptabilite><p class="p-4">Contenu Comptabilité</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icônes</h2>
|
||||
<MalioTabList v-model="noIconValue" :tabs="tabsNoIcon">
|
||||
<template #tab1><p class="p-4">Contenu onglet 1</p></template>
|
||||
<template #tab2><p class="p-4">Contenu onglet 2</p></template>
|
||||
<template #tab3><p class="p-4">Contenu onglet 3</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Non contrôlé (sans v-model)</h2>
|
||||
<MalioTabList :tabs="tabs">
|
||||
<template #qualimat><p class="p-4">Contenu Qualimat</p></template>
|
||||
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||
<template #comptabilite><p class="p-4">Contenu Comptabilité</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Deux onglets</h2>
|
||||
<MalioTabList v-model="twoTabValue" :tabs="tabsTwo">
|
||||
<template #general><p class="p-4">Informations générales</p></template>
|
||||
<template #details><p class="p-4">Détails avancés</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-1 text-xl font-bold">Beaucoup d'onglets (fenêtré)</h2>
|
||||
<p class="mb-4 text-sm text-m-muted">
|
||||
7 onglets avec <code>:max-visible-tabs="5"</code> — flèches gauche/droite pour faire défiler
|
||||
(1 par 1). L'onglet actif reste sélectionné même hors fenêtre.
|
||||
</p>
|
||||
<MalioTabList v-model="manyValue" :tabs="manyTabs" :max-visible-tabs="5">
|
||||
<template #infos><p class="p-4">Contenu Informations</p></template>
|
||||
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||
<template #compta><p class="p-4">Contenu Comptabilité</p></template>
|
||||
<template #documents><p class="p-4">Contenu Documents</p></template>
|
||||
<template #historique><p class="p-4">Contenu Historique</p></template>
|
||||
<template #parametres><p class="p-4">Contenu Paramètres</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-1 text-xl font-bold">Peu d'onglets avec maxVisibleTabs</h2>
|
||||
<p class="mb-4 text-sm text-m-muted">
|
||||
3 onglets avec <code>:max-visible-tabs="5"</code> — le fenêtrage ne s'active pas
|
||||
(onglets ≤ max), donc pas de flèches, affichage normal centré.
|
||||
</p>
|
||||
<MalioTabList v-model="fewValue" :tabs="fewTabs" :max-visible-tabs="5">
|
||||
<template #general><p class="p-4">Contenu Général</p></template>
|
||||
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
// --- Bac à sable interactif ---
|
||||
const sbCount = ref(9)
|
||||
const sbMax = ref(0)
|
||||
const sbIcons = ref(true)
|
||||
const sbLong = ref(false)
|
||||
|
||||
const SB_LABELS = [
|
||||
'Informations', 'Adresses', 'Contacts', 'Comptabilité', 'Documents',
|
||||
'Historique', 'Paramètres', 'Qualité', 'Facturation', 'Accueil',
|
||||
'Notifications', 'Statistiques', 'Équipe', 'Sécurité', 'Étiquettes',
|
||||
]
|
||||
const SB_ICONS = [
|
||||
'mdi:information-outline', 'mdi:map-marker-outline', 'mdi:account-box-outline', 'mdi:web',
|
||||
'mdi:file-document-outline', 'mdi:history', 'mdi:cog-outline', 'mdi:check-decagram-outline',
|
||||
'mdi:receipt-text-outline', 'mdi:home', 'mdi:bell-outline', 'mdi:chart-bar',
|
||||
'mdi:account-group-outline', 'mdi:lock-outline', 'mdi:tag-outline',
|
||||
]
|
||||
|
||||
const sbTabs = computed(() =>
|
||||
Array.from({ length: sbCount.value }, (_, i) => ({
|
||||
key: `sb${i}`,
|
||||
label: sbLong.value ? `${SB_LABELS[i % SB_LABELS.length]} détaillé` : SB_LABELS[i % SB_LABELS.length],
|
||||
icon: sbIcons.value ? SB_ICONS[i % SB_ICONS.length] : undefined,
|
||||
})),
|
||||
)
|
||||
const sbMaxProp = computed(() => (sbMax.value > 0 ? sbMax.value : undefined))
|
||||
const sbValue = ref('sb0')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' },
|
||||
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
|
||||
{ key: 'comptabilite', label: 'Comptabilité', icon: 'mdi:web' },
|
||||
]
|
||||
|
||||
const tabsNoIcon = [
|
||||
{ key: 'tab1', label: 'Onglet 1' },
|
||||
{ key: 'tab2', label: 'Onglet 2' },
|
||||
{ key: 'tab3', label: 'Onglet 3' },
|
||||
]
|
||||
|
||||
const tabsTwo = [
|
||||
{ key: 'general', label: 'Général', icon: 'mdi:information-outline' },
|
||||
{ key: 'details', label: 'Détails', icon: 'mdi:cog-outline' },
|
||||
]
|
||||
|
||||
const manyTabs = [
|
||||
{ key: 'infos', label: 'Informations', icon: 'mdi:information-outline' },
|
||||
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
|
||||
{ key: 'compta', label: 'Comptabilité', icon: 'mdi:web' },
|
||||
{ key: 'documents', label: 'Documents', icon: 'mdi:file-document-outline' },
|
||||
{ key: 'historique', label: 'Historique', icon: 'mdi:history' },
|
||||
{ key: 'parametres', label: 'Paramètres', icon: 'mdi:cog-outline' },
|
||||
]
|
||||
|
||||
const simpleValue = ref('qualimat')
|
||||
const noIconValue = ref('tab1')
|
||||
const twoTabValue = ref('general')
|
||||
const manyValue = ref('infos')
|
||||
|
||||
const fewTabs = [
|
||||
{ key: 'general', label: 'Général', icon: 'mdi:information-outline' },
|
||||
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
|
||||
]
|
||||
const fewValue = ref('general')
|
||||
</script>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioTime v-model="simpleValue" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||
<MalioTime
|
||||
v-model="labeledValue"
|
||||
label="Heure de depart"
|
||||
name="departure-time"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||
<MalioTime
|
||||
v-model="initialValue"
|
||||
label="Heure d'arrivee"
|
||||
hint="Format HH:MM"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Required</h2>
|
||||
<MalioTime
|
||||
v-model="requiredValue"
|
||||
label="Heure limite"
|
||||
required
|
||||
hint="Champ obligatoire"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Desactive</h2>
|
||||
<MalioTime
|
||||
v-model="disabledValue"
|
||||
label="Heure verrouillee"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||
<MalioTime
|
||||
v-model="readonlyValue"
|
||||
label="Heure en lecture seule"
|
||||
readonly
|
||||
hint="Visible mais non modifiable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioTime
|
||||
v-model="errorValue"
|
||||
label="Heure de fermeture"
|
||||
error="L'heure saisie n'est pas valide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succes</h2>
|
||||
<MalioTime
|
||||
v-model="successValue"
|
||||
label="Heure confirmee"
|
||||
success="Horaire enregistre"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioTime from '../../../../app/components/malio/time/Time.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const labeledValue = ref('')
|
||||
const initialValue = ref('08:30')
|
||||
const requiredValue = ref('')
|
||||
const disabledValue = ref('14:15')
|
||||
const readonlyValue = ref('18:45')
|
||||
const errorValue = ref('25:90')
|
||||
const successValue = ref('09:00')
|
||||
</script>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioTimePicker v-model="simpleValue" label="Heure" />
|
||||
<p class="mt-2 text-sm text-m-muted">Valeur : {{ simpleValue || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioTimePicker label="Heure (readonly vide)" :readonly="true" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioTimePicker v-model="readonlyFilledTime" label="Heure (readonly rempli)" :readonly="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const readonlyFilledTime = ref('14:30')
|
||||
const simpleValue = ref('')
|
||||
const initialValue = ref('08:30')
|
||||
const disabledValue = ref('14:15')
|
||||
const errorValue = ref('25:90')
|
||||
const successValue = ref('09:00')
|
||||
const noClearValue = ref('10:00')
|
||||
</script>
|
||||
+125
-7
@@ -1,10 +1,128 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl py-16 text-center">
|
||||
<h1 class="text-3xl font-bold text-m-text">
|
||||
Playground @malio/layer-ui
|
||||
</h1>
|
||||
<p class="mt-4 text-m-muted">
|
||||
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
|
||||
</p>
|
||||
<div class="flex min-h-screen">
|
||||
<aside class="w-72 bg-m-bg p-6 text-white">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xl text-black font-semibold"
|
||||
@click="clearSelection"
|
||||
>
|
||||
Liste des composants
|
||||
</button>
|
||||
|
||||
<nav class="mt-6 flex flex-col gap-2">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="rounded px-3 py-2 text-left text-black font-bold hover:bg-m-primary hover:text-white"
|
||||
:class="selectedName === item.name ? 'bg-m-secondary text-white' : ''"
|
||||
@click="selectOrToggle(item.name)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 p-6">
|
||||
<component
|
||||
:is="selectedDemoComponent"
|
||||
v-if="selectedDemoComponent"
|
||||
/>
|
||||
<p
|
||||
v-else-if="selectedName"
|
||||
class="text-gray-700"
|
||||
>
|
||||
Page de demo introuvable:
|
||||
<code>.playground/pages/composant/{{ selectedDemoFileName }}.vue</code>
|
||||
</p>
|
||||
<div v-else>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">
|
||||
Playground composants
|
||||
</h1>
|
||||
<p class="mt-2 text-gray-600">
|
||||
Selectionne un composant dans la liste pour afficher sa page de demo.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, shallowRef } from 'vue'
|
||||
|
||||
type LoadedModule = {
|
||||
default: unknown
|
||||
}
|
||||
|
||||
type Item = {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const componentModules = import.meta.glob('../../app/components/malio/*.vue')
|
||||
const demoModules = import.meta.glob('./composant/*.vue')
|
||||
|
||||
const demoByName: Record<string, () => Promise<LoadedModule>> =
|
||||
Object.fromEntries(
|
||||
Object.entries(demoModules).map(([file, loader]) => {
|
||||
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
|
||||
return [name.toLowerCase(), loader as () => Promise<LoadedModule>]
|
||||
}),
|
||||
)
|
||||
|
||||
const items = computed<Item[]>(() =>
|
||||
Object.keys(componentModules).map((file) => {
|
||||
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const selectedName = ref('')
|
||||
const hasInitializedSelection = ref(false)
|
||||
|
||||
watch(
|
||||
items,
|
||||
(val) => {
|
||||
if (!hasInitializedSelection.value && val.length > 0) {
|
||||
selectedName.value = val[0].name
|
||||
hasInitializedSelection.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function selectOrToggle(name: string) {
|
||||
selectedName.value = selectedName.value === name ? '' : name
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedName.value = ''
|
||||
}
|
||||
|
||||
const selectedDemoComponent = shallowRef<unknown>(null)
|
||||
|
||||
watch(selectedName, async (name) => {
|
||||
if (!name) {
|
||||
selectedDemoComponent.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const loader = demoByName[name.toLowerCase()]
|
||||
if (!loader) {
|
||||
selectedDemoComponent.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await loader()
|
||||
selectedDemoComponent.value = mod.default
|
||||
})
|
||||
|
||||
const selectedDemoFileName = computed(() => {
|
||||
const name = selectedName.value
|
||||
if (!name) return ''
|
||||
return name.charAt(0).toLowerCase() + name.slice(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
|
||||
|
||||
export const navSections: SidebarSection[] = [
|
||||
{
|
||||
label: 'BOUTONS',
|
||||
icon: 'mdi:gesture-tap-button',
|
||||
items: [
|
||||
{label: 'Button', to: '/composant/button/button'},
|
||||
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CHAMPS',
|
||||
icon: 'mdi:form-textbox',
|
||||
items: [
|
||||
{label: 'Texte', to: '/composant/input/inputText'},
|
||||
{label: 'Nombre', to: '/composant/input/inputNumber'},
|
||||
{label: 'Montant', to: '/composant/input/inputAmount'},
|
||||
{label: 'Email', to: '/composant/input/inputEmail'},
|
||||
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
|
||||
{label: 'Téléphone', to: '/composant/input/inputPhone'},
|
||||
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
|
||||
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
|
||||
{label: 'Upload', to: '/composant/input/inputUpload'},
|
||||
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DATES & HEURES',
|
||||
icon: 'mdi:calendar-clock',
|
||||
items: [
|
||||
{label: 'Date', to: '/composant/date/date'},
|
||||
{label: 'Plage de dates', to: '/composant/date/dateRange'},
|
||||
{label: 'Semaine', to: '/composant/date/dateWeek'},
|
||||
{label: 'Date & heure', to: '/composant/date/datetime'},
|
||||
{label: 'Heure', to: '/composant/time/time'},
|
||||
{label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SÉLECTIONS',
|
||||
icon: 'mdi:form-dropdown',
|
||||
items: [
|
||||
{label: 'Select', to: '/composant/select/select'},
|
||||
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
||||
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
||||
{label: 'Radio', to: '/composant/radio/radioButton'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'NAVIGATION',
|
||||
icon: 'mdi:navigation-variant',
|
||||
items: [
|
||||
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
||||
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
||||
{label: 'Modal', to: '/composant/modal/modal'},
|
||||
{label: 'Onglets', to: '/composant/tab/tabList'},
|
||||
{label: 'Accordéon', to: '/composant/accordion/accordion'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DONNÉES',
|
||||
icon: 'mdi:table',
|
||||
items: [
|
||||
{label: 'DataTable', to: '/composant/datatable/datatable'},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DIVERS',
|
||||
icon: 'mdi:dots-horizontal',
|
||||
items: [
|
||||
{label: 'Champs readonly', to: '/composant/divers/readonly'},
|
||||
{label: 'Champs disabled', to: '/composant/divers/disabled'},
|
||||
{label: 'Heure', to: '/composant/time/time'},
|
||||
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
||||
{label: 'Formulaire client', to: '/composant/form/client'},
|
||||
{label: 'Filtres', to: '/composant/filtre/filtres'},
|
||||
],
|
||||
},
|
||||
]
|
||||
+2
-20
@@ -2,26 +2,8 @@
|
||||
"branches": ["main", "master"],
|
||||
"repositoryUrl": "https://gitea.malio.fr/MALIO-DEV/malio-layer-ui.git",
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "angular",
|
||||
"parserOpts": {
|
||||
"headerPattern": "^(\\w+)(?:\\(([\\w$.\\-* ]+)\\))?\\s*:\\s+(.+)$",
|
||||
"headerCorrespondence": ["type", "scope", "subject"]
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "angular",
|
||||
"parserOpts": {
|
||||
"headerPattern": "^(\\w+)(?:\\(([\\w$.\\-* ]+)\\))?\\s*:\\s+(.+)$",
|
||||
"headerCorrespondence": ["type", "scope", "subject"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/npm"
|
||||
]
|
||||
}
|
||||
|
||||
+3
-75
@@ -7,83 +7,11 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
|
||||
### Added
|
||||
* [#333] Création d'un composant text
|
||||
* [#337] Création d'un composant select
|
||||
* [#362] Création d'un composant checkbox
|
||||
* [#363] Création d'un composant amount
|
||||
* [#364] Création d'un composant button radio
|
||||
* [#365] Création d'un composant number
|
||||
* [#366] Création d'un composant select checkbox
|
||||
* [#407] Création d'un composant time
|
||||
* Création d'un composant textarea
|
||||
* [#MUI-8] Création d'un composant mot de passe
|
||||
* [#MUI-9] Création d'un composant upload
|
||||
* [#MUI-14] Création d'un composant bouton icône
|
||||
* [#MUI-11] Création d'un composant navigation par onglets
|
||||
* [#MUI-20] Création d'un composant sidebar
|
||||
* [#MUI-23] Revoir la config couleur tailwind
|
||||
* [#MUI-10] Création d'un composant bouton
|
||||
* [#MUI-2] Faire un MCP pour la librairie de composant
|
||||
* [#MUI-15] Création d'un composant drawer
|
||||
* [#MUI-22] Création d'un composant datatable
|
||||
* [#MUI-27] Création d'un composant sélection de site
|
||||
* Création d'un composant rich text (TipTap) avec sortie markdown / HTML
|
||||
* [#MUI-30] Création d'un composant email
|
||||
* [#MUI-31] Création d'un composant téléphone
|
||||
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
||||
* [#MUI-34] Revoir le système de playground
|
||||
* [#MUI-33] Développer le composant Datepicker
|
||||
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
|
||||
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
|
||||
* [#MUI-37] Création d'un composant accordéon
|
||||
* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire)
|
||||
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
|
||||
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
|
||||
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
|
||||
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
|
||||
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
|
||||
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
|
||||
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
|
||||
* [#MUI-42] Anneau de focus clavier standardisé (`outline` 2px `m-primary`, offset 2px) affiché **uniquement** à la navigation clavier (jamais au clic souris), sur l'ensemble des champs et contrôles : inputs (Text, Email, Password, Phone, Amount, Number + boutons ±, Upload, TextArea, Autocomplete), Select, SelectCheckbox, famille Date (Date, DateRange, DateTime, DateWeek), Button, ButtonIcon. Mécanique : composable `useKbdFocusRing` (détection de modalité clavier/souris) + utilitaires CSS `.m-focus-ring` (éléments à `:focus-visible` natif) et `.m-focus-ring-kbd` (champs texte, où `:focus-visible` se déclenche aussi à la souris)
|
||||
* [#MUI-42] Anneau « combo » : quand un dropdown / calendrier est ouvert (Autocomplete, Select, SelectCheckbox, Date), l'anneau entoure le champ **et** la liste / le calendrier d'un seul tenant, adapté au sens d'ouverture (utilitaires `.m-combo-ring-top` / `.m-combo-ring-bottom`)
|
||||
* [#MUI-42] Navigation clavier WAI-ARIA APG sur les listes déroulantes : Select et SelectCheckbox gagnent la navigation (flèches, Home/End, Entrée/Espace, Échap, Tab — absente jusque-là), avec scroll automatique de l'option active et `aria-activedescendant` ; InputAutocomplete complété (scroll auto, ArrowUp ouvre sur la dernière option, Home/End, Tab ferme)
|
||||
* [#MUI-42] SelectCheckbox : la ligne « Tout sélectionner » est intégrée à la navigation clavier ; le clic sur toute la ligne d'option (et plus seulement le label) coche/décoche
|
||||
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
|
||||
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
|
||||
* [#MUI-43] MalioDate : event `update:valid` (booléen) exposant l'état de validité de la saisie (`false` sur date malformée ou hors `min`/`max`, qui n'émet pas `modelValue`) — permet au parent de bloquer le submit ; la validité ne couvre pas `required` (champ vide = valide)
|
||||
* [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). Nouveau parseur `parseDisplayToIsoDateTime`.
|
||||
* [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur.
|
||||
* [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas).
|
||||
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
|
||||
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
|
||||
* [#337] Création d'un composant select
|
||||
* [#363] Création d'un composant amount
|
||||
* [#363] Création d'un composant checkbox
|
||||
|
||||
### Changed
|
||||
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
|
||||
* TabList : le nombre d'onglets visibles en mode fenêtré s'**adapte automatiquement à la largeur réelle** (mesure via `ResizeObserver` + ligne de mesure cachée), au lieu d'un `maxVisibleTabs` fixe qui pouvait faire déborder les onglets sur les chevrons. Les chevrons restent fixés aux bords et le nombre affiché est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). `maxVisibleTabs` devient un **plafond optionnel**. Calcul isolé dans une fonction pure testable (`tabFit.ts`, basée sur les largeurs réelles des onglets). Sans layout (SSR), repli sur le plafond / tous les onglets. **Breaking** : la prop `maxWidth` est supprimée (la barre utilise désormais toute la largeur disponible au lieu d'être plafonnée à 1100px).
|
||||
* TabList : au **survol** d'un onglet inactif, on applique désormais le même style que l'onglet actif — texte `m-primary` plein + barre soulignée `m-primary` (`hover:after:*`) — au lieu du discret `text-m-primary/70`, pour bien marquer la cible.
|
||||
* Sidebar : états visuels des liens de navigation — **survol** : highlight pleine largeur entièrement porté par le `<li>` (fond `m-primary` à 10 % + texte `m-primary` + semi-bold, `hover:bg-m-primary/10 hover:text-m-primary hover:font-semibold`, espacement `pt-1 pb-1`). La couleur de base (`text-black`) est aussi sur le `<li>` et le `<a>` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du `<a>`, le fond devenait bleu mais le texte restait noir. **Lien actif** : texte `m-primary` + semi-bold, sans fond (`active-class="!text-m-primary font-semibold"` ; `!important` car `active-class` est hors `twMerge`).
|
||||
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
|
||||
* MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`).
|
||||
* DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`).
|
||||
* Select : nouvelle prop `fieldClass` pour surcharger les classes du field (notamment la hauteur `h-[40px]` jusqu'ici codée en dur) ; utilisée par le DataTable pour passer le sélecteur de perPage à `30px`.
|
||||
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
||||
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
|
||||
|
||||
### Fixed
|
||||
* Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir).
|
||||
* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit.
|
||||
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
|
||||
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
|
||||
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
|
||||
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
|
||||
* Espace réservé (`min-h-[1rem]`) pour le paragraphe hint/error/success de 15 composants (Input*, Select*, Time*, CalendarField, Checkbox) — l'apparition d'une erreur ne décale plus les cellules voisines dans une grille
|
||||
* InputPhone : la croix `+` (add button) suit la même cascade d'état que les autres icônes du champ (muted / primary en focus / black quand rempli / danger / success) au lieu d'être figée en primary
|
||||
* Select / SelectCheckbox : le chevron suit l'état du champ (muted par défaut, primary à l'ouverture, black avec une option sélectionnée, danger / success en cas d'erreur ou succès) au lieu de `text-current`
|
||||
* InputTextArea : composant single-root (était multi-root) — le wrapper du message ne prend plus sa propre cellule de grille, `row-span-2` fonctionne à nouveau
|
||||
* Label désactivé en `text-m-muted` (gris des bordures) au lieu de `text-black/60` sur les inputs à floating-label (InputText, Email, Password, Amount, Phone, Upload, Autocomplete, TextArea, RichText)
|
||||
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
|
||||
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
|
||||
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
|
||||
* [#MUI-42] RadioButton : ajout d'un focus visible au clavier (`outline` 2px `m-primary`, offset 2px) — l'input en `appearance-none` n'avait aucun indicateur de focus, seul l'`outline: auto 1px` du navigateur restait, quasi invisible. La navigation native (Tab entre groupes, flèches dans le groupe) reste inchangée
|
||||
* [#MUI-42] Checkbox : ajout d'un focus visible au clavier sur la case (`outline` 2px `m-primary`, offset 2px) — l'input réel est masqué (`clip-path`), aucun indicateur n'apparaissait à la tabulation
|
||||
* [#MUI-42] Select : le focus reste sur le bouton après sélection (un `blur()` renvoyait le focus au `body`, cassant la tabulation clavier — un Tab repartait du haut de page)
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# CLAUDE.md — @malio/layer-ui
|
||||
|
||||
## Projet
|
||||
|
||||
Bibliothèque de composants UI sous forme de **Nuxt 4 Layer**. Le package `@malio/layer-ui` est consommé par les autres applications Malio via `extends` dans leur `nuxt.config.ts`.
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
npm run dev # Lance le playground (.playground/)
|
||||
npm run dev:prepare # Génère les types Nuxt (à lancer après un clone)
|
||||
npm run test # Vitest (run mode)
|
||||
npm run lint # ESLint
|
||||
npm run story:dev # Histoire (documentation composants)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
components/malio/ # Composants (auto-importés comme <MalioXxx>)
|
||||
story/ # Fichiers .story.vue (Histoire)
|
||||
assets/css/malio.css # Design tokens (CSS custom properties)
|
||||
.playground/ # App Nuxt pour tester les composants en dev
|
||||
```
|
||||
|
||||
## Conventions composants
|
||||
|
||||
- **Nommage fichier** : PascalCase (`InputText.vue`). Le préfixe `Malio` est ajouté automatiquement par le dossier `malio/`.
|
||||
- **`defineOptions({ name: 'MalioXxx', inheritAttrs: false })`** en tête de chaque composant.
|
||||
- **Props communes** : `id`, `label`, `modelValue`, `inputClass`, `labelClass`, `groupClass`, `disabled`, `readonly`, `hint`, `error`, `success`.
|
||||
- **Pattern contrôlé/non-contrôlé** : `isControlled = computed(() => props.modelValue !== undefined)` avec `localValue` en fallback.
|
||||
- **Classes CSS** : fusionnées avec `twMerge()` pour permettre l'override par le consommateur via les props `*Class`.
|
||||
- **Accessibilité** : `aria-invalid`, `aria-describedby`, labels liés par `for/id`.
|
||||
- **Icônes** : via `@iconify/vue` (Icon component), pas `@nuxt/icon` dans les composants.
|
||||
|
||||
## Stack technique
|
||||
|
||||
- **Nuxt 4** (layer), **Vue 3** Composition API (`<script setup lang="ts">`)
|
||||
- **TypeScript** strict (`defineProps<T>()` + `withDefaults`)
|
||||
- **Tailwind CSS** avec palette custom `m-*` (primary, secondary, error, etc.) basée sur des CSS variables RGB
|
||||
- **tailwind-merge** pour la fusion intelligente des classes
|
||||
- **maska** pour le masquage d'input (InputText)
|
||||
- **Vitest** + `@vue/test-utils` pour les tests unitaires (pattern `*.test.ts` colocalisé)
|
||||
- **Histoire** pour la documentation visuelle des composants
|
||||
|
||||
## Design tokens
|
||||
|
||||
Définis dans `app/assets/css/malio.css` comme CSS custom properties RGB :
|
||||
- `--m-primary`, `--m-secondary`, `--m-tertiary`, `--m-border`, `--m-text`, `--m-muted`, `--m-bg`, `--m-error`, `--m-success`, `--m-radius`
|
||||
- Utilisés via Tailwind : `text-m-primary`, `border-m-error`, `bg-m-bg`, `rounded-malio`, etc.
|
||||
|
||||
## Tests
|
||||
|
||||
- Fichiers colocalisés : `ComponentName.test.ts` à côté du `.vue`
|
||||
- Pattern : `mountComponent(props)` helper, tests de rendu, props, emits, états, accessibilité
|
||||
- Environnement : jsdom
|
||||
|
||||
## Git & CI
|
||||
|
||||
- **Conventional Commits** obligatoires (hooks pre-commit + commit-msg)
|
||||
- Branches : `develop` → `main`
|
||||
- **semantic-release** sur push main (Gitea Actions)
|
||||
- Registry privé Gitea (`@malio` scope)
|
||||
-1094
File diff suppressed because it is too large
Load Diff
+10
-70
@@ -2,78 +2,18 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
/* Anneau de focus clavier standard (navigation au Tab), invisible à la souris.
|
||||
Deux déclencheurs, même rendu :
|
||||
- .m-focus-ring → s'appuie sur :focus-visible natif. Pour les éléments
|
||||
où :focus-visible se limite déjà au clavier (boutons,
|
||||
onglets, tuiles, checkbox/radio…).
|
||||
- .m-focus-ring-kbd → classe ajoutée en JS (via useKbdFocusRing) uniquement
|
||||
quand le focus vient du clavier. Pour les champs texte,
|
||||
où :focus-visible natif se déclenche aussi à la souris.
|
||||
Le `:focus` sur .m-focus-ring-kbd élève la spécificité pour passer devant le
|
||||
`outline-none` des inputs. */
|
||||
.m-focus-ring:focus-visible,
|
||||
.m-focus-ring-kbd:focus {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
|
||||
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
|
||||
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
|
||||
reste sans contour pour un raccord sans couture. */
|
||||
.m-combo-ring-top {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 -2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
.m-combo-ring-bottom {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Globales ── */
|
||||
--m-primary: 34 39 131; /* #222783 - Bleu Malio */
|
||||
--m-primary-light: 239 239 253; /* #EFEFFD - Teinte claire du primary (fonds doux) */
|
||||
--m-bg: 243 244 248; /* #F3F4F8 - Fond de page */
|
||||
--m-surface: 243 244 248; /* #F3F4F8 - Fond hover/cartes */
|
||||
--m-text: 15 23 42; /* #0F172A */
|
||||
--m-muted: 100 116 139; /* #64748B */
|
||||
--m-border: 203 213 225; /* #CBD5E1 */
|
||||
--m-disabled: 204 204 223; /* #CCCCDF - Partagé entre toutes les familles bouton */
|
||||
--m-danger: 242 105 107; /* #F2696B - Erreurs et boutons danger */
|
||||
--m-success: 15 149 70; /* #0F9546 */
|
||||
/* Couleurs en RGB “space separated” pour Tailwind */
|
||||
--m-primary: 34 39 131; /* Couleur principal*/
|
||||
--m-secondary: 48 73 152; /* Couleur secondaire */
|
||||
--m-tertiary: 243 244 248; /* Couleur tertiaire (background) */
|
||||
--m-border: 203 213 225; /* Couleur des bordures */
|
||||
--m-text: 15 23 42; /* Couleur du texte */
|
||||
--m-muted: 100 116 139; /* Couleur pour les éléments désactivés ou secondaires */
|
||||
--m-bg: 243 244 248; /* Couleur de fond générale */
|
||||
|
||||
/* ── Boutons Primary ── */
|
||||
--m-btn-primary: 34 39 131; /* #222783 */
|
||||
--m-btn-primary-hover: 18 28 219; /* #121CDB */
|
||||
--m-btn-primary-active: 33 37 103; /* #212567 */
|
||||
|
||||
/* ── Boutons Secondary ── */
|
||||
--m-btn-secondary: 75 77 237; /* #4B4DED */
|
||||
--m-btn-secondary-hover: 137 123 241; /* #897BF1 */
|
||||
--m-btn-secondary-active: 18 28 219; /* #121CDB */
|
||||
|
||||
/* ── Boutons Danger ── */
|
||||
--m-btn-danger: 242 105 107; /* #F2696B */
|
||||
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
||||
--m-btn-danger-active: 255 83 86; /* #FF5356 */
|
||||
|
||||
/* ── Largeurs Boutons ── */
|
||||
--m-btn-action-width: 150px; /* Boutons d'action (liste, ligne tableau, footer dense…) */
|
||||
|
||||
/* ── Couleurs de site (usage ponctuel) ── */
|
||||
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
||||
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
||||
--m-site-green: 116 191 4; /* #74BF04 - Vert Pommevic */
|
||||
|
||||
--m-radius: 6px;
|
||||
--m-error: 155 17 30; /* rouge pour les erreurs */
|
||||
--m-success: 15 149 70; /* vert pour les succès */
|
||||
}
|
||||
}
|
||||
|
||||
+2
-54
@@ -17,7 +17,6 @@ type CheckboxProps = {
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
|
||||
@@ -115,7 +114,7 @@ describe('MalioCheckbox', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('p').text()).toBe('You must accept')
|
||||
})
|
||||
|
||||
@@ -126,7 +125,7 @@ describe('MalioCheckbox', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('p').text()).toBe('Invalid')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
||||
})
|
||||
|
||||
it('shows success styles and message when there is no error', () => {
|
||||
@@ -140,55 +139,4 @@ describe('MalioCheckbox', () => {
|
||||
expect(wrapper.get('p').text()).toBe('Valid')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-success')
|
||||
})
|
||||
|
||||
it('uses muted label color when unchecked', () => {
|
||||
const wrapper = mountCheckbox({label: 'Accept terms', modelValue: false})
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('uses black label color when checked', () => {
|
||||
const wrapper = mountCheckbox({label: 'Accept terms', modelValue: true})
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||
const wrapper = mountCheckbox({label: 'Accept terms'})
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
|
||||
await wrapper.get('input').setValue(true)
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountCheckbox({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountCheckbox({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountCheckbox({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountCheckbox({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountCheckbox({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
@@ -25,12 +25,12 @@
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
{{ label }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="mergedMessageClass"
|
||||
>
|
||||
@@ -40,9 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {computed, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
||||
|
||||
@@ -61,7 +60,6 @@ const props = withDefaults(
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -77,17 +75,14 @@ const props = withDefaults(
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localChecked = ref(false)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const isChecked = computed(() => (isControlled.value ? !!props.modelValue : localChecked.value))
|
||||
const isChecked = computed(() => !!props.modelValue)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const disabled = computed(() => props.disabled)
|
||||
@@ -99,24 +94,23 @@ const describedBy = computed(() => {
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'checkbox-wrapper-4 w-full',
|
||||
'checkbox-wrapper-4 mt-4 w-full',
|
||||
props.groupClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'inp-cbx peer ',
|
||||
'inp-cbx peer',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'cbx text-lg',
|
||||
isChecked.value ? 'text-black' : 'text-m-muted',
|
||||
'cbx text-black',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||
hasError.value ? 'text-m-danger' : '',
|
||||
hasError.value ? 'text-m-error' : '',
|
||||
hasSuccess.value ? 'text-m-success' : '',
|
||||
props.labelClass,
|
||||
),
|
||||
@@ -125,9 +119,8 @@ const mergedLabelClass = computed(() =>
|
||||
const mergedMessageClass = computed(() =>
|
||||
twMerge(
|
||||
'text-xs',
|
||||
props.reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
? 'text-m-error'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
@@ -146,10 +139,6 @@ const onChange = (event: Event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled.value) {
|
||||
localChecked.value = target.checked
|
||||
}
|
||||
|
||||
emit('update:modelValue', target.checked)
|
||||
}
|
||||
</script>
|
||||
@@ -172,19 +161,10 @@ const onChange = (event: Event) => {
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
transform: scale(1);
|
||||
border: 2px solid rgb(var(--m-muted) / 1);
|
||||
border: 2px solid rgb(0, 0, 0);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.inp-cbx:checked + .cbx span:first-child {
|
||||
border-color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.inp-cbx:focus-visible + .cbx span:first-child {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cbx span:first-child svg {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -220,14 +200,14 @@ const onChange = (event: Event) => {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.inp-cbx + .cbx.text-m-danger span:first-child {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
.inp-cbx + .cbx.text-m-error span:first-child {
|
||||
border-color: rgb(var(--m-error) / 1);
|
||||
}
|
||||
.cbx.text-m-danger span:first-child svg {
|
||||
stroke: rgb(var(--m-danger) / 1);
|
||||
.cbx.text-m-error span:first-child svg {
|
||||
stroke: rgb(var(--m-error) / 1);
|
||||
}
|
||||
.inp-cbx:checked + .cbx.text-m-danger span:first-child {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
.inp-cbx:checked + .cbx.text-m-error span:first-child {
|
||||
border-color: rgb(var(--m-error) / 1);
|
||||
}
|
||||
|
||||
.inp-cbx + .cbx.text-m-success span:first-child {
|
||||
@@ -24,7 +24,6 @@ type InputProps = {
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputForTest = Input as DefineComponent<InputProps>
|
||||
@@ -54,16 +53,6 @@ describe('MalioInputText', () => {
|
||||
expect(wrapper.get('label').text()).toBe('labelTest')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInput({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInput({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the name attribute', () => {
|
||||
const wrapper = mountInput({name: 'nameTest'})
|
||||
|
||||
@@ -137,13 +126,6 @@ describe('MalioInputText', () => {
|
||||
expect(wrapper.get('input').classes()).toContain('text-black/60')
|
||||
})
|
||||
|
||||
it('shows muted label color when disabled (matches border color)', () => {
|
||||
const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'})
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
expect(wrapper.get('label').classes()).not.toContain('text-black/60')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on input change', async () => {
|
||||
const wrapper = mountInput({modelValue: ''})
|
||||
|
||||
@@ -179,19 +161,19 @@ describe('MalioInputText', () => {
|
||||
it('shows error message without label and icon', () => {
|
||||
const wrapper = mountInput({error: 'Error message test'})
|
||||
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-error')
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
||||
})
|
||||
|
||||
it('shows error message with label and without icon', () => {
|
||||
const wrapper = mountInput({error: 'Error message test', label: 'Error message'})
|
||||
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-error')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
||||
})
|
||||
|
||||
it('shows error message with label and icon', () => {
|
||||
@@ -201,19 +183,19 @@ describe('MalioInputText', () => {
|
||||
iconName: 'mdi:key-outline',
|
||||
})
|
||||
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-error')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
||||
})
|
||||
|
||||
it('shows error message with icon and without label', () => {
|
||||
const wrapper = mountInput({error: 'Error message test', iconName: 'mdi:key-outline'})
|
||||
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Error message test')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-error')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-error')
|
||||
})
|
||||
|
||||
it('shows success message without label and icon', () => {
|
||||
@@ -258,10 +240,10 @@ describe('MalioInputText', () => {
|
||||
success: 'Success message test',
|
||||
})
|
||||
|
||||
expect(wrapper.find('p.text-m-danger').exists()).toBe(true)
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
|
||||
expect(wrapper.find('p.text-m-error').exists()).toBe(true)
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Error message test')
|
||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-error')
|
||||
expect(wrapper.get('input').classes()).not.toContain('border-m-success')
|
||||
})
|
||||
|
||||
@@ -271,34 +253,6 @@ describe('MalioInputText', () => {
|
||||
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
|
||||
})
|
||||
|
||||
it('reserves space for the message even when no hint/error/success is set', () => {
|
||||
const wrapper = mountInput({})
|
||||
|
||||
const p = wrapper.find('p')
|
||||
expect(p.exists()).toBe(true)
|
||||
expect(p.text()).toBe('')
|
||||
expect(p.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountInput({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountInput({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountInput({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('does not render label when label prop is missing', () => {
|
||||
const wrapper = mountInput({labelClass: 'text-red-500'})
|
||||
|
||||
@@ -311,7 +265,7 @@ describe('MalioInputText', () => {
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('pointer-events-none')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('absolute')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-2')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('top-1/2')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2')
|
||||
})
|
||||
@@ -323,9 +277,9 @@ describe('MalioInputText', () => {
|
||||
label: 'Password',
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-2')
|
||||
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||
expect(wrapper.get('label').classes()).toContain('left-11')
|
||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
||||
})
|
||||
|
||||
it('passes icon size props to icon component', () => {
|
||||
@@ -340,39 +294,4 @@ describe('MalioInputText', () => {
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('shows primary icon color on focus', async () => {
|
||||
const wrapper = mountInput({iconName: 'mdi:key-outline'})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('shows black icon color when filled and unfocused', () => {
|
||||
const wrapper = mountInput({iconName: 'mdi:key-outline', modelValue: 'hello'})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||
const wrapper = mountInput({label: 'Champ', readonly: true})
|
||||
const field = wrapper.get('input')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(field.classes()).not.toContain('grow-height')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mountInput({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir et icône noire', () => {
|
||||
const wrapper = mountInput({label: 'Champ', readonly: true, modelValue: 'hello', iconName: 'mdi:key-outline'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import InputAmount from './InputAmount.vue'
|
||||
|
||||
type InputAmountProps = {
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
modelValue?: string | null
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
required?: boolean
|
||||
maxLength?: number | string
|
||||
minLength?: number | string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
|
||||
|
||||
const mountInputAmount = (props: InputAmountProps = {}) =>
|
||||
mount(InputAmountForTest, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
IconifyIcon: {
|
||||
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('MalioInputAmount', () => {
|
||||
it('renders as a text input with decimal input mode', () => {
|
||||
const wrapper = mountInputAmount()
|
||||
|
||||
expect(wrapper.get('input').attributes('type')).toBe('text')
|
||||
expect(wrapper.get('input').attributes('inputmode')).toBe('decimal')
|
||||
})
|
||||
|
||||
it('renders the default icon with muted styling', () => {
|
||||
const wrapper = mountInputAmount()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-2')
|
||||
})
|
||||
|
||||
it('generates an amount-specific id', () => {
|
||||
const wrapper = mountInputAmount({label: 'Montant'})
|
||||
|
||||
const inputId = wrapper.get('input').attributes('id')
|
||||
|
||||
expect(inputId?.startsWith('malio-input-amount-')).toBe(true)
|
||||
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
||||
})
|
||||
|
||||
it('applies the provided input classes', () => {
|
||||
const wrapper = mountInputAmount({inputClass: 'text-right'})
|
||||
|
||||
expect(wrapper.get('input').classes()).toContain('text-right')
|
||||
})
|
||||
|
||||
it('links hint text through aria-describedby', () => {
|
||||
const wrapper = mountInputAmount({hint: 'Saisissez un montant'})
|
||||
|
||||
const inputId = wrapper.get('input').attributes('id')
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
|
||||
expect(wrapper.get('p').attributes('id')).toBe(`${inputId}-describedby`)
|
||||
})
|
||||
|
||||
it('sets aria-invalid and describedby when showing an error', () => {
|
||||
const wrapper = mountInputAmount({error: 'Montant invalide'})
|
||||
|
||||
const inputId = wrapper.get('input').attributes('id')
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Montant invalide')
|
||||
})
|
||||
|
||||
it('keeps dots as the decimal separator on input', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('12.5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
||||
})
|
||||
|
||||
it('accepts commas but normalizes them to dots', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('0012,345abc')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
|
||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
||||
})
|
||||
|
||||
it('normalizes a leading decimal separator', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue(',5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
||||
})
|
||||
|
||||
it('keeps the normalized decimal value on blur', async () => {
|
||||
const wrapper = mountInputAmount()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('12.5')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
|
||||
expect(input.element.value).toBe('12.5')
|
||||
})
|
||||
|
||||
it('keeps integer values unchanged on blur', async () => {
|
||||
const wrapper = mountInputAmount()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('12')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12']])
|
||||
expect(input.element.value).toBe('12')
|
||||
})
|
||||
|
||||
it('keeps an empty value empty on blur', async () => {
|
||||
const wrapper = mountInputAmount()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['']])
|
||||
expect(input.element.value).toBe('')
|
||||
})
|
||||
|
||||
it('supports icon positioning on the left', () => {
|
||||
const wrapper = mountInputAmount({
|
||||
label: 'Montant',
|
||||
iconPosition: 'left',
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-2')
|
||||
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:minlength="minLength"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="onBlur"
|
||||
>
|
||||
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
v-if="iconName"
|
||||
:icon="iconName"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
data-test="icon"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success' : iconColor,
|
||||
iconPositionClass,
|
||||
]"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
modelValue?: string | null | undefined
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
required?: boolean
|
||||
maxLength?: number | string
|
||||
minLength?: number | string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
autocomplete: 'off',
|
||||
modelValue: undefined,
|
||||
iconName: 'mdi:currency-eur',
|
||||
iconPosition: 'right',
|
||||
label: '',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
required: false,
|
||||
maxLength: undefined,
|
||||
minLength: undefined,
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localValue = ref('')
|
||||
const isFocused = ref(false)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'relative mt-4 flex h-12 w-full items-center',
|
||||
props.groupClass,
|
||||
),
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
props.inputClass,
|
||||
iconInputPaddingClass.value,
|
||||
focusPaddingClass.value,
|
||||
),
|
||||
)
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
labelPositionClass.value,
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
hasError.value
|
||||
? 'text-m-error'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
const describedBy = computed(() => {
|
||||
if (!props.hint && !hasError.value && !hasSuccess.value) return undefined
|
||||
return `${inputId.value}-describedby`
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const normalizeAmount = (value: string) => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Keep the DOM input value, local state, and v-model emission in sync.
|
||||
const updateValue = (target: HTMLInputElement, value: string) => {
|
||||
target.value = value
|
||||
if (!isControlled.value) {
|
||||
localValue.value = value
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Normalize while typing so the field never keeps invalid amount characters.
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
updateValue(target, normalizeAmount(target.value))
|
||||
}
|
||||
|
||||
// Keep the blur handler only for focus-driven UI state.
|
||||
const onBlur = () => {
|
||||
isFocused.value = false
|
||||
}
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
if (!props.iconName) return ''
|
||||
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : ''
|
||||
})
|
||||
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-8'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-2' : 'right-2'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-label {
|
||||
background: white;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.grow-height {
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||
}
|
||||
|
||||
.grow-height:focus {
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grow-height { transition: none; }
|
||||
}
|
||||
</style>
|
||||
+2
-35
@@ -6,13 +6,9 @@ import InputNumber from './InputNumber.vue'
|
||||
type InputNumberProps = {
|
||||
modelValue?: string | null
|
||||
label?: string
|
||||
required?: boolean
|
||||
readonly?: boolean
|
||||
min?: number | string
|
||||
max?: number | string
|
||||
error?: string
|
||||
hint?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
|
||||
@@ -30,11 +26,11 @@ const mountInputNumber = (props: InputNumberProps = {}) =>
|
||||
})
|
||||
|
||||
describe('MalioInputNumber', () => {
|
||||
it('renders the input with a fixed 22px height', () => {
|
||||
it('renders the input with a fixed 20px height', () => {
|
||||
const wrapper = mountInputNumber()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
expect(input.classes()).toContain('h-[22px]')
|
||||
expect(input.classes()).toContain('h-[20px]')
|
||||
})
|
||||
|
||||
it('renders the increment and decrement buttons with a fixed 20px height', () => {
|
||||
@@ -166,33 +162,4 @@ describe('MalioInputNumber', () => {
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
|
||||
expect(input.element.value).toBe('5')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
@@ -1,86 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :class="mergedGroupClass" >
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isMinusDisabled"
|
||||
@click="decrement"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:minus"
|
||||
:class="mergedButtonMinusClass"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:style="inputWidthStyle"
|
||||
:value="displayedValue"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isPlusDisabled"
|
||||
@click="increment"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:plus"
|
||||
:class="mergedButtonPlusClass"
|
||||
/>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
<div :class="mergedGroupClass" >
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
</p>
|
||||
{{ label }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isMinusDisabled"
|
||||
@click="decrement"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:minus"
|
||||
:class="mergedButtonMinusClass"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:style="inputWidthStyle"
|
||||
:value="displayedValue"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isPlusDisabled"
|
||||
@click="increment"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:plus"
|
||||
:class="mergedButtonPlusClass"
|
||||
/>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -98,7 +89,6 @@ const props = withDefaults(
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -116,7 +106,6 @@ const props = withDefaults(
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -170,6 +159,7 @@ const inputWidthStyle = computed(() => ({
|
||||
maxWidth: '100%',
|
||||
}))
|
||||
|
||||
|
||||
const isMinusDisabled = computed(() =>
|
||||
props.disabled || currentNumericValue.value <= minValue.value,
|
||||
)
|
||||
@@ -178,56 +168,51 @@ const isPlusDisabled = computed(() =>
|
||||
props.disabled || currentNumericValue.value >= maxValue.value,
|
||||
|
||||
)
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'relative flex h-12 w-full items-center',
|
||||
'relative mt-4 flex h-12 w-full items-center',
|
||||
props.groupClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
' peer h-[20px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'cursor-pointer text-black mr-4 text-[18px]',
|
||||
hasError.value ? 'text-m-danger' : '',
|
||||
'radio-text mt-px cursor-pointer text-black mr-3',
|
||||
hasError.value ? 'text-m-error' : '',
|
||||
hasSuccess.value ? 'text-m-success' : '',
|
||||
props.disabled ? 'cursor-not-allowed text-m-muted' : '',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60' : '',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedButtonMinusClass = computed(() =>
|
||||
twMerge(
|
||||
'h-[22px] w-[40px] border border-black rounded-s-[3px]',
|
||||
'h-[20px] w-[30px] border border-black rounded-s-[3px]',
|
||||
isMinusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
||||
hasError.value
|
||||
? 'border-m-danger'
|
||||
? 'border-m-error'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success'
|
||||
: '',
|
||||
),
|
||||
)
|
||||
|
||||
const mergedButtonPlusClass = computed(() =>
|
||||
twMerge(
|
||||
'h-[22px] w-[40px] border border-black rounded-e-[3px]',
|
||||
'h-[20px] w-[30px] border border-black rounded-e-[3px]',
|
||||
isPlusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
||||
hasError.value
|
||||
? 'border-m-danger'
|
||||
? 'border-m-error'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success'
|
||||
: '',
|
||||
@@ -1,64 +1,67 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:class="mergedGroupClass"
|
||||
<div
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="mask"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:minlength="minLength"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="mask"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:minlength="minLength"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
v-if="iconName"
|
||||
:icon="iconName"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
data-test="icon"
|
||||
:class="[iconStateClass, iconPositionClass]"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
<IconifyIcon
|
||||
v-if="iconName"
|
||||
:icon="iconName"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
data-test="icon"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
</p>
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success' : iconColor,
|
||||
iconPositionClass,
|
||||
]"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -68,13 +71,9 @@ import {vMaska} from 'maska/vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -99,7 +98,6 @@ const props = withDefaults(
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
mask?: string | MaskInputOptions
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -123,7 +121,6 @@ const props = withDefaults(
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
mask: undefined,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -135,57 +132,42 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isReadonly.value
|
||||
? isFilled.value
|
||||
: isFocused.value || currentValue.value.length > 0,
|
||||
)
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'relative flex h-12 w-full items-center',
|
||||
'relative mt-4 flex h-12 w-full items-center',
|
||||
props.groupClass,
|
||||
),
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
: 'focus:border-m-primary',
|
||||
props.inputClass,
|
||||
iconInputPaddingClass.value,
|
||||
isReadonly.value ? '' : focusPaddingClass.value,
|
||||
focusPaddingClass.value,
|
||||
),
|
||||
)
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
labelPositionClass.value,
|
||||
shouldFloatLabel.value
|
||||
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||
: '',
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
? 'text-m-error'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: disabled.value
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -218,7 +200,7 @@ const iconInputPaddingClass = computed(() => {
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-8'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
@@ -228,19 +210,9 @@ const focusPaddingClass = computed(() => {
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-2' : 'right-2'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
|
||||
const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (disabled.value) return props.iconColor
|
||||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
if (isFocused.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return props.iconColor
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
+5
-89
@@ -21,7 +21,6 @@ type InputTextAreaProps = {
|
||||
error?: string
|
||||
success?: string
|
||||
rounded?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
|
||||
@@ -119,9 +118,9 @@ describe('MalioInputTextArea', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('textarea').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
|
||||
expect(wrapper.get('textarea').classes()).toContain('border-m-error')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Textarea error')
|
||||
expect(wrapper.get('textarea').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
@@ -146,91 +145,8 @@ describe('MalioInputTextArea', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('textarea').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('textarea').classes()).toContain('border-m-error')
|
||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
|
||||
})
|
||||
|
||||
it('renders as a single root element (works as a single grid item)', () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const wrapper = mount(InputTextAreaForTest, {
|
||||
attachTo: host,
|
||||
})
|
||||
|
||||
// host > div[data-v-app] > component roots
|
||||
const app = host.firstElementChild as HTMLElement
|
||||
expect(app.children.length).toBe(1)
|
||||
|
||||
wrapper.unmount()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('applies primary scrollbar class on focus', async () => {
|
||||
const wrapper = mount(InputTextAreaForTest)
|
||||
|
||||
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
|
||||
|
||||
await wrapper.get('textarea').trigger('focus')
|
||||
|
||||
expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
|
||||
})
|
||||
|
||||
it('removes primary scrollbar class on blur', async () => {
|
||||
const wrapper = mount(InputTextAreaForTest)
|
||||
|
||||
await wrapper.get('textarea').trigger('focus')
|
||||
await wrapper.get('textarea').trigger('blur')
|
||||
|
||||
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', required: true}})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de bleu', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
|
||||
const field = wrapper.get('textarea')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu focus', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
// En readonly, pas de couleur primary sur le label
|
||||
expect(wrapper.get('label').classes()).not.toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true, modelValue: 'du texte'}})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
|
||||
const msg = wrapper.find('[data-test="message-line"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false}})
|
||||
expect(wrapper.find('[data-test="message-line"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false, error: 'Erreur'}})
|
||||
const msg = wrapper.find('[data-test="message-line"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Textarea error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative mt-4 w-full"
|
||||
>
|
||||
<textarea
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
|
||||
:autocomplete="autocomplete"
|
||||
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 overflow-auto"
|
||||
:class="[
|
||||
isFilled ? 'border-black' : 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
||||
hasError
|
||||
? 'border-m-error focus:border-m-error focus:pl-[11px]'
|
||||
: hasSuccess
|
||||
? 'border-m-success focus:border-m-success focus:pl-[11px]'
|
||||
: 'focus:border-m-primary focus:pl-[11px]',
|
||||
textInput,
|
||||
showCounterComputed ? 'pb-6' : '',
|
||||
rounded,
|
||||
]"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:rows="rowsCount"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:style="textareaStyle"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
|
||||
:class="[
|
||||
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
|
||||
disabled ? 'text-black/60' : '',
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
|
||||
textLabel,
|
||||
]"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<span
|
||||
v-if="showCounterComputed"
|
||||
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
|
||||
>
|
||||
{{ currentLength }}/{{ maxLength }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasError || hasSuccess || hint"
|
||||
class="mt-1 flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<p
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'ml-[2px]',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
|
||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
modelValue?: string | null | undefined
|
||||
size?: number | string
|
||||
textInput?: string
|
||||
textLabel?: string
|
||||
resize?: 'none' | 'both' | 'horizontal' | 'vertical'
|
||||
minResizeWidth?: number
|
||||
maxResizeWidth?: number
|
||||
minResizeHeight?: number
|
||||
maxResizeHeight?: number
|
||||
required?: boolean
|
||||
maxLength?: number
|
||||
showCounter?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
rounded?: string
|
||||
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
autocomplete: 'off',
|
||||
modelValue: undefined,
|
||||
label: '',
|
||||
size: 2,
|
||||
textInput: 'text-lg',
|
||||
required: false,
|
||||
maxLength: 800,
|
||||
showCounter: false,
|
||||
readonly: false,
|
||||
textLabel: 'text-sm',
|
||||
disabled: false,
|
||||
rounded: 'rounded-md',
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
resize: 'both',
|
||||
minResizeWidth: 280,
|
||||
maxResizeWidth: 640,
|
||||
minResizeHeight: 40,
|
||||
maxResizeHeight: 320,
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localValue = ref('')
|
||||
const isFocused = ref(false)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const rowsCount = computed(() => Math.max(1, Number(props.size || 3)))
|
||||
const currentLength = computed(() => (currentValue.value ?? '').length)
|
||||
const showCounterComputed = computed(() =>
|
||||
props.showCounter && Number(props.maxLength) > 0
|
||||
)
|
||||
const toCssSize = (value: number | string) => (typeof value === 'number' ? `${value}px` : value)
|
||||
const textareaStyle = computed(() => ({
|
||||
resize: props.resize,
|
||||
minWidth: toCssSize(props.minResizeWidth),
|
||||
maxWidth: toCssSize(props.maxResizeWidth),
|
||||
minHeight: toCssSize(props.minResizeHeight),
|
||||
maxHeight: toCssSize(props.maxResizeHeight),
|
||||
}))
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
const describedBy = computed(() =>
|
||||
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
if (!isControlled.value) {
|
||||
localValue.value = target.value
|
||||
}
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-label {
|
||||
background: white;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
</style>
|
||||
+3
-42
@@ -112,8 +112,8 @@ describe('MalioRadioButton', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('.radio-message').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('.radio-message').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('MalioRadioButton', () => {
|
||||
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
|
||||
expect(wrapper.get('.radio-control').classes()).not.toContain('is-success')
|
||||
expect(wrapper.get('.radio-message').text()).toBe('Selection required')
|
||||
expect(wrapper.get('.radio-message').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('.radio-message').classes()).toContain('text-m-error')
|
||||
})
|
||||
|
||||
it('merges custom classes on group, input and label', () => {
|
||||
@@ -153,43 +153,4 @@ describe('MalioRadioButton', () => {
|
||||
expect(wrapper.get('input').classes()).toContain('border-red-500')
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('font-bold')
|
||||
})
|
||||
|
||||
it('uses muted label color and muted border when unchecked', () => {
|
||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'b'})
|
||||
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-muted')
|
||||
})
|
||||
|
||||
it('uses black label color when checked', () => {
|
||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
|
||||
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('has checked:border-black on input', () => {
|
||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
|
||||
|
||||
expect(wrapper.get('input').classes()).toContain('checked:border-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountRadioButton({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountRadioButton({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
||||
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
|
||||
|
||||
await wrapper.get('input').trigger('change')
|
||||
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
{{ label }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -44,9 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {computed, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||
|
||||
@@ -87,13 +86,9 @@ const props = withDefaults(
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localValue = ref<string | number | boolean | null | undefined>(undefined)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const isChecked = computed(() =>
|
||||
isControlled.value ? props.modelValue === props.value : localValue.value === props.value,
|
||||
)
|
||||
const isChecked = computed(() => props.modelValue === props.value)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const disabled = computed(() => props.disabled)
|
||||
@@ -122,16 +117,15 @@ const mergedControlClass = computed(() =>
|
||||
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-m-muted checked:border-black',
|
||||
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-black',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'radio-text mt-px cursor-pointer',
|
||||
isChecked.value ? 'text-black' : 'text-m-muted',
|
||||
hasError.value ? 'text-m-danger' : '',
|
||||
'radio-text mt-px cursor-pointer text-black',
|
||||
hasError.value ? 'text-m-error' : '',
|
||||
hasSuccess.value ? 'text-m-success' : '',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||
props.labelClass,
|
||||
@@ -142,7 +136,7 @@ const mergedMessageClass = computed(() =>
|
||||
twMerge(
|
||||
'radio-message ml-3 -mt-1 text-xs',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
? 'text-m-error'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
@@ -166,10 +160,6 @@ const onChange = (event: Event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = props.value
|
||||
}
|
||||
|
||||
emit('update:modelValue', props.value)
|
||||
}
|
||||
</script>
|
||||
@@ -179,17 +169,12 @@ const onChange = (event: Event) => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.radio-control input[type='radio']:focus-visible {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.radio-control.is-error input[type='radio'] {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
border-color: rgb(var(--m-error) / 1);
|
||||
}
|
||||
|
||||
.radio-control.is-error .radio-dot {
|
||||
color: rgb(var(--m-danger) / 1);
|
||||
color: rgb(var(--m-error) / 1);
|
||||
}
|
||||
|
||||
.radio-control.is-success input[type='radio'] {
|
||||
@@ -0,0 +1,177 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import Select from './Select.vue'
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: string | number | null
|
||||
}
|
||||
|
||||
type SelectProps = {
|
||||
modelValue?: string | number | null
|
||||
options?: Option[]
|
||||
emptyOptionLabel?: string
|
||||
label?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
minWidth?: string
|
||||
maxWidth?: string
|
||||
textField?: string
|
||||
textValue?: string
|
||||
textLabel?: string
|
||||
rounded?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const SelectForTest = Select as DefineComponent<SelectProps>
|
||||
|
||||
const options: Option[] = [
|
||||
{label: 'France', value: 'fr'},
|
||||
{label: 'Belgique', value: 'be'},
|
||||
{label: 'Canada', value: 'ca'},
|
||||
]
|
||||
|
||||
describe('MalioSelect', () => {
|
||||
it('renders the label text', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Country'},
|
||||
})
|
||||
|
||||
expect(wrapper.get('label').text()).toBe('Country')
|
||||
})
|
||||
|
||||
it('generates button and listbox ids and links them together', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
const button = wrapper.get('button')
|
||||
expect(button.attributes('id')?.startsWith('custom-select-btn-')).toBe(true)
|
||||
expect(button.attributes('aria-controls')?.startsWith('custom-select-listbox-')).toBe(true)
|
||||
|
||||
await button.trigger('click')
|
||||
|
||||
expect(wrapper.get('ul').attributes('id')).toBe(button.attributes('aria-controls'))
|
||||
})
|
||||
|
||||
it('uses disabled styles and prevents opening when disabled', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, disabled: true, options},
|
||||
})
|
||||
|
||||
const button = wrapper.get('button')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(button.classes()).toContain('cursor-not-allowed')
|
||||
|
||||
await button.trigger('click')
|
||||
|
||||
expect(wrapper.find('ul').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the list and rotates the icon on click', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.get('ul').exists()).toBe(true)
|
||||
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
|
||||
expect(wrapper.get('svg').classes()).toContain('rotate-180')
|
||||
})
|
||||
|
||||
it('emits update:modelValue when selecting an option', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.findAll('li')[2].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
|
||||
})
|
||||
|
||||
it('renders the empty option with muted text style', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {
|
||||
modelValue: null,
|
||||
options,
|
||||
emptyOptionLabel: 'Aucune selection',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
const firstOption = wrapper.findAll('li')[0]
|
||||
expect(firstOption.text()).toBe('Aucune selection')
|
||||
expect(firstOption.classes()).toContain('text-black/40')
|
||||
})
|
||||
|
||||
it('shows the selected value text when an option is selected', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {
|
||||
options,
|
||||
modelValue: 'fr',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('France')
|
||||
expect(wrapper.get('button').classes()).toContain('border-black')
|
||||
})
|
||||
|
||||
it('shows hint message in muted color', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, hint: 'Select a country'},
|
||||
})
|
||||
|
||||
expect(wrapper.get('p.text-m-muted').text()).toBe('Select a country')
|
||||
})
|
||||
|
||||
it('shows error state on button, label and helper text', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {
|
||||
modelValue: null,
|
||||
options,
|
||||
label: 'Country',
|
||||
error: 'Selection error',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('border-m-error')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Selection error')
|
||||
expect(wrapper.get('button').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows success state on button, label and helper text', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {
|
||||
modelValue: null,
|
||||
options,
|
||||
label: 'Country',
|
||||
success: 'Selection success',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('border-m-success')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-success')
|
||||
expect(wrapper.get('p.text-m-success').text()).toBe('Selection success')
|
||||
})
|
||||
|
||||
it('prioritizes error over success', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {
|
||||
modelValue: null,
|
||||
options,
|
||||
error: 'Selection error',
|
||||
success: 'Selection success',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('border-m-error')
|
||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||
expect(wrapper.get('p.text-m-error').text()).toBe('Selection error')
|
||||
})
|
||||
})
|
||||
@@ -1,60 +1,44 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="root"
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<div
|
||||
ref="root"
|
||||
class="relative mt-4 w-full"
|
||||
:class="[minWidth, maxWidth]"
|
||||
>
|
||||
<button
|
||||
:id="buttonId"
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none"
|
||||
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
:class="[
|
||||
isReadonly ? '' : 'grow-height',
|
||||
isReadonly ? '' : 'focus-visible:border-m-primary',
|
||||
hasError
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
|
||||
: 'border-m-danger'
|
||||
? 'rounded-b-none !border-2 !border-m-error !border-b-0'
|
||||
: 'rounded-t-none !border-2 !border-m-error !border-t-0'
|
||||
: 'border-m-error'
|
||||
: hasSuccess
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
|
||||
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
|
||||
: 'border-m-success'
|
||||
: isReadonly
|
||||
? 'border-black'
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
|
||||
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
|
||||
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||
rounded,
|
||||
textField,
|
||||
keyboardFocused
|
||||
? (isOpen
|
||||
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||
: 'm-focus-ring-kbd')
|
||||
: '',
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@focus="onKbdFocus"
|
||||
@blur="onKbdBlur"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -62,57 +46,39 @@
|
||||
:class="[
|
||||
isOpen ? 'top-2 z-30' : 'top-2',
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: disabled
|
||||
? 'text-m-muted'
|
||||
: isReadonly
|
||||
? isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
textLabel,
|
||||
]"
|
||||
:style="labelTransformStyle"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<span
|
||||
class="block truncate"
|
||||
:class="[
|
||||
textValue,
|
||||
isOptionSelected ? (disabled ? 'text-black/60' : 'text-black') : 'select-none text-transparent'
|
||||
isOptionSelected ? 'text-black' : 'select-none text-transparent'
|
||||
]"
|
||||
>
|
||||
{{ selectedLabel || '\u00A0' }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!disabled"
|
||||
data-test="chevron"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: disabled
|
||||
? 'text-m-muted'
|
||||
: isReadonly
|
||||
? isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted'
|
||||
: 'text-current'
|
||||
]"
|
||||
>
|
||||
<slot name="icon">
|
||||
@@ -132,36 +98,25 @@
|
||||
ref="listRef"
|
||||
role="listbox"
|
||||
:aria-labelledby="buttonId"
|
||||
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border bg-white"
|
||||
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
|
||||
:class="[
|
||||
openDirection === 'down'
|
||||
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
||||
: 'bottom-[calc(100%-4px)] rounded-t-md border-b-0',
|
||||
? 'top-[calc(100%-2px)] rounded-b-md border-t-0'
|
||||
: 'bottom-[calc(100%-2px)] rounded-t-md border-b-0',
|
||||
hasError
|
||||
? 'select-scrollbar-error'
|
||||
: hasSuccess
|
||||
? 'select-scrollbar-success'
|
||||
: 'select-scrollbar-primary',
|
||||
hasError
|
||||
? 'border-m-danger'
|
||||
? 'border-m-error'
|
||||
: hasSuccess
|
||||
? 'border-m-success'
|
||||
: 'border-m-primary',
|
||||
keyboardFocused
|
||||
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||
: '',
|
||||
: 'border-m-primary'
|
||||
]"
|
||||
>
|
||||
<li
|
||||
v-if="normalizedOptions.length === 0"
|
||||
class="px-3 py-2 text-m-muted"
|
||||
data-test="no-options-text"
|
||||
>
|
||||
{{ noOptionsText }}
|
||||
</li>
|
||||
<li
|
||||
v-for="(opt, index) in normalizedOptions"
|
||||
v-else
|
||||
:id="optionId(index)"
|
||||
:key="String(opt.value)"
|
||||
role="option"
|
||||
@@ -179,36 +134,29 @@
|
||||
{{ opt.label || '\u00A0' }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${buttonId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${buttonId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-error'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number | null
|
||||
@@ -221,17 +169,13 @@ const props = withDefaults(defineProps<{
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
minWidth?: string
|
||||
maxWidth?: string
|
||||
textField?: string
|
||||
textValue?: string
|
||||
textLabel?: string
|
||||
fieldClass?: string
|
||||
rounded?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
groupClass?: string
|
||||
noOptionsText?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(), {
|
||||
options: () => [],
|
||||
emptyOptionLabel: '',
|
||||
@@ -239,24 +183,19 @@ const props = withDefaults(defineProps<{
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
minWidth: 'w-96',
|
||||
maxWidth: '',
|
||||
textField: 'text-lg',
|
||||
textValue: 'text-lg',
|
||||
textLabel: 'text-sm',
|
||||
fieldClass: '',
|
||||
rounded: 'rounded-md',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
groupClass: '',
|
||||
noOptionsText: 'Aucune option disponible',
|
||||
required: false,
|
||||
reserveMessageSpace: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string | number | null): void
|
||||
}>()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
const openDirection = ref<'down' | 'up'>('down')
|
||||
@@ -265,21 +204,17 @@ const buttonId = `custom-select-btn-${uid}`
|
||||
const listboxId = `custom-select-listbox-${uid}`
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const listHeight = ref(0)
|
||||
const normalizedOptions = computed<Option[]>(() => {
|
||||
if (!props.emptyOptionLabel) return props.options
|
||||
return [{label: props.emptyOptionLabel, value: null}, ...props.options]
|
||||
})
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge('relative w-full h-12 flex items-center', props.groupClass),
|
||||
)
|
||||
const normalizedOptions = computed<Option[]>(() => [
|
||||
{label: props.emptyOptionLabel, value: null},
|
||||
...props.options,
|
||||
])
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isOptionSelected = computed(() =>
|
||||
props.options.some(o => o.value === props.modelValue)
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isReadonly.value ? isOptionSelected.value : (isOpen.value || isOptionSelected.value)
|
||||
isOpen.value || isOptionSelected.value
|
||||
)
|
||||
const selectedLabel = computed(() =>
|
||||
props.options.find(o => o.value === props.modelValue)?.label ?? ''
|
||||
@@ -307,7 +242,6 @@ function updateOpenDirection() {
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (props.disabled || props.readonly) return
|
||||
updateOpenDirection()
|
||||
isOpen.value = true
|
||||
|
||||
@@ -351,7 +285,7 @@ function close() {
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.disabled) return
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
return
|
||||
@@ -362,68 +296,6 @@ function toggle() {
|
||||
function select(value: string | number | null) {
|
||||
emit('update:modelValue', value)
|
||||
close()
|
||||
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
|
||||
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
|
||||
// les options ; au clavier le focus n'a jamais quitté le bouton.
|
||||
buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
// Garde l'option active visible quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
// Tab : laisse le focus partir mais ferme la liste
|
||||
if (e.key === 'Tab') {
|
||||
if (isOpen.value) close()
|
||||
return
|
||||
}
|
||||
|
||||
// Liste fermée : ouverture au clavier
|
||||
if (!isOpen.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Liste ouverte
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const opt = normalizedOptions.value[activeIndex.value]
|
||||
if (opt) select(opt.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = normalizedOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
@@ -441,18 +313,9 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.grow-height {
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grow-height {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(ul[role="listbox"]) {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
:deep(.select-scrollbar-primary) {
|
||||
@@ -1,256 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import {nextTick} from 'vue'
|
||||
import Accordion from './Accordion.vue'
|
||||
import AccordionItem from './AccordionItem.vue'
|
||||
|
||||
const TWO_ITEMS = `
|
||||
<MalioAccordionItem title="Prix" value="prix"><p>Contenu prix</p></MalioAccordionItem>
|
||||
<MalioAccordionItem title="Catégorie" value="cat"><p>Contenu catégorie</p></MalioAccordionItem>
|
||||
`
|
||||
|
||||
function mountAccordion(props: Record<string, unknown> = {}, slot: string = TWO_ITEMS, attachTo?: HTMLElement) {
|
||||
return mount(Accordion, {
|
||||
props,
|
||||
slots: {default: slot},
|
||||
attachTo,
|
||||
global: {components: {MalioAccordionItem: AccordionItem}},
|
||||
})
|
||||
}
|
||||
|
||||
describe('MalioAccordion — rendu & mode multiple', () => {
|
||||
it('renders each item header with its title', () => {
|
||||
const wrapper = mountAccordion()
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
expect(headers).toHaveLength(2)
|
||||
expect(headers[0].text()).toContain('Prix')
|
||||
expect(headers[1].text()).toContain('Catégorie')
|
||||
})
|
||||
|
||||
it('renders the slot content of each panel', () => {
|
||||
const wrapper = mountAccordion()
|
||||
expect(wrapper.html()).toContain('Contenu prix')
|
||||
expect(wrapper.html()).toContain('Contenu catégorie')
|
||||
})
|
||||
|
||||
it('all panels are collapsed by default', () => {
|
||||
const wrapper = mountAccordion()
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||
expect(headers[1].attributes('aria-expanded')).toBe('false')
|
||||
const regions = wrapper.findAll('[role="region"]')
|
||||
expect(regions[0].classes()).toContain('grid-rows-[0fr]')
|
||||
})
|
||||
|
||||
it('opens a panel on header click (multiple mode is default)', async () => {
|
||||
const wrapper = mountAccordion()
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[0].trigger('click')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('true')
|
||||
const regions = wrapper.findAll('[role="region"]')
|
||||
expect(regions[0].classes()).toContain('grid-rows-[1fr]')
|
||||
})
|
||||
|
||||
it('keeps multiple panels open simultaneously in multiple mode', async () => {
|
||||
const wrapper = mountAccordion()
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[0].trigger('click')
|
||||
await headers[1].trigger('click')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('true')
|
||||
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('closes an open panel when its header is clicked again', async () => {
|
||||
const wrapper = mountAccordion()
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[0].trigger('click')
|
||||
await headers[0].trigger('click')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('wires aria-controls / aria-labelledby / role=region correctly', () => {
|
||||
const wrapper = mountAccordion({id: 'acc'})
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
const regions = wrapper.findAll('[role="region"]')
|
||||
expect(headers[0].attributes('id')).toBe('acc-header-prix')
|
||||
expect(headers[0].attributes('aria-controls')).toBe('acc-panel-prix')
|
||||
expect(regions[0].attributes('id')).toBe('acc-panel-prix')
|
||||
expect(regions[0].attributes('aria-labelledby')).toBe('acc-header-prix')
|
||||
})
|
||||
|
||||
it('emits update:modelValue with an array in multiple mode', async () => {
|
||||
const wrapper = mountAccordion()
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[0].trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
|
||||
await nextTick()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MalioAccordion — mode single & contrôlé', () => {
|
||||
it('opening a panel closes the others in single mode', async () => {
|
||||
const wrapper = mountAccordion({mode: 'single'})
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[0].trigger('click')
|
||||
await headers[1].trigger('click')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('emits a string in single mode', async () => {
|
||||
const wrapper = mountAccordion({mode: 'single'})
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[1].trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
|
||||
})
|
||||
|
||||
it('emits empty string when closing the open panel in single mode', async () => {
|
||||
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[0].trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
})
|
||||
|
||||
it('respects modelValue array in controlled multiple mode', () => {
|
||||
const wrapper = mountAccordion({modelValue: ['cat']})
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('respects modelValue string in controlled single mode', () => {
|
||||
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('true')
|
||||
expect(headers[1].attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('does not mutate local state in controlled mode (emits only)', async () => {
|
||||
const wrapper = mountAccordion({modelValue: []})
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[0].trigger('click')
|
||||
// état piloté par le parent : sans mise à jour de la prop, reste fermé
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
|
||||
const WITH_DEFAULT_OPEN = `
|
||||
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
|
||||
<MalioAccordionItem title="Catégorie" value="cat" :default-open="true"><p>C</p></MalioAccordionItem>
|
||||
`
|
||||
const WITH_DISABLED = `
|
||||
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
|
||||
<MalioAccordionItem title="Catégorie" value="cat" :disabled="true"><p>C</p></MalioAccordionItem>
|
||||
`
|
||||
|
||||
it('opens defaultOpen items initially in uncontrolled mode', async () => {
|
||||
const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN)
|
||||
await nextTick()
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('sets disabled and aria-disabled on a disabled item', () => {
|
||||
const wrapper = mountAccordion({}, WITH_DISABLED)
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
expect(headers[1].attributes('disabled')).toBeDefined()
|
||||
expect(headers[1].attributes('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('does not toggle a disabled item on click', async () => {
|
||||
const wrapper = mountAccordion({}, WITH_DISABLED)
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
await headers[1].trigger('click')
|
||||
expect(headers[1].attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves focus to the next header on ArrowDown', async () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const wrapper = mountAccordion({}, TWO_ITEMS, root)
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
;(headers[0].element as HTMLElement).focus()
|
||||
await headers[0].trigger('keydown', {key: 'ArrowDown'})
|
||||
expect(document.activeElement).toBe(headers[1].element)
|
||||
wrapper.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('wraps focus to the first header on ArrowDown from the last', async () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const wrapper = mountAccordion({}, TWO_ITEMS, root)
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
;(headers[1].element as HTMLElement).focus()
|
||||
await headers[1].trigger('keydown', {key: 'ArrowDown'})
|
||||
expect(document.activeElement).toBe(headers[0].element)
|
||||
wrapper.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('moves focus to the previous header on ArrowUp', async () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const wrapper = mountAccordion({}, TWO_ITEMS, root)
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
;(headers[1].element as HTMLElement).focus()
|
||||
await headers[1].trigger('keydown', {key: 'ArrowUp'})
|
||||
expect(document.activeElement).toBe(headers[0].element)
|
||||
wrapper.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('skips disabled headers during keyboard navigation', async () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const slot = `
|
||||
<MalioAccordionItem title="A" value="a"><p>A</p></MalioAccordionItem>
|
||||
<MalioAccordionItem title="B" value="b" :disabled="true"><p>B</p></MalioAccordionItem>
|
||||
<MalioAccordionItem title="C" value="c"><p>C</p></MalioAccordionItem>
|
||||
`
|
||||
const wrapper = mountAccordion({}, slot, root)
|
||||
const headers = wrapper.findAll('button[aria-expanded]')
|
||||
;(headers[0].element as HTMLElement).focus()
|
||||
await headers[0].trigger('keydown', {key: 'ArrowDown'})
|
||||
// saute le header désactivé (B) pour aller directement à C
|
||||
expect(document.activeElement).toBe(headers[2].element)
|
||||
wrapper.unmount()
|
||||
root.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
|
||||
const ONE = `<MalioAccordionItem title="A" value="a"><p>contenu</p></MalioAccordionItem>`
|
||||
const ONE_OPEN = `<MalioAccordionItem title="A" value="a" :default-open="true"><p>contenu</p></MalioAccordionItem>`
|
||||
|
||||
it('clips the panel (overflow-hidden) while collapsed', () => {
|
||||
const wrapper = mountAccordion({}, ONE)
|
||||
const inner = wrapper.find('[role="region"] > div')
|
||||
expect(inner.classes()).toContain('overflow-hidden')
|
||||
expect(inner.classes()).not.toContain('overflow-visible')
|
||||
})
|
||||
|
||||
it('lets the panel overflow once open at mount (defaultOpen)', async () => {
|
||||
const wrapper = mountAccordion({}, ONE_OPEN)
|
||||
await nextTick()
|
||||
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
|
||||
})
|
||||
|
||||
it('switches to overflow-visible after the open transition ends', async () => {
|
||||
const wrapper = mountAccordion({}, ONE)
|
||||
await wrapper.find('button[aria-expanded]').trigger('click')
|
||||
await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
|
||||
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
|
||||
})
|
||||
|
||||
it('re-clips (overflow-hidden) as soon as it closes', async () => {
|
||||
const wrapper = mountAccordion({}, ONE_OPEN)
|
||||
await nextTick()
|
||||
await wrapper.find('button[aria-expanded]').trigger('click')
|
||||
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
<template>
|
||||
<div v-bind="$attrs" :class="rootClass">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, provide, ref, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import {accordionContextKey, type AccordionItemRegistration} from './context'
|
||||
|
||||
defineOptions({name: 'MalioAccordion', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
mode?: 'single' | 'multiple'
|
||||
modelValue?: string | string[]
|
||||
id?: string
|
||||
groupClass?: string
|
||||
}>(), {
|
||||
mode: 'multiple',
|
||||
modelValue: undefined,
|
||||
id: '',
|
||||
groupClass: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[]): void
|
||||
}>()
|
||||
|
||||
const generatedId = useId()
|
||||
const baseId = computed(() => props.id || `malio-accordion-${generatedId}`)
|
||||
const mode = computed(() => props.mode)
|
||||
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const localOpen = ref<string[]>([])
|
||||
|
||||
const items = ref<AccordionItemRegistration[]>([])
|
||||
|
||||
const openKeys = computed<string[]>(() => {
|
||||
if (isControlled.value) {
|
||||
const v = props.modelValue
|
||||
if (props.mode === 'single') return v ? [v as string] : []
|
||||
if (Array.isArray(v)) return v
|
||||
return v ? [v as string] : []
|
||||
}
|
||||
return localOpen.value
|
||||
})
|
||||
|
||||
function isOpen(value: string) {
|
||||
return openKeys.value.includes(value)
|
||||
}
|
||||
|
||||
function toggle(value: string) {
|
||||
const current = openKeys.value
|
||||
let next: string[]
|
||||
if (props.mode === 'single') {
|
||||
next = current.includes(value) ? [] : [value]
|
||||
} else {
|
||||
next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value]
|
||||
}
|
||||
if (!isControlled.value) {
|
||||
localOpen.value = next
|
||||
}
|
||||
emit('update:modelValue', props.mode === 'single' ? (next[0] ?? '') : next)
|
||||
}
|
||||
|
||||
function register(item: AccordionItemRegistration, defaultOpen: boolean) {
|
||||
items.value.push(item)
|
||||
if (defaultOpen && !isControlled.value) {
|
||||
if (props.mode === 'single') {
|
||||
if (localOpen.value.length === 0) localOpen.value = [item.value]
|
||||
} else if (!localOpen.value.includes(item.value)) {
|
||||
localOpen.value.push(item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unregister(value: string) {
|
||||
items.value = items.value.filter(i => i.value !== value)
|
||||
}
|
||||
|
||||
// `items` est ordonné par ordre de montage (= ordre du DOM pour des sections
|
||||
// statiques/ajoutées en fin). Si un consommateur réordonne dynamiquement les
|
||||
// items, cet ordre peut diverger de l'ordre visuel ; trier par position DOM
|
||||
// serait alors nécessaire (hors périmètre v1).
|
||||
function focusSibling(value: string, offset: 1 | -1) {
|
||||
const enabled = items.value.filter(i => !i.isDisabled())
|
||||
const idx = enabled.findIndex(i => i.value === value)
|
||||
if (idx === -1) return
|
||||
const next = enabled[(idx + offset + enabled.length) % enabled.length]
|
||||
next?.getHeaderEl()?.focus()
|
||||
}
|
||||
|
||||
const rootClass = computed(() =>
|
||||
twMerge('divide-y divide-black border-y border-black', props.groupClass),
|
||||
)
|
||||
|
||||
provide(accordionContextKey, {
|
||||
mode,
|
||||
baseId,
|
||||
isOpen,
|
||||
toggle,
|
||||
register,
|
||||
unregister,
|
||||
focusSibling,
|
||||
})
|
||||
</script>
|
||||
@@ -1,48 +0,0 @@
|
||||
import {describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import Accordion from './Accordion.vue'
|
||||
import AccordionItem from './AccordionItem.vue'
|
||||
|
||||
function mountInAccordion(slot: string, accordionProps: Record<string, unknown> = {}) {
|
||||
return mount(Accordion, {
|
||||
props: accordionProps,
|
||||
slots: {default: slot},
|
||||
global: {components: {MalioAccordionItem: AccordionItem}},
|
||||
})
|
||||
}
|
||||
|
||||
describe('MalioAccordionItem', () => {
|
||||
it('throws when used outside MalioAccordion', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow(
|
||||
/à l'intérieur de MalioAccordion/,
|
||||
)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('generates an auto id-based value and still toggles when value prop is omitted', async () => {
|
||||
const wrapper = mountInAccordion(
|
||||
`<MalioAccordionItem title="Sans value"><p>X</p></MalioAccordionItem>`,
|
||||
)
|
||||
const header = wrapper.find('button[aria-expanded]')
|
||||
expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/)
|
||||
await header.trigger('click')
|
||||
expect(header.attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('applies headerClass and panelClass overrides via twMerge', () => {
|
||||
const wrapper = mountInAccordion(
|
||||
`<MalioAccordionItem title="T" value="t" header-class="bg-red-500" panel-class="text-lg"><p>X</p></MalioAccordionItem>`,
|
||||
)
|
||||
const header = wrapper.find('button[aria-expanded]')
|
||||
expect(header.classes()).toContain('bg-red-500')
|
||||
expect(wrapper.find('[role="region"]').html()).toContain('text-lg')
|
||||
})
|
||||
|
||||
it('renders a rotating chevron icon', () => {
|
||||
const wrapper = mountInAccordion(
|
||||
`<MalioAccordionItem title="T" value="t"><p>X</p></MalioAccordionItem>`,
|
||||
)
|
||||
expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="m-0">
|
||||
<button
|
||||
:id="headerId"
|
||||
ref="headerRef"
|
||||
type="button"
|
||||
:class="headerClasses"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="panelId"
|
||||
:disabled="disabled"
|
||||
:aria-disabled="disabled || undefined"
|
||||
@click="onToggle"
|
||||
@keydown.down.prevent="ctx.focusSibling(value, 1)"
|
||||
@keydown.up.prevent="ctx.focusSibling(value, -1)"
|
||||
>
|
||||
<span>{{ title }}</span>
|
||||
<IconifyIcon
|
||||
icon="mdi:chevron-down"
|
||||
:width="24"
|
||||
class="shrink-0 transition-transform duration-200"
|
||||
:class="open ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
</h3>
|
||||
<div
|
||||
:id="panelId"
|
||||
role="region"
|
||||
:aria-labelledby="headerId"
|
||||
class="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
:class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||
@transitionend="onPanelTransitionEnd"
|
||||
>
|
||||
<div
|
||||
:class="overflowVisible ? 'overflow-visible' : 'overflow-hidden'"
|
||||
:inert="!open || undefined"
|
||||
>
|
||||
<div :class="panelInnerClass">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, inject, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import {accordionContextKey} from './context'
|
||||
|
||||
defineOptions({name: 'MalioAccordionItem', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
value?: string
|
||||
defaultOpen?: boolean
|
||||
disabled?: boolean
|
||||
headerClass?: string
|
||||
panelClass?: string
|
||||
}>(), {
|
||||
value: '',
|
||||
defaultOpen: false,
|
||||
disabled: false,
|
||||
headerClass: '',
|
||||
panelClass: '',
|
||||
})
|
||||
|
||||
const ctx = inject(accordionContextKey)
|
||||
if (!ctx) {
|
||||
throw new Error('MalioAccordionItem doit être utilisé à l\'intérieur de MalioAccordion')
|
||||
}
|
||||
|
||||
const generatedId = useId()
|
||||
const value = computed(() => props.value || `malio-accordion-item-${generatedId}`)
|
||||
const headerRef = ref<HTMLButtonElement | null>(null)
|
||||
const headerId = computed(() => `${ctx.baseId.value}-header-${value.value}`)
|
||||
const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`)
|
||||
const open = computed(() => ctx.isOpen(value.value))
|
||||
|
||||
// Le panneau garde `overflow-hidden` pendant l'animation (clipping requis par
|
||||
// la transition grid-template-rows), puis passe en `overflow-visible` une fois
|
||||
// complètement ouvert pour qu'un popover enfant (datepicker, select…) ne soit
|
||||
// pas rogné. On re-clippe dès le début de la fermeture.
|
||||
const overflowVisible = ref(false)
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) overflowVisible.value = false
|
||||
})
|
||||
|
||||
function onPanelTransitionEnd(e: TransitionEvent) {
|
||||
if (e.propertyName === 'grid-template-rows' && open.value) {
|
||||
overflowVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onToggle() {
|
||||
if (props.disabled) return
|
||||
ctx.toggle(value.value)
|
||||
}
|
||||
|
||||
const headerClasses = computed(() =>
|
||||
twMerge(
|
||||
'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[20px] text-left font-[600] text-[20px] transition-colors',
|
||||
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
|
||||
props.headerClass,
|
||||
),
|
||||
)
|
||||
|
||||
const panelInnerClass = computed(() => twMerge('px-7 pt-[10px] pb-[20px]', props.panelClass))
|
||||
|
||||
onMounted(() => {
|
||||
ctx.register(
|
||||
{
|
||||
value: value.value,
|
||||
getHeaderEl: () => headerRef.value,
|
||||
isDisabled: () => props.disabled,
|
||||
},
|
||||
props.defaultOpen,
|
||||
)
|
||||
// Ouvert au montage (defaultOpen / contrôlé) : pas d'animation, overflow visible direct.
|
||||
if (open.value) overflowVisible.value = true
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => ctx.unregister(value.value))
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
import type {ComputedRef, InjectionKey} from 'vue'
|
||||
|
||||
export interface AccordionItemRegistration {
|
||||
value: string
|
||||
getHeaderEl: () => HTMLElement | null
|
||||
isDisabled: () => boolean
|
||||
}
|
||||
|
||||
export interface AccordionContext {
|
||||
mode: ComputedRef<'single' | 'multiple'>
|
||||
baseId: ComputedRef<string>
|
||||
isOpen: (value: string) => boolean
|
||||
toggle: (value: string) => void
|
||||
register: (item: AccordionItemRegistration, defaultOpen: boolean) => void
|
||||
unregister: (value: string) => void
|
||||
focusSibling: (value: string, offset: 1 | -1) => void
|
||||
}
|
||||
|
||||
export const accordionContextKey: InjectionKey<AccordionContext> = Symbol('MalioAccordion')
|
||||
@@ -1,218 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import Button from './Button.vue'
|
||||
|
||||
type ButtonProps = {
|
||||
id?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
buttonClass?: string
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
}
|
||||
|
||||
const ButtonForTest = Button as DefineComponent<ButtonProps>
|
||||
|
||||
const mountComponent = (props: ButtonProps = {}, slots?: Record<string, string>) =>
|
||||
mount(ButtonForTest, {
|
||||
props,
|
||||
slots,
|
||||
global: {
|
||||
stubs: {
|
||||
IconifyIcon: {
|
||||
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('MalioButton', () => {
|
||||
it('renders a button with label', () => {
|
||||
const wrapper = mountComponent({ label: 'Valider' })
|
||||
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Valider')
|
||||
})
|
||||
|
||||
it('renders slot content over label prop', () => {
|
||||
const wrapper = mountComponent({ label: 'Prop' }, { default: 'Slot content' })
|
||||
|
||||
expect(wrapper.text()).toContain('Slot content')
|
||||
expect(wrapper.text()).not.toContain('Prop')
|
||||
})
|
||||
|
||||
it('uses provided id on button', () => {
|
||||
const wrapper = mountComponent({ id: 'custom-id' })
|
||||
|
||||
expect(wrapper.get('button').attributes('id')).toBe('custom-id')
|
||||
})
|
||||
|
||||
it('generates an id when missing', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const buttonId = wrapper.get('button').attributes('id')
|
||||
expect(buttonId?.startsWith('malio-button-')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets type="button" on the button', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').attributes('type')).toBe('button')
|
||||
})
|
||||
|
||||
it('emits click event when clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit click when disabled', async () => {
|
||||
const wrapper = mountComponent({ disabled: true })
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets disabled attribute when disabled', () => {
|
||||
const wrapper = mountComponent({ disabled: true })
|
||||
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
// --- Variant: Primary (default) ---
|
||||
|
||||
it('applies primary variant by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-btn-primary')
|
||||
expect(wrapper.get('button').classes()).toContain('text-white')
|
||||
expect(wrapper.get('button').classes()).toContain('cursor-pointer')
|
||||
})
|
||||
|
||||
it('applies primary disabled styles', () => {
|
||||
const wrapper = mountComponent({ disabled: true })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-disabled')
|
||||
expect(wrapper.get('button').classes()).toContain('text-white')
|
||||
expect(wrapper.get('button').classes()).toContain('cursor-not-allowed')
|
||||
})
|
||||
|
||||
// --- Variant: Secondary ---
|
||||
|
||||
it('applies secondary variant', () => {
|
||||
const wrapper = mountComponent({ variant: 'secondary' })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-btn-secondary')
|
||||
expect(wrapper.get('button').classes()).toContain('text-white')
|
||||
})
|
||||
|
||||
it('applies secondary disabled styles', () => {
|
||||
const wrapper = mountComponent({ variant: 'secondary', disabled: true })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-disabled')
|
||||
expect(wrapper.get('button').classes()).toContain('cursor-not-allowed')
|
||||
})
|
||||
|
||||
// --- Variant: Tertiary ---
|
||||
|
||||
it('applies tertiary variant with border and no background', () => {
|
||||
const wrapper = mountComponent({ variant: 'tertiary' })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('border')
|
||||
expect(wrapper.get('button').classes()).toContain('border-m-btn-primary')
|
||||
expect(wrapper.get('button').classes()).toContain('text-m-btn-primary')
|
||||
expect(wrapper.get('button').classes()).toContain('bg-transparent')
|
||||
expect(wrapper.get('button').classes()).not.toContain('text-white')
|
||||
})
|
||||
|
||||
it('applies tertiary disabled styles with border', () => {
|
||||
const wrapper = mountComponent({ variant: 'tertiary', disabled: true })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('border')
|
||||
expect(wrapper.get('button').classes()).toContain('border-m-disabled')
|
||||
expect(wrapper.get('button').classes()).toContain('text-m-disabled')
|
||||
expect(wrapper.get('button').classes()).toContain('bg-transparent')
|
||||
})
|
||||
|
||||
// --- Variant: Danger ---
|
||||
|
||||
it('applies danger variant', () => {
|
||||
const wrapper = mountComponent({ variant: 'danger' })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-btn-danger')
|
||||
expect(wrapper.get('button').classes()).toContain('text-white')
|
||||
})
|
||||
|
||||
it('applies danger disabled styles', () => {
|
||||
const wrapper = mountComponent({ variant: 'danger', disabled: true })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-disabled')
|
||||
expect(wrapper.get('button').classes()).toContain('cursor-not-allowed')
|
||||
})
|
||||
|
||||
// --- Sizing ---
|
||||
|
||||
it('applies correct dimensions', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('w-[180px]')
|
||||
expect(wrapper.get('button').classes()).toContain('h-[38px]')
|
||||
})
|
||||
|
||||
it('applies font styles', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('text-base')
|
||||
expect(wrapper.get('button').classes()).toContain('font-bold')
|
||||
})
|
||||
|
||||
// --- buttonClass override ---
|
||||
|
||||
it('applies buttonClass', () => {
|
||||
const wrapper = mountComponent({ buttonClass: 'w-full rounded-full' })
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('w-full')
|
||||
expect(wrapper.get('button').classes()).toContain('rounded-full')
|
||||
})
|
||||
|
||||
// --- Icon ---
|
||||
|
||||
it('renders icon on the right by default', () => {
|
||||
const wrapper = mountComponent({ iconName: 'mdi:arrow-right' })
|
||||
|
||||
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders icon on the left when specified', () => {
|
||||
const wrapper = mountComponent({ iconName: 'mdi:arrow-left', iconPosition: 'left' })
|
||||
|
||||
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render icon when iconName is empty', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('passes icon name and size to icon component', () => {
|
||||
const wrapper = mount(ButtonForTest, {
|
||||
props: { iconName: 'mdi:check', iconSize: 18 },
|
||||
})
|
||||
|
||||
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||
expect(iconComponent.props('icon')).toBe('mdi:check')
|
||||
expect(iconComponent.props('width')).toBe(18)
|
||||
expect(iconComponent.props('height')).toBe(18)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
:id="buttonId"
|
||||
:class="mergedButtonClass"
|
||||
:disabled="disabled"
|
||||
type="button"
|
||||
v-bind="attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<IconifyIcon
|
||||
v-if="iconName && iconPosition === 'left'"
|
||||
:icon="iconName"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
data-test="icon-left"
|
||||
/>
|
||||
|
||||
<span><slot>{{ label }}</slot></span>
|
||||
|
||||
<IconifyIcon
|
||||
v-if="iconName && iconPosition === 'right'"
|
||||
:icon="iconName"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
data-test="icon-right"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs, useId } from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
defineOptions({ name: 'MalioButton', inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
buttonClass?: string
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
label: '',
|
||||
disabled: false,
|
||||
buttonClass: '',
|
||||
variant: 'primary',
|
||||
iconName: '',
|
||||
iconPosition: 'right',
|
||||
iconSize: 16,
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
|
||||
const buttonId = computed(() => props.id || `malio-button-${generatedId}`)
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
if (props.disabled) {
|
||||
if (props.variant === 'tertiary') {
|
||||
return 'border border-m-disabled text-m-disabled bg-transparent cursor-not-allowed'
|
||||
}
|
||||
return 'bg-m-disabled text-white cursor-not-allowed'
|
||||
}
|
||||
|
||||
switch (props.variant) {
|
||||
case 'secondary':
|
||||
return 'bg-m-btn-secondary hover:bg-m-btn-secondary-hover active:bg-m-btn-secondary-active text-white cursor-pointer'
|
||||
case 'tertiary':
|
||||
return 'border border-m-btn-primary bg-transparent text-m-btn-primary hover:border-m-btn-primary-hover hover:text-m-btn-primary-hover active:border-m-btn-primary-active active:text-m-btn-primary-active cursor-pointer'
|
||||
case 'danger':
|
||||
return 'bg-m-btn-danger hover:bg-m-btn-danger-hover active:bg-m-btn-danger-active text-white cursor-pointer'
|
||||
default:
|
||||
return 'bg-m-btn-primary hover:bg-m-btn-primary-hover active:bg-m-btn-primary-active text-white cursor-pointer'
|
||||
}
|
||||
})
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 m-focus-ring',
|
||||
variantClasses.value,
|
||||
props.buttonClass,
|
||||
),
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'click', e: MouseEvent): void
|
||||
}>()
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!props.disabled) {
|
||||
emit('click', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,151 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import ButtonIcon from './ButtonIcon.vue'
|
||||
|
||||
type ButtonIconProps = {
|
||||
id?: string
|
||||
icon: string
|
||||
ariaLabel: string
|
||||
disabled?: boolean
|
||||
buttonClass?: string
|
||||
iconSize?: string | number
|
||||
variant?: 'filled' | 'ghost'
|
||||
}
|
||||
|
||||
const ButtonIconForTest = ButtonIcon as DefineComponent<ButtonIconProps>
|
||||
|
||||
const mountComponent = (props: ButtonIconProps = {icon: 'mdi:arrow-left', ariaLabel: 'Retour'}) =>
|
||||
mount(ButtonIconForTest, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
IconifyIcon: {
|
||||
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('MalioButtonIcon', () => {
|
||||
it('renders a button with the icon', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses provided id on button', () => {
|
||||
const wrapper = mountComponent({id: 'custom-id', icon: 'mdi:arrow-left', ariaLabel: 'Retour'})
|
||||
|
||||
expect(wrapper.get('button').attributes('id')).toBe('custom-id')
|
||||
})
|
||||
|
||||
it('generates an id when missing', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const buttonId = wrapper.get('button').attributes('id')
|
||||
expect(buttonId?.startsWith('malio-button-icon-')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets aria-label on button', () => {
|
||||
const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour'})
|
||||
|
||||
expect(wrapper.get('button').attributes('aria-label')).toBe('Retour')
|
||||
})
|
||||
|
||||
it('sets type="button" on the button', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').attributes('type')).toBe('button')
|
||||
})
|
||||
|
||||
it('passes icon name to icon component', () => {
|
||||
const wrapper = mount(ButtonIconForTest, {
|
||||
props: {icon: 'mdi:pencil-outline', ariaLabel: 'Modifier'},
|
||||
})
|
||||
|
||||
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||
expect(iconComponent.props('icon')).toBe('mdi:pencil-outline')
|
||||
})
|
||||
|
||||
it('passes icon size to icon component', () => {
|
||||
const wrapper = mount(ButtonIconForTest, {
|
||||
props: {icon: 'mdi:arrow-left', ariaLabel: 'Retour', iconSize: 32},
|
||||
})
|
||||
|
||||
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||
expect(iconComponent.props('width')).toBe(32)
|
||||
expect(iconComponent.props('height')).toBe(32)
|
||||
})
|
||||
|
||||
it('emits click event when clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit click when disabled', async () => {
|
||||
const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', disabled: true})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets disabled attribute when disabled', () => {
|
||||
const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', disabled: true})
|
||||
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('applies disabled styles when disabled', () => {
|
||||
const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', disabled: true})
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('cursor-not-allowed')
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-disabled')
|
||||
})
|
||||
|
||||
it('applies cursor-pointer when not disabled', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('cursor-pointer')
|
||||
})
|
||||
|
||||
it('applies white text color for icon visibility', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('text-white')
|
||||
})
|
||||
|
||||
it('applies default background color', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('bg-m-btn-primary')
|
||||
})
|
||||
|
||||
it('applies buttonClass', () => {
|
||||
const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', buttonClass: 'rounded-full'})
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('rounded-full')
|
||||
})
|
||||
|
||||
it('applies ghost variant with no background and colored icon', () => {
|
||||
const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', variant: 'ghost'})
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('text-m-btn-primary')
|
||||
expect(wrapper.get('button').classes()).not.toContain('bg-m-btn-primary')
|
||||
expect(wrapper.get('button').classes()).not.toContain('text-white')
|
||||
})
|
||||
|
||||
it('applies ghost disabled styles with no background', () => {
|
||||
const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', variant: 'ghost', disabled: true})
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('text-m-disabled')
|
||||
expect(wrapper.get('button').classes()).toContain('cursor-not-allowed')
|
||||
expect(wrapper.get('button').classes()).not.toContain('bg-m-disabled')
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
:id="buttonId"
|
||||
:class="mergedButtonClass"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
type="button"
|
||||
v-bind="attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="icon"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
data-test="icon"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useAttrs, useId} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
defineOptions({name: 'MalioButtonIcon', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
icon: string
|
||||
ariaLabel: string
|
||||
disabled?: boolean
|
||||
buttonClass?: string
|
||||
iconSize?: string | number
|
||||
variant?: 'filled' | 'ghost'
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
disabled: false,
|
||||
buttonClass: '',
|
||||
iconSize: 24,
|
||||
variant: 'filled',
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
|
||||
const buttonId = computed(() => props.id || `malio-button-icon-${generatedId}`)
|
||||
|
||||
const isFilled = computed(() => props.variant === 'filled')
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 m-focus-ring',
|
||||
isFilled.value
|
||||
? props.disabled
|
||||
? 'bg-m-disabled text-white cursor-not-allowed'
|
||||
: 'bg-m-btn-primary hover:bg-m-btn-primary-hover active:bg-m-btn-primary-active text-white cursor-pointer'
|
||||
: props.disabled
|
||||
? 'text-m-disabled cursor-not-allowed'
|
||||
: 'text-m-btn-primary hover:text-m-btn-primary-hover active:text-m-btn-primary-active cursor-pointer',
|
||||
props.buttonClass,
|
||||
),
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'click', e: MouseEvent): void
|
||||
}>()
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!props.disabled) {
|
||||
emit('click', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,278 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
import DataTable from './DataTable.vue'
|
||||
|
||||
type DataTableProps = {
|
||||
id?: string
|
||||
columns?: { key: string; label: string }[]
|
||||
items?: Record<string, unknown>[]
|
||||
totalItems?: number
|
||||
page?: number
|
||||
perPage?: number
|
||||
perPageOptions?: number[]
|
||||
rowClickable?: boolean
|
||||
tableClass?: string
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
const DataTableForTest = DataTable as DefineComponent<DataTableProps>
|
||||
|
||||
const defaultColumns = [
|
||||
{ key: 'nom', label: 'Nom' },
|
||||
{ key: 'ville', label: 'Ville' },
|
||||
]
|
||||
|
||||
const defaultItems = [
|
||||
{ nom: 'Dupont', ville: 'Paris' },
|
||||
{ nom: 'Martin', ville: 'Lyon' },
|
||||
{ nom: 'Bernard', ville: 'Marseille' },
|
||||
]
|
||||
|
||||
function mountComponent(props: DataTableProps = {}, slots?: Record<string, unknown>) {
|
||||
return mount(DataTableForTest, {
|
||||
props: {
|
||||
columns: defaultColumns,
|
||||
items: defaultItems,
|
||||
totalItems: 3,
|
||||
...props,
|
||||
},
|
||||
slots,
|
||||
global: {
|
||||
stubs: {
|
||||
MalioSelect: {
|
||||
name: 'MalioSelect',
|
||||
template: '<div data-test="malio-select"><slot /></div>',
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
MalioButton: {
|
||||
template: '<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)"><slot>{{ label }}</slot></button>',
|
||||
props: ['label', 'disabled', 'variant', 'buttonClass'],
|
||||
emits: ['click'],
|
||||
inheritAttrs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('MalioDataTable', () => {
|
||||
describe('Table rendering', () => {
|
||||
it('renders column headers as text when no header slot', () => {
|
||||
const wrapper = mountComponent()
|
||||
const headers = wrapper.findAll('th')
|
||||
expect(headers).toHaveLength(2)
|
||||
expect(headers[0].text()).toBe('Nom')
|
||||
expect(headers[1].text()).toBe('Ville')
|
||||
})
|
||||
|
||||
it('renders header slot when provided', () => {
|
||||
const wrapper = mountComponent({}, {
|
||||
'header-nom': '<input data-test="filter-nom" placeholder="Nom" />',
|
||||
})
|
||||
expect(wrapper.find('[data-test="filter-nom"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders items as rows', () => {
|
||||
const wrapper = mountComponent()
|
||||
const rows = wrapper.findAll('[data-test="row"]')
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0].text()).toContain('Dupont')
|
||||
expect(rows[0].text()).toContain('Paris')
|
||||
})
|
||||
|
||||
it('renders cell slot when provided', () => {
|
||||
const wrapper = mountComponent({}, {
|
||||
'cell-nom': ({ item }: { item: Record<string, unknown> }) => h('strong', String(item.nom)),
|
||||
})
|
||||
const firstRow = wrapper.findAll('[data-test="row"]')[0]
|
||||
expect(firstRow.find('strong').text()).toBe('Dupont')
|
||||
})
|
||||
|
||||
it('renders empty message when items is empty', () => {
|
||||
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Aucune donnée')
|
||||
})
|
||||
|
||||
it('renders custom empty message', () => {
|
||||
const wrapper = mountComponent({ items: [], totalItems: 0, emptyMessage: 'Rien ici' })
|
||||
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Rien ici')
|
||||
})
|
||||
|
||||
it('renders empty slot when provided', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ items: [], totalItems: 0 },
|
||||
{ empty: '<p data-test="custom-empty">Vide</p>' },
|
||||
)
|
||||
expect(wrapper.find('[data-test="custom-empty"]').text()).toBe('Vide')
|
||||
})
|
||||
|
||||
it('empty row has colspan equal to columns length', () => {
|
||||
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||
const td = wrapper.find('[data-test="empty-row"] td')
|
||||
expect(td.attributes('colspan')).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Row click', () => {
|
||||
it('emits row-click with item on row click', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
||||
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||
})
|
||||
|
||||
it('emits row-click on Enter key', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.enter')
|
||||
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||
})
|
||||
|
||||
it('emits row-click on Space key', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.space')
|
||||
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||
})
|
||||
|
||||
it('rows have tabindex when clickable', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBe('0')
|
||||
})
|
||||
|
||||
it('rows have cursor-pointer when clickable', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findAll('[data-test="row"]')[0].classes()).toContain('cursor-pointer')
|
||||
})
|
||||
|
||||
it('rows are not clickable when rowClickable is false', async () => {
|
||||
const wrapper = mountComponent({ rowClickable: false })
|
||||
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
||||
expect(wrapper.emitted('row-click')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rows have no tabindex when not clickable', () => {
|
||||
const wrapper = mountComponent({ rowClickable: false })
|
||||
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('th elements have scope="col"', () => {
|
||||
const wrapper = mountComponent()
|
||||
const ths = wrapper.findAll('th')
|
||||
ths.forEach(th => {
|
||||
expect(th.attributes('scope')).toBe('col')
|
||||
})
|
||||
})
|
||||
|
||||
it('generates an id when not provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const id = wrapper.find('div').attributes('id')
|
||||
expect(id).toMatch(/^malio-datatable-/)
|
||||
})
|
||||
|
||||
it('uses custom id when provided', () => {
|
||||
const wrapper = mountComponent({ id: 'my-table' })
|
||||
expect(wrapper.find('div').attributes('id')).toBe('my-table')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('hides pagination when totalItems is 0', () => {
|
||||
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows pagination when totalItems > 0', () => {
|
||||
const wrapper = mountComponent({ totalItems: 30 })
|
||||
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders all pages when totalPages <= 5', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('highlights current page', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('emits update:page on page button click', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||
await wrapper.find('[data-test="page-3"]').trigger('click')
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
|
||||
})
|
||||
|
||||
it('Prev button is disabled on page 1', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('Next button is disabled on last page', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 5 })
|
||||
expect(wrapper.find('[data-test="next-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('Prev button emits update:page with page - 1', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||
await wrapper.find('[data-test="prev-button"]').trigger('click')
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
|
||||
})
|
||||
|
||||
it('Next button emits update:page with page + 1', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||
await wrapper.find('[data-test="next-button"]').trigger('click')
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
|
||||
})
|
||||
|
||||
it('shows ellipsis for truncated pages (> 5 pages)', () => {
|
||||
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
|
||||
expect(ellipsis.length).toBeGreaterThan(0)
|
||||
expect(ellipsis[0].text()).toBe('…')
|
||||
})
|
||||
|
||||
it('always shows first and last page when > 5 pages', () => {
|
||||
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows 1 neighbor on each side of current page', () => {
|
||||
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('pagination nav has aria-label', () => {
|
||||
const wrapper = mountComponent({ totalItems: 30 })
|
||||
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
|
||||
})
|
||||
|
||||
it('Prev button has aria-label "Page précédente"', () => {
|
||||
const wrapper = mountComponent({ totalItems: 30 })
|
||||
expect(wrapper.find('[data-test="prev-button"]').attributes('aria-label')).toBe('Page précédente')
|
||||
})
|
||||
|
||||
it('Next button has aria-label "Page suivante"', () => {
|
||||
const wrapper = mountComponent({ totalItems: 30 })
|
||||
expect(wrapper.find('[data-test="next-button"]').attributes('aria-label')).toBe('Page suivante')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Per-page selector', () => {
|
||||
it('emits update:per-page and reset page to 1 on change', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
|
||||
const select = wrapper.findComponent({ name: 'MalioSelect' })
|
||||
select.vm.$emit('update:modelValue', 25)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('update:per-page')?.[0]).toEqual([25])
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([1])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
<template>
|
||||
<div :id="componentId" class="w-full" v-bind="attrs">
|
||||
<table :class="twMerge('w-full border-separate border-spacing-0 border border-black rounded-malio overflow-hidden', tableClass)">
|
||||
<thead>
|
||||
<tr class="bg-m-surface">
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
scope="col"
|
||||
class="border-b border-black px-3 py-3 text-left align-middle text-[16px]"
|
||||
>
|
||||
<slot
|
||||
v-if="$slots[`header-${col.key}`]"
|
||||
:name="`header-${col.key}`"
|
||||
:column="col"
|
||||
/>
|
||||
<span v-else class="font-semibold text-black">{{ col.label }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:class="rowClickable ? 'cursor-pointer hover:bg-m-bg' : ''"
|
||||
:tabindex="rowClickable ? 0 : undefined"
|
||||
data-test="row"
|
||||
@click="rowClickable ? emit('row-click', item) : undefined"
|
||||
@keydown.enter="rowClickable ? emit('row-click', item) : undefined"
|
||||
@keydown.space.prevent="rowClickable ? emit('row-click', item) : undefined"
|
||||
>
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-3 py-4 text-[14px] text-black"
|
||||
:class="index < items.length - 1 ? 'border-b border-black' : ''"
|
||||
>
|
||||
<slot
|
||||
v-if="$slots[`cell-${col.key}`]"
|
||||
:name="`cell-${col.key}`"
|
||||
:item="item"
|
||||
:column="col"
|
||||
/>
|
||||
<template v-else>{{ item[col.key] }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!items.length" data-test="empty-row">
|
||||
<td
|
||||
:colspan="columns.length"
|
||||
class="px-3 py-4 text-center text-m-muted"
|
||||
>
|
||||
<slot name="empty">{{ emptyMessage }}</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-if="totalItems > 0"
|
||||
class="flex items-center justify-between pt-3"
|
||||
data-test="pagination"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
|
||||
<div class="h-[30px]">
|
||||
<MalioSelect
|
||||
:model-value="perPage"
|
||||
:options="perPageSelectOptions"
|
||||
group-class="w-20 h-[30px]"
|
||||
field-class="h-[30px]"
|
||||
rounded="rounded"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
text-label="text-xs"
|
||||
data-test="per-page-select"
|
||||
@update:model-value="onPerPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Préc."
|
||||
:disabled="page <= 1"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page précédente"
|
||||
data-test="prev-button"
|
||||
@click="goToPage(page - 1)"
|
||||
/>
|
||||
|
||||
<template v-for="(p, idx) in visiblePages" :key="idx">
|
||||
<span
|
||||
v-if="p === '...'"
|
||||
class="px-1 text-sm text-m-muted"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="inline-flex h-[30px] min-w-[2.5rem] items-center justify-center rounded px-2 text-sm transition-colors"
|
||||
:class="p === page
|
||||
? 'bg-m-btn-primary text-white font-semibold'
|
||||
: 'text-m-text hover:bg-m-bg'"
|
||||
:aria-current="p === page ? 'page' : undefined"
|
||||
:data-test="`page-${p}`"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Suiv."
|
||||
:disabled="page >= totalPages"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page suivante"
|
||||
data-test="next-button"
|
||||
@click="goToPage(page + 1)"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs, useId } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import MalioSelect from '../select/Select.vue'
|
||||
import MalioButton from '../button/Button.vue'
|
||||
|
||||
defineOptions({ name: 'MalioDataTable', inheritAttrs: false })
|
||||
|
||||
type DataTableColumn = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
columns: DataTableColumn[]
|
||||
items: Record<string, unknown>[]
|
||||
totalItems: number
|
||||
page?: number
|
||||
perPage?: number
|
||||
perPageOptions?: number[]
|
||||
rowClickable?: boolean
|
||||
tableClass?: string
|
||||
emptyMessage?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
perPageOptions: () => [10, 25, 50],
|
||||
rowClickable: true,
|
||||
tableClass: '',
|
||||
emptyMessage: 'Aucune donnée',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:page' | 'update:per-page', value: number): void
|
||||
(e: 'row-click', item: Record<string, unknown>): void
|
||||
}>()
|
||||
|
||||
const generatedId = useId()
|
||||
const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
|
||||
|
||||
const perPageSelectOptions = computed(() =>
|
||||
props.perPageOptions.map(n => ({ label: String(n), value: n }))
|
||||
)
|
||||
|
||||
function onPerPageChange(value: string | number | null) {
|
||||
if (value !== null) {
|
||||
emit('update:per-page', Number(value))
|
||||
emit('update:page', 1)
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
emit('update:page', page)
|
||||
}
|
||||
}
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = totalPages.value
|
||||
const current = props.page
|
||||
|
||||
if (total <= 5) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
if (total > 1) {
|
||||
pages.push(total)
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
@@ -1,624 +0,0 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import Date_ from './Date.vue'
|
||||
|
||||
type DateProps = {
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
markedDates?: Record<string, 'success' | 'danger'>
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const DateForTest = Date_ as DefineComponent<DateProps>
|
||||
const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body})
|
||||
|
||||
describe('MalioDate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('rendu', () => {
|
||||
it('renders the label and the calendar icon', () => {
|
||||
const wrapper = mountDate({label: 'Date de naissance'})
|
||||
expect(wrapper.get('label').text()).toBe('Date de naissance')
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountDate({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountDate({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays the formatted value in the field', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('does not show the popover initially', () => {
|
||||
const wrapper = mountDate()
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('popover', () => {
|
||||
it('opens on field click', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens on calendar icon click', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens on calendar icon click in editable mode', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens on the current month when there is no value', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
|
||||
})
|
||||
|
||||
it('opens on the value month when a value is set', async () => {
|
||||
const wrapper = mountDate({modelValue: '2025-12-25'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||
})
|
||||
|
||||
it('closes on outside mousedown', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation jours', () => {
|
||||
it('goes to the next month on the right chevron', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026')
|
||||
})
|
||||
|
||||
it('rolls December to January and bumps the year', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-12-15'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027')
|
||||
})
|
||||
})
|
||||
|
||||
describe('month-change', () => {
|
||||
it('émet month-change à l\'ouverture avec le mois courant', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 4, year: 2026}])
|
||||
})
|
||||
|
||||
it('émet month-change sur le mois de la valeur à l\'ouverture', async () => {
|
||||
const wrapper = mountDate({modelValue: '2025-12-25'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 11, year: 2025}])
|
||||
})
|
||||
|
||||
it('émet month-change à chaque navigation de mois', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 5, year: 2026}])
|
||||
await wrapper.get('[data-test="header-prev"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-prev"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 3, year: 2026}])
|
||||
})
|
||||
|
||||
it('ne ré-émet pas month-change après fermeture', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const countOpen = wrapper.emitted('month-change')?.length ?? 0
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('month-change')?.length ?? 0).toBe(countOpen)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markedDates', () => {
|
||||
it('transmet markedDates à la grille (fond tokenisé)', async () => {
|
||||
const wrapper = mountDate({markedDates: {'2026-05-20': 'success'}})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const pill = wrapper.get('[data-iso="2026-05-20"]').get('span.rounded-full')
|
||||
expect(pill.classes()).toContain('bg-m-success/15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sélection', () => {
|
||||
it('emits the ISO date and closes on day click', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bornes min/max', () => {
|
||||
it('disables days outside the range', async () => {
|
||||
const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||
await outside.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vue mois', () => {
|
||||
it('switches to month view on header toggle', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates the year with chevrons in month view', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027')
|
||||
})
|
||||
|
||||
it('returns to day view on month click', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('effacement', () => {
|
||||
it('shows the clear button when there is a value', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
expect(wrapper.find('[data-test="clear"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the clear button when empty', () => {
|
||||
const wrapper = mountDate()
|
||||
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits null and does not open the popover on clear', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('états', () => {
|
||||
it('does not open when disabled', async () => {
|
||||
const wrapper = mountDate({disabled: true})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disabled : label grisé', () => {
|
||||
const wrapper = mountDate({disabled: true, label: 'Date'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('disabled : pas de croix d\'effacement même avec une valeur', () => {
|
||||
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
|
||||
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disabled + rempli : icône calendrier grisée (pas noire)', () => {
|
||||
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
|
||||
const icon = wrapper.get('[data-test="calendar-icon"]')
|
||||
expect(icon.classes()).toContain('text-m-muted')
|
||||
expect(icon.classes()).not.toContain('text-black')
|
||||
})
|
||||
|
||||
it('does not open when readonly', async () => {
|
||||
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly vide : bordure noire sans bleu', () => {
|
||||
const wrapper = mountDate({readonly: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.classes()).toContain('border-black')
|
||||
expect(input.classes()).not.toContain('border-m-muted')
|
||||
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : label muted sans bleu', () => {
|
||||
const wrapper = mountDate({readonly: true, label: 'Date'})
|
||||
const label = wrapper.get('label')
|
||||
expect(label.classes()).toContain('text-m-muted')
|
||||
expect(label.classes()).not.toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : icône calendrier en text-m-muted', () => {
|
||||
const wrapper = mountDate({readonly: true, label: 'Date'})
|
||||
expect(wrapper.get('[data-test="calendar-icon"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label et icône en noir, bordure noire', () => {
|
||||
const wrapper = mountDate({readonly: true, label: 'Date', modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
const label = wrapper.get('label')
|
||||
const icon = wrapper.get('[data-test="calendar-icon"]')
|
||||
expect(input.classes()).toContain('border-black')
|
||||
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(label.classes()).toContain('text-black')
|
||||
expect(icon.classes()).toContain('text-black')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibilité', () => {
|
||||
it('sets aria-invalid and describedby on error', () => {
|
||||
const wrapper = mountDate({error: 'Date requise'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('Date requise')
|
||||
})
|
||||
})
|
||||
|
||||
describe('synchronisation externe', () => {
|
||||
it('updates the displayed value when modelValue changes', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
await wrapper.setProps({modelValue: '2026-12-25'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('25/12/2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserveMessageSpace', () => {
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountDate({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await wrapper.setProps({modelValue: '2026-05-19'})
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('readonly')).toBeDefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('31/02/2026')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await input.trigger('focus')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('19/05/2026')
|
||||
// Valeur DOM réelle de la touche Entrée ('Enter') ; `trigger('keydown.enter')`
|
||||
// produirait `key: 'enter'`, qui ne matche pas le handler manuel `e.key === 'Enter'`.
|
||||
await input.trigger('keydown', {key: 'Enter'})
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
// 31/02/2026 : champs valides (jour ≤ 31, mois ≤ 12) mais le 31 février n'existe pas.
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
|
||||
it('empêche la frappe d\'une date absurde (99/99/9999 borné par le masque)', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999')
|
||||
await input.trigger('blur')
|
||||
// Le bornage refuse « 9 » dès le 1er chiffre du jour/mois : la saisie absurde
|
||||
// ne s'inscrit jamais et aucune date réelle n'est émise.
|
||||
expect((input.element as HTMLInputElement).value).not.toContain('99')
|
||||
const emitted = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(emitted.every(([value]) => value === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('empêche un jour > 31 ou un mois > 12 (exemple métier 33/19, 2e chiffre borné)', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
// 33 (jour) : le 2e « 3 » est refusé → seul « 3 » subsiste.
|
||||
await input.setValue('33')
|
||||
expect((input.element as HTMLInputElement).value).toBe('3')
|
||||
// 19 en mois : après un jour valide, le 2e chiffre du mois (« 9 ») est refusé.
|
||||
await input.setValue('15/19')
|
||||
expect((input.element as HTMLInputElement).value).not.toContain('19')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gabarit de saisie (editable)', () => {
|
||||
it('affiche le gabarit complet en gris quand editable + focus + vide', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||
const ghost = wrapper.get('[data-test="format-ghost"]')
|
||||
expect(ghost.text()).toBe('JJ/MM/AAAA')
|
||||
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('19')
|
||||
// eager : le séparateur se pose dès que le groupe est complet (« 19 » → « 19/ »)
|
||||
expect(wrapper.get('[data-test="format-ghost"]').text()).toBe('19/MM/AAAA')
|
||||
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/')
|
||||
expect(wrapper.get('[data-test="ghost-typed"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('pose le séparateur automatiquement dès qu\'un groupe est complet (eager)', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('1905')
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/')
|
||||
})
|
||||
|
||||
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('n\'affiche pas de gabarit quand editable mais vide et non focus', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('vide le champ au clic sur la croix même après une saisie invalide (modelValue déjà null)', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect((input.element as HTMLInputElement).value).toBe('31/02/2026')
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('état de validité (update:valid)', () => {
|
||||
it('émet valid=true au montage avec une valeur valide', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=true au montage quand le champ est vide', () => {
|
||||
const wrapper = mountDate()
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=true sur saisie clavier valide', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet valid=false sur saisie hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
|
||||
const wrapper = mountDate({editable: true, required: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('émet valid=true sur clear', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=true quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||
await wrapper.setProps({modelValue: '2026-05-19'})
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saisie brute (update:rawValue)', () => {
|
||||
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet le texte brut trimmé sur saisie hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026'])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('émet rawValue vide sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
})
|
||||
|
||||
it('émet rawValue vide sur clear', async () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
})
|
||||
|
||||
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
|
||||
await input.trigger('focus')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<CalendarField
|
||||
:id="id"
|
||||
:display-value="displayValue"
|
||||
:sync-to="modelValue ?? null"
|
||||
:name="name"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="mergedError"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:editable="editable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
@month-change="(payload) => emit('month-change', payload)"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:selected-date="modelValue ?? null"
|
||||
:marked-dates="markedDates"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
// Statut générique par jour, ISO yyyy-mm-dd → variante de fond. Aucune
|
||||
// logique métier dans le layer : le consommateur fournit la liste.
|
||||
markedDates?: Record<string, 'success' | 'danger'>
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
label: '',
|
||||
modelValue: undefined,
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
markedDates: undefined,
|
||||
clearable: true,
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
(e: 'update:valid', value: boolean): void
|
||||
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
|
||||
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
|
||||
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
|
||||
(e: 'update:rawValue', value: string): void
|
||||
// Mois affiché dans le popover (month 0-11) : à l'ouverture et à chaque nav.
|
||||
(e: 'month-change', value: {month: number, year: number}): void
|
||||
}>()
|
||||
|
||||
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
||||
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
// La validité ne reflète que la saisie : malformée/hors plage → false. Un champ
|
||||
// vide est valide (l'obligation `required` reste à la charge du parent).
|
||||
const setError = (message: string) => {
|
||||
internalError.value = message
|
||||
emit('update:valid', message === '')
|
||||
}
|
||||
|
||||
const onCommit = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIso(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
setError(props.invalidMessage)
|
||||
emit('update:rawValue', trimmed)
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', iso)
|
||||
close()
|
||||
}
|
||||
|
||||
// immediate : émet aussi la validité au montage, pour que le parent connaisse
|
||||
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
|
||||
watch(() => props.modelValue, (val) => {
|
||||
setError('')
|
||||
if (val && !isValidIso(val) && import.meta.dev) {
|
||||
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
||||
}
|
||||
}, {immediate: true})
|
||||
</script>
|
||||
@@ -1,155 +0,0 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import DateRange from './DateRange.vue'
|
||||
|
||||
type RangeValue = {start: string; end: string}
|
||||
type DateRangeProps = {
|
||||
modelValue?: RangeValue | null
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
error?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
const DateRangeForTest = DateRange as DefineComponent<DateRangeProps>
|
||||
const mountRange = (props: DateRangeProps = {}) =>
|
||||
mount(DateRangeForTest, {props, attachTo: document.body})
|
||||
|
||||
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, isos: string[]) => {
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
for (const iso of isos) {
|
||||
await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click')
|
||||
}
|
||||
}
|
||||
|
||||
describe('MalioDateRange', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('renders the label and calendar icon', () => {
|
||||
const wrapper = mountRange({label: 'Période'})
|
||||
expect(wrapper.get('label').text()).toBe('Période')
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the formatted range when modelValue is set', () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('19/05/2026 - 25/05/2026')
|
||||
})
|
||||
|
||||
it('shows an empty field without a value', () => {
|
||||
const wrapper = mountRange()
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('opens on the start month when a range is set', async () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||
})
|
||||
|
||||
it('does not emit on the first click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19'])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits the range and closes on the second click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('auto-inverts when the second click is before the first', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19'])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
|
||||
})
|
||||
|
||||
it('allows a single-day range', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19'])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}])
|
||||
})
|
||||
|
||||
it('restarts a new range on the third click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}])
|
||||
})
|
||||
|
||||
it('previews the range on hover while selecting', async () => {
|
||||
const wrapper = mountRange()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
|
||||
})
|
||||
|
||||
it('does not preview before selecting', async () => {
|
||||
const wrapper = mountRange()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('none')
|
||||
})
|
||||
|
||||
it('marks start, end and in-range roles for a committed range', async () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
|
||||
})
|
||||
|
||||
it('cancels an in-progress selection on outside click', async () => {
|
||||
const wrapper = mountRange()
|
||||
await openAndClickDays(wrapper, ['2026-05-19'])
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none')
|
||||
})
|
||||
|
||||
it('emits null on clear', async () => {
|
||||
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('disables days outside min/max', async () => {
|
||||
const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||
await outside.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets aria-invalid on error', () => {
|
||||
const wrapper = mountRange({error: 'Période requise'})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Période requise')
|
||||
})
|
||||
|
||||
it('does not open when disabled', async () => {
|
||||
const wrapper = mountRange({disabled: true})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<CalendarField
|
||||
:id="id"
|
||||
:display-value="displayValue"
|
||||
:sync-to="validRange?.start ?? null"
|
||||
:name="name"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="error"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="onClear"
|
||||
@close="onClose"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:range-start="rangeStart"
|
||||
:range-end="rangeEnd"
|
||||
:preview-date="previewDate"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => onSelectDay(iso, close)"
|
||||
@hover="onHover"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
|
||||
|
||||
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: DateRangeValue | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
label: '',
|
||||
modelValue: undefined,
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
clearable: true,
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
|
||||
|
||||
const pendingStart = ref<string | null>(null)
|
||||
const hoverDate = ref<string | null>(null)
|
||||
const isSelecting = computed(() => pendingStart.value !== null)
|
||||
|
||||
const validRange = computed<DateRangeValue | null>(() => {
|
||||
const v = props.modelValue
|
||||
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
|
||||
return null
|
||||
})
|
||||
|
||||
const rangeStart = computed(() =>
|
||||
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
|
||||
)
|
||||
const rangeEnd = computed(() =>
|
||||
isSelecting.value ? null : (validRange.value?.end ?? null),
|
||||
)
|
||||
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (isSelecting.value || !validRange.value) return ''
|
||||
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
|
||||
})
|
||||
|
||||
const onSelectDay = (iso: string, close: () => void) => {
|
||||
if (pendingStart.value === null) {
|
||||
pendingStart.value = iso
|
||||
hoverDate.value = null
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
|
||||
pendingStart.value = null
|
||||
hoverDate.value = null
|
||||
close()
|
||||
}
|
||||
|
||||
const onHover = (iso: string | null) => {
|
||||
if (isSelecting.value) hoverDate.value = iso
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
pendingStart.value = null
|
||||
hoverDate.value = null
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
emit('update:modelValue', null)
|
||||
pendingStart.value = null
|
||||
hoverDate.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -1,354 +0,0 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import DateTime_ from './DateTime.vue'
|
||||
import MalioTimePicker from '../time/TimePicker.vue'
|
||||
|
||||
type DateTimeProps = {
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}
|
||||
|
||||
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
|
||||
const mountDateTime = (props: DateTimeProps = {}) =>
|
||||
mount(DateTimeForTest, {props, attachTo: document.body})
|
||||
|
||||
describe('MalioDateTime', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('rendu', () => {
|
||||
it('affiche le label et l\'icône calendrier', () => {
|
||||
const wrapper = mountDateTime({label: 'Rendez-vous'})
|
||||
expect(wrapper.get('label').text()).toBe('Rendez-vous')
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('affiche la valeur formatée date + heure dans le champ', () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('20/05/2026 14:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('popover', () => {
|
||||
it('ouvre la grille et le champ sélecteur d\'heure au clic', async () => {
|
||||
const wrapper = mountDateTime()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
|
||||
expect(wrapper.findComponent(MalioTimePicker).exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="time-field"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sélection', () => {
|
||||
it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', async () => {
|
||||
const wrapper = mountDateTime()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
// heure système figée à 09:05
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('applique l\'heure réglée avant le clic du jour', async () => {
|
||||
const wrapper = mountDateTime()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '09:15')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
|
||||
})
|
||||
|
||||
it('met à jour l\'heure quand une date est déjà choisie', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '08:45')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
|
||||
})
|
||||
|
||||
it('initialise le champ heure depuis la valeur', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.findComponent(MalioTimePicker).props('modelValue')).toBe('14:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bornes min/max', () => {
|
||||
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
|
||||
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('effacement', () => {
|
||||
it('émet null au clic sur la croix', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibilité', () => {
|
||||
it('positionne aria-invalid et describedby sur erreur', () => {
|
||||
const wrapper = mountDateTime({error: 'Date requise'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('Date requise')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('par défaut (editable=false) l\'input reste readonly', () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeDefined()
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet le datetime ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('20/05/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('31/02/2026 14:30')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('empêche la frappe d\'un datetime absurde (99/99/9999 99:99 borné par le masque)', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999 99:99')
|
||||
await input.trigger('blur')
|
||||
// Le masque borne le 1er chiffre de chaque champ (jour 0-3, mois 0-1,
|
||||
// heure 0-2, minute 0-5) : « 9 » est rejeté partout, rien ne s'inscrit
|
||||
// et aucun datetime réel n'est émis.
|
||||
expect((input.element as HTMLInputElement).value).not.toContain('99')
|
||||
const emitted = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(emitted.every(([value]) => value === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('passe en erreur si le datetime saisi est hors min/max', async () => {
|
||||
const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026 10:00')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('20/05/2026 14:30')
|
||||
await input.trigger('keydown', {key: 'Enter'})
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
// 31/02 : champs valides mais date inexistante (le masque la laisse passer, la validation la rejette).
|
||||
await input.setValue('31/02/2026 10:00')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gabarit de saisie (editable)', () => {
|
||||
it('affiche le gabarit date+heure complet en gris quand editable + focus + vide', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('JJ/MM/AAAA HH:MM')
|
||||
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('190520')
|
||||
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('19/05/20AA HH:MM')
|
||||
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/05/20')
|
||||
})
|
||||
|
||||
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('état de validité (update:valid)', () => {
|
||||
it('émet valid=true au montage avec une valeur valide', () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=true au montage quand le champ est vide', () => {
|
||||
const wrapper = mountDateTime()
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=true sur saisie clavier valide', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('20/05/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
|
||||
const wrapper = mountDateTime({editable: true, required: true, modelValue: '2026-05-20T14:30:00'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=true sur clear', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('émet valid=true quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDateTime()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
|
||||
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saisie brute (update:rawValue)', () => {
|
||||
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet le texte brut trimmé sur saisie hors min/max', async () => {
|
||||
const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026 10:00')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026 10:00'])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('20/05/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
|
||||
})
|
||||
|
||||
it('émet rawValue vide sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
})
|
||||
|
||||
it('émet rawValue vide sur clear', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
})
|
||||
|
||||
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDateTime({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('31/02/2026 14:30')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
|
||||
await input.trigger('focus')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<CalendarField
|
||||
:id="id"
|
||||
:display-value="displayValue"
|
||||
:sync-to="datePart"
|
||||
:name="name"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="mergedError"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:editable="editable"
|
||||
placeholder-template="JJ/MM/AAAA HH:MM"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:selected-date="datePart"
|
||||
:min="min?.slice(0, 10)"
|
||||
:max="max?.slice(0, 10)"
|
||||
@select="onSelectDay"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<MalioTimePicker
|
||||
:model-value="timeValue || null"
|
||||
label="Heure"
|
||||
:clearable="false"
|
||||
static-popover
|
||||
@update:model-value="onTimeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import MalioTimePicker from '../time/TimePicker.vue'
|
||||
import {formatTime} from '../time/composables/timeFormat'
|
||||
import {isDateInRange} from './composables/dateFormat'
|
||||
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, parseDisplayToIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
||||
|
||||
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
label: '',
|
||||
modelValue: undefined,
|
||||
placeholder: 'JJ/MM/AAAA HH:MM',
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
clearable: true,
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
(e: 'update:valid', value: boolean): void
|
||||
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
|
||||
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
|
||||
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
|
||||
(e: 'update:rawValue', value: string): void
|
||||
}>()
|
||||
|
||||
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
|
||||
const pendingTime = ref('')
|
||||
|
||||
const parts = computed(() => splitDateTime(props.modelValue ?? null))
|
||||
const datePart = computed(() => parts.value.date)
|
||||
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
|
||||
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
||||
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
// La validité ne reflète que la saisie clavier : malformée/hors plage → false. Un
|
||||
// champ vide est valide (l'obligation `required` reste à la charge du parent).
|
||||
const setError = (message: string) => {
|
||||
internalError.value = message
|
||||
emit('update:valid', message === '')
|
||||
}
|
||||
|
||||
function onSelectDay(iso: string) {
|
||||
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
|
||||
// (heure courante au moment du clic)
|
||||
const now = new Date()
|
||||
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', composeDateTime(iso, time))
|
||||
}
|
||||
|
||||
function onTimeChange(value: string | null) {
|
||||
if (!value) return
|
||||
if (datePart.value) {
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', composeDateTime(datePart.value, value))
|
||||
}
|
||||
else {
|
||||
pendingTime.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function onCommit(text: string) {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIsoDateTime(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
setError('')
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
setError(props.invalidMessage)
|
||||
emit('update:rawValue', trimmed)
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
setError('')
|
||||
pendingTime.value = ''
|
||||
emit('update:rawValue', '')
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
// immediate : émet aussi la validité au montage, pour que le parent connaisse
|
||||
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
|
||||
watch(() => props.modelValue, (val) => {
|
||||
setError('')
|
||||
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
|
||||
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
|
||||
}
|
||||
}, {immediate: true})
|
||||
</script>
|
||||
@@ -1,122 +0,0 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import DateWeek from './DateWeek.vue'
|
||||
|
||||
type DateWeekProps = {
|
||||
modelValue?: string | null
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
error?: string
|
||||
min?: string
|
||||
max?: string
|
||||
}
|
||||
|
||||
const DateWeekForTest = DateWeek as DefineComponent<DateWeekProps>
|
||||
const mountWeek = (props: DateWeekProps = {}) =>
|
||||
mount(DateWeekForTest, {props, attachTo: document.body})
|
||||
|
||||
describe('MalioDateWeek', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('renders the label and calendar icon', () => {
|
||||
const wrapper = mountWeek({label: 'Semaine'})
|
||||
expect(wrapper.get('label').text()).toBe('Semaine')
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the formatted week when modelValue is set', () => {
|
||||
const wrapper = mountWeek({modelValue: '2026-W21'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)')
|
||||
})
|
||||
|
||||
it('shows an empty field without a value', () => {
|
||||
const wrapper = mountWeek()
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('opens on the month of the selected week', async () => {
|
||||
const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||
})
|
||||
|
||||
it('selects the week when a day is clicked', async () => {
|
||||
const wrapper = mountWeek()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('selects the week when the week number is clicked', async () => {
|
||||
const wrapper = mountWeek()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('previews the whole week on day hover', async () => {
|
||||
const wrapper = mountWeek()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
|
||||
})
|
||||
|
||||
it('previews the whole week on week-number hover', async () => {
|
||||
const wrapper = mountWeek()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
|
||||
})
|
||||
|
||||
it('marks the committed week number', async () => {
|
||||
const wrapper = mountWeek({modelValue: '2026-W21'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true')
|
||||
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
|
||||
})
|
||||
|
||||
it('emits null on clear', async () => {
|
||||
const wrapper = mountWeek({modelValue: '2026-W21'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('disables a week fully outside min/max', async () => {
|
||||
const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]')
|
||||
expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true)
|
||||
const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]')
|
||||
expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('does not open when disabled', async () => {
|
||||
const wrapper = mountWeek({disabled: true})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not open when readonly', async () => {
|
||||
const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('sets aria-invalid on error', () => {
|
||||
const wrapper = mountWeek({error: 'Semaine requise'})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Semaine requise')
|
||||
})
|
||||
})
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<CalendarField
|
||||
:id="id"
|
||||
:display-value="displayValue"
|
||||
:sync-to="validWeek?.monday ?? null"
|
||||
:name="name"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="error"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="onClear"
|
||||
@close="onClose"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:range-start="activeMonday"
|
||||
:range-end="activeSunday"
|
||||
:marked-week-start="validWeek?.monday ?? null"
|
||||
interactive-week-number
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
@hover="onHover"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {formatWeekDisplay, isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek} from './composables/dateWeek'
|
||||
|
||||
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
label: '',
|
||||
modelValue: undefined,
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
clearable: true,
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
||||
|
||||
const hoverWeekStart = ref<string | null>(null)
|
||||
|
||||
const validWeek = computed(() => {
|
||||
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
|
||||
return {monday: isoWeekToMonday(props.modelValue) as string}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
|
||||
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
|
||||
|
||||
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
emit('update:modelValue', toIsoWeek(iso))
|
||||
hoverWeekStart.value = null
|
||||
close()
|
||||
}
|
||||
|
||||
const onHover = (iso: string | null) => {
|
||||
hoverWeekStart.value = iso ? mondayOf(iso) : null
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
hoverWeekStart.value = null
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
emit('update:modelValue', null)
|
||||
hoverWeekStart.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
|
||||
|
||||
describe('dateFormat', () => {
|
||||
describe('isValidIso', () => {
|
||||
it('accepts a real ISO date', () => {
|
||||
expect(isValidIso('2026-05-19')).toBe(true)
|
||||
})
|
||||
it('rejects a malformed string', () => {
|
||||
expect(isValidIso('19/05/2026')).toBe(false)
|
||||
expect(isValidIso('2026-5-9')).toBe(false)
|
||||
expect(isValidIso('')).toBe(false)
|
||||
})
|
||||
it('rejects an impossible date', () => {
|
||||
expect(isValidIso('2026-02-30')).toBe(false)
|
||||
expect(isValidIso('2026-13-01')).toBe(false)
|
||||
})
|
||||
it('accepts Feb 29 on a leap year and rejects it otherwise', () => {
|
||||
expect(isValidIso('2024-02-29')).toBe(true)
|
||||
expect(isValidIso('2026-02-29')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatIsoToDisplay', () => {
|
||||
it('formats ISO to DD/MM/YYYY', () => {
|
||||
expect(formatIsoToDisplay('2026-05-19')).toBe('19/05/2026')
|
||||
})
|
||||
it('returns empty string for null or invalid input', () => {
|
||||
expect(formatIsoToDisplay(null)).toBe('')
|
||||
expect(formatIsoToDisplay('nope')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDisplayToIso', () => {
|
||||
it('parses DD/MM/YYYY to ISO', () => {
|
||||
expect(parseDisplayToIso('19/05/2026')).toBe('2026-05-19')
|
||||
})
|
||||
it('returns null for malformed or impossible input', () => {
|
||||
expect(parseDisplayToIso('2026-05-19')).toBeNull()
|
||||
expect(parseDisplayToIso('31/02/2026')).toBeNull()
|
||||
expect(parseDisplayToIso('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDateInRange', () => {
|
||||
it('returns true when no bounds are given', () => {
|
||||
expect(isDateInRange('2026-05-19')).toBe(true)
|
||||
})
|
||||
it('respects the min bound (inclusive)', () => {
|
||||
expect(isDateInRange('2026-05-19', '2026-05-19')).toBe(true)
|
||||
expect(isDateInRange('2026-05-18', '2026-05-19')).toBe(false)
|
||||
})
|
||||
it('respects the max bound (inclusive)', () => {
|
||||
expect(isDateInRange('2026-05-19', undefined, '2026-05-19')).toBe(true)
|
||||
expect(isDateInRange('2026-05-20', undefined, '2026-05-19')).toBe(false)
|
||||
})
|
||||
it('respects both bounds', () => {
|
||||
expect(isDateInRange('2026-05-15', '2026-05-10', '2026-05-20')).toBe(true)
|
||||
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
export function isValidIso(iso: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
const date = new Date(y, m - 1, d)
|
||||
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
|
||||
}
|
||||
|
||||
export function formatIsoToDisplay(iso: string | null): string {
|
||||
if (!iso || !isValidIso(iso)) return ''
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
export function parseDisplayToIso(display: string): string | null {
|
||||
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(display.trim())
|
||||
if (!match) return null
|
||||
const [, dd, mm, yyyy] = match
|
||||
const iso = `${yyyy}-${mm}-${dd}`
|
||||
return isValidIso(iso) ? iso : null
|
||||
}
|
||||
|
||||
export function isDateInRange(iso: string, min?: string, max?: string): boolean {
|
||||
if (min && iso < min) return false
|
||||
if (max && iso > max) return false
|
||||
return true
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange'
|
||||
|
||||
describe('dateRange', () => {
|
||||
describe('normalizeRange', () => {
|
||||
it('keeps an already ordered pair', () => {
|
||||
expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'})
|
||||
})
|
||||
it('swaps a reversed pair', () => {
|
||||
expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'})
|
||||
})
|
||||
it('handles an equal pair', () => {
|
||||
expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRangeBounds', () => {
|
||||
it('returns null without a start', () => {
|
||||
expect(resolveRangeBounds(null, null, null)).toBeNull()
|
||||
})
|
||||
it('returns a single-point range when only start is set', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'})
|
||||
})
|
||||
it('orders start and committed end', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
|
||||
})
|
||||
it('uses preview when end is not set', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'})
|
||||
})
|
||||
it('inverts when preview is before start', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'})
|
||||
})
|
||||
it('prioritises committed end over preview', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayRangeRole', () => {
|
||||
const bounds = {lo: '2026-05-19', hi: '2026-05-25'}
|
||||
it('returns none without bounds', () => {
|
||||
expect(dayRangeRole('2026-05-20', null)).toBe('none')
|
||||
})
|
||||
it('returns single when lo === hi and matches', () => {
|
||||
expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single')
|
||||
expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none')
|
||||
})
|
||||
it('returns start, end and in-range', () => {
|
||||
expect(dayRangeRole('2026-05-19', bounds)).toBe('start')
|
||||
expect(dayRangeRole('2026-05-25', bounds)).toBe('end')
|
||||
expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range')
|
||||
})
|
||||
it('returns none outside the bounds', () => {
|
||||
expect(dayRangeRole('2026-05-10', bounds)).toBe('none')
|
||||
expect(dayRangeRole('2026-05-30', bounds)).toBe('none')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
export type DateRangeValue = {start: string; end: string}
|
||||
|
||||
export function normalizeRange(a: string, b: string): DateRangeValue {
|
||||
return a <= b ? {start: a, end: b} : {start: b, end: a}
|
||||
}
|
||||
|
||||
export function resolveRangeBounds(
|
||||
start: string | null,
|
||||
end: string | null,
|
||||
preview: string | null,
|
||||
): {lo: string; hi: string} | null {
|
||||
if (!start) return null
|
||||
const other = end ?? preview
|
||||
if (!other) return {lo: start, hi: start}
|
||||
return start <= other ? {lo: start, hi: other} : {lo: other, hi: start}
|
||||
}
|
||||
|
||||
export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
|
||||
|
||||
export function dayRangeRole(
|
||||
iso: string,
|
||||
bounds: {lo: string; hi: string} | null,
|
||||
): DayRangeRole {
|
||||
if (!bounds) return 'none'
|
||||
const {lo, hi} = bounds
|
||||
if (lo === hi) return iso === lo ? 'single' : 'none'
|
||||
if (iso === lo) return 'start'
|
||||
if (iso === hi) return 'end'
|
||||
if (iso > lo && iso < hi) return 'in-range'
|
||||
return 'none'
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {
|
||||
formatWeekDisplay,
|
||||
isValidIsoWeek,
|
||||
isoWeekToMonday,
|
||||
mondayOf,
|
||||
sundayOf,
|
||||
toIsoWeek,
|
||||
} from './dateWeek'
|
||||
|
||||
describe('dateWeek', () => {
|
||||
describe('mondayOf / sundayOf', () => {
|
||||
it('returns Monday and Sunday of a midweek date', () => {
|
||||
expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi
|
||||
expect(sundayOf('2026-05-20')).toBe('2026-05-24')
|
||||
})
|
||||
it('keeps Monday on a Monday', () => {
|
||||
expect(mondayOf('2026-05-18')).toBe('2026-05-18')
|
||||
})
|
||||
it('returns the preceding Monday for a Sunday', () => {
|
||||
expect(mondayOf('2026-05-24')).toBe('2026-05-18')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toIsoWeek', () => {
|
||||
it('returns the ISO week of a date', () => {
|
||||
expect(toIsoWeek('2026-05-20')).toBe('2026-W21')
|
||||
})
|
||||
it('handles year boundaries', () => {
|
||||
expect(toIsoWeek('2026-01-01')).toBe('2026-W01')
|
||||
expect(toIsoWeek('2025-12-31')).toBe('2026-W01')
|
||||
expect(toIsoWeek('2027-01-01')).toBe('2026-W53')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isoWeekToMonday', () => {
|
||||
it('returns the Monday of a week string', () => {
|
||||
expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18')
|
||||
})
|
||||
it('round-trips with toIsoWeek', () => {
|
||||
for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) {
|
||||
const monday = isoWeekToMonday(w)
|
||||
expect(monday).not.toBeNull()
|
||||
expect(toIsoWeek(monday as string)).toBe(w)
|
||||
}
|
||||
})
|
||||
it('returns null for invalid input', () => {
|
||||
expect(isoWeekToMonday('2026-21')).toBeNull()
|
||||
expect(isoWeekToMonday('2026-W00')).toBeNull()
|
||||
expect(isoWeekToMonday('2026-W54')).toBeNull()
|
||||
expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidIsoWeek', () => {
|
||||
it('accepts a real ISO week', () => {
|
||||
expect(isValidIsoWeek('2026-W21')).toBe(true)
|
||||
})
|
||||
it('rejects malformed or impossible weeks', () => {
|
||||
expect(isValidIsoWeek('2026-21')).toBe(false)
|
||||
expect(isValidIsoWeek('2026-W00')).toBe(false)
|
||||
expect(isValidIsoWeek('2026-W54')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWeekDisplay', () => {
|
||||
it('formats a week as a human label', () => {
|
||||
expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)')
|
||||
})
|
||||
it('returns empty string for invalid input', () => {
|
||||
expect(formatWeekDisplay('2026-W54')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import {formatIsoToDisplay} from './dateFormat'
|
||||
|
||||
const parseUtc = (iso: string): Date => {
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d))
|
||||
}
|
||||
|
||||
const toIso = (d: Date): string => {
|
||||
const y = d.getUTCFullYear()
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export function mondayOf(iso: string): string {
|
||||
const d = parseUtc(iso)
|
||||
const dayNum = d.getUTCDay() || 7 // dimanche = 7
|
||||
d.setUTCDate(d.getUTCDate() - (dayNum - 1))
|
||||
return toIso(d)
|
||||
}
|
||||
|
||||
export function sundayOf(iso: string): string {
|
||||
const d = parseUtc(mondayOf(iso))
|
||||
d.setUTCDate(d.getUTCDate() + 6)
|
||||
return toIso(d)
|
||||
}
|
||||
|
||||
export function toIsoWeek(iso: string): string {
|
||||
const d = parseUtc(iso)
|
||||
const dayNum = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine
|
||||
const isoYear = d.getUTCFullYear()
|
||||
const yearStart = new Date(Date.UTC(isoYear, 0, 1))
|
||||
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
|
||||
return `${isoYear}-W${String(week).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function isoWeekToMonday(week: string): string | null {
|
||||
const m = /^(\d{4})-W(\d{2})$/.exec(week)
|
||||
if (!m) return null
|
||||
const year = Number(m[1])
|
||||
const w = Number(m[2])
|
||||
if (w < 1 || w > 53) return null
|
||||
// Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4))
|
||||
const jan4Day = jan4.getUTCDay() || 7
|
||||
const monday = new Date(jan4)
|
||||
monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7)
|
||||
const iso = toIso(monday)
|
||||
// Garde-fou : la semaine 53 n'existe pas pour toutes les années
|
||||
if (toIsoWeek(iso) !== week) return null
|
||||
return iso
|
||||
}
|
||||
|
||||
export function isValidIsoWeek(week: string): boolean {
|
||||
return isoWeekToMonday(week) !== null
|
||||
}
|
||||
|
||||
export function formatWeekDisplay(week: string): string {
|
||||
const monday = isoWeekToMonday(week)
|
||||
if (!monday) return ''
|
||||
const sunday = sundayOf(monday)
|
||||
const w = Number(week.slice(6))
|
||||
const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05"
|
||||
const endFull = formatIsoToDisplay(sunday) // "24/05/2026"
|
||||
return `Semaine ${w} (${startDdMm} → ${endFull})`
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {
|
||||
composeDateTime,
|
||||
formatIsoDateTimeToDisplay,
|
||||
isValidIsoDateTime,
|
||||
parseDisplayToIsoDateTime,
|
||||
splitDateTime,
|
||||
} from './datetimeFormat'
|
||||
|
||||
describe('datetimeFormat', () => {
|
||||
describe('isValidIsoDateTime', () => {
|
||||
it('accepte un datetime ISO complet valide', () => {
|
||||
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
|
||||
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
|
||||
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
|
||||
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
|
||||
expect(isValidIsoDateTime('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatIsoDateTimeToDisplay', () => {
|
||||
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
|
||||
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
|
||||
})
|
||||
|
||||
it('renvoie une chaîne vide pour nul ou invalide', () => {
|
||||
expect(formatIsoDateTimeToDisplay(null)).toBe('')
|
||||
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
|
||||
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitDateTime', () => {
|
||||
it('découpe un datetime valide', () => {
|
||||
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
|
||||
})
|
||||
|
||||
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
|
||||
expect(splitDateTime(null)).toEqual({date: null, time: ''})
|
||||
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
|
||||
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDisplayToIsoDateTime', () => {
|
||||
it('parse un JJ/MM/AAAA HH:MM valide en datetime ISO', () => {
|
||||
expect(parseDisplayToIsoDateTime('20/05/2026 14:30')).toBe('2026-05-20T14:30:00')
|
||||
expect(parseDisplayToIsoDateTime('01/01/2026 00:00')).toBe('2026-01-01T00:00:00')
|
||||
expect(parseDisplayToIsoDateTime('31/12/2026 23:59')).toBe('2026-12-31T23:59:00')
|
||||
})
|
||||
|
||||
it('tolère les espaces autour', () => {
|
||||
expect(parseDisplayToIsoDateTime(' 20/05/2026 14:30 ')).toBe('2026-05-20T14:30:00')
|
||||
})
|
||||
|
||||
it('rejette une date malformée', () => {
|
||||
expect(parseDisplayToIsoDateTime('32/01/2026 10:00')).toBeNull()
|
||||
expect(parseDisplayToIsoDateTime('10/13/2026 10:00')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejette une heure hors bornes', () => {
|
||||
expect(parseDisplayToIsoDateTime('20/05/2026 24:00')).toBeNull()
|
||||
expect(parseDisplayToIsoDateTime('20/05/2026 12:60')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejette un format incomplet ou sans heure', () => {
|
||||
expect(parseDisplayToIsoDateTime('20/05/2026')).toBeNull()
|
||||
expect(parseDisplayToIsoDateTime('20/05/2026 14')).toBeNull()
|
||||
expect(parseDisplayToIsoDateTime('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeDateTime', () => {
|
||||
it('recompose un datetime ISO avec secondes à 00', () => {
|
||||
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
|
||||
})
|
||||
|
||||
it('utilise 00:00 quand l\'heure est vide', () => {
|
||||
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import {isValidIso, parseDisplayToIso} from './dateFormat'
|
||||
|
||||
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
|
||||
|
||||
export function isValidIsoDateTime(s: string): boolean {
|
||||
const m = DATETIME_RE.exec(s)
|
||||
if (!m) return false
|
||||
const [, date, hh, mm, ss] = m
|
||||
if (!isValidIso(date)) return false
|
||||
const h = Number(hh)
|
||||
const min = Number(mm)
|
||||
const sec = Number(ss)
|
||||
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
|
||||
}
|
||||
|
||||
export function formatIsoDateTimeToDisplay(s: string | null): string {
|
||||
if (!s || !isValidIsoDateTime(s)) return ''
|
||||
const [date, time] = s.split('T')
|
||||
const [y, mo, d] = date.split('-')
|
||||
const [hh, mm] = time.split(':')
|
||||
return `${d}/${mo}/${y} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
export function splitDateTime(s: string | null): {date: string | null; time: string} {
|
||||
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
|
||||
const [date, time] = s.split('T')
|
||||
return {date, time: time.slice(0, 5)}
|
||||
}
|
||||
|
||||
export function parseDisplayToIsoDateTime(display: string): string | null {
|
||||
const match = /^(\d{2}\/\d{2}\/\d{4}) (\d{2}):(\d{2})$/.exec(display.trim())
|
||||
if (!match) return null
|
||||
const [, datePart, hh, mm] = match
|
||||
const iso = parseDisplayToIso(datePart)
|
||||
if (!iso) return null
|
||||
if (Number(hh) > 23 || Number(mm) > 59) return null
|
||||
return `${iso}T${hh}:${mm}:00`
|
||||
}
|
||||
|
||||
export function composeDateTime(date: string, time: string): string {
|
||||
const t = time || '00:00'
|
||||
return `${date}T${t}:00`
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {buildBoundedMask} from './maskTemplate'
|
||||
|
||||
describe('buildBoundedMask', () => {
|
||||
it('dérive le masque structurel du gabarit (séparateurs conservés)', () => {
|
||||
expect(buildBoundedMask('JJ/MM/AAAA').mask).toBe('##/##/####')
|
||||
expect(buildBoundedMask('JJ/MM/AAAA HH:MM').mask).toBe('##/##/#### ##:##')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preProcess — bornage de la saisie (1er ET 2e chiffre)', () => {
|
||||
const pre = (template: string, value: string) => buildBoundedMask(template).preProcess!(value)
|
||||
|
||||
it('jour : refuse > 31 et 00, accepte 01-31', () => {
|
||||
expect(pre('JJ/MM/AAAA', '32')).toBe('3') // 32 impossible → 2e chiffre refusé
|
||||
expect(pre('JJ/MM/AAAA', '33')).toBe('3') // exemple métier : 33 refusé
|
||||
expect(pre('JJ/MM/AAAA', '31')).toBe('31')
|
||||
expect(pre('JJ/MM/AAAA', '00')).toBe('0') // 00 impossible
|
||||
expect(pre('JJ/MM/AAAA', '09')).toBe('09')
|
||||
})
|
||||
|
||||
it('mois : refuse > 12 et 00 (après un jour valide) — cas 33/19', () => {
|
||||
expect(pre('JJ/MM/AAAA', '0119')).toBe('011') // 19 (mois) refusé
|
||||
expect(pre('JJ/MM/AAAA', '0113')).toBe('011')
|
||||
expect(pre('JJ/MM/AAAA', '0112')).toBe('0112')
|
||||
expect(pre('JJ/MM/AAAA', '0100')).toBe('010')
|
||||
})
|
||||
|
||||
it('laisse l’année libre', () => {
|
||||
expect(pre('JJ/MM/AAAA', '01012026')).toBe('01012026')
|
||||
})
|
||||
|
||||
it('heure 00-23 et minute 00-59 (datetime), sans confondre minute et mois', () => {
|
||||
const t = 'JJ/MM/AAAA HH:MM'
|
||||
expect(pre(t, '010120262300')).toBe('010120262300') // 23:00 ok
|
||||
expect(pre(t, '010120262460')).toBe('010120262') // heure 24 refusée
|
||||
expect(pre(t, '010120261259')).toBe('010120261259') // minute 59 ok (≠ mois)
|
||||
expect(pre(t, '010120261260')).toBe('0101202612') // minute 60 refusée
|
||||
})
|
||||
|
||||
it('stoppe à la première saisie invalide (99/99/9999 → rien)', () => {
|
||||
expect(pre('JJ/MM/AAAA', '99/99/9999')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
|
||||
// Un champ numérique du gabarit : sa longueur et la plage de valeurs autorisée.
|
||||
interface Field {
|
||||
length: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
// Découpe un gabarit (ex. `JJ/MM/AAAA HH:MM`) en champs numériques bornés.
|
||||
// Le `M` désigne le mois avant les heures, et les minutes après — d'où le suivi
|
||||
// de `seenHour`, pour ne pas borner les minutes comme un mois (0-59 vs 1-12).
|
||||
function parseFields(template: string): Field[] {
|
||||
const fields: Field[] = []
|
||||
let seenHour = false
|
||||
let i = 0
|
||||
|
||||
while (i < template.length) {
|
||||
const ch = template[i]!
|
||||
if (!/[A-Za-z]/.test(ch)) {
|
||||
i++ // séparateur (/, espace, :)
|
||||
continue
|
||||
}
|
||||
|
||||
let j = i
|
||||
while (j < template.length && template[j] === ch) j++
|
||||
const length = j - i
|
||||
const letter = ch.toUpperCase()
|
||||
if (letter === 'H') seenHour = true
|
||||
|
||||
if (letter === 'J') fields.push({length, min: 1, max: 31})
|
||||
else if (letter === 'M') fields.push(seenHour ? {length, min: 0, max: 59} : {length, min: 1, max: 12})
|
||||
else if (letter === 'H') fields.push({length, min: 0, max: 23})
|
||||
else fields.push({length, min: 0, max: 10 ** length - 1}) // année (ou autre) : libre
|
||||
|
||||
i = j
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// Un chiffre est accepté tant qu'il existe encore une complétion valide du champ :
|
||||
// on borne la valeur partielle [min possible (padding 0), max possible (padding 9)]
|
||||
// et on vérifie qu'elle croise la plage autorisée [field.min, field.max].
|
||||
function canComplete(partial: string, field: Field): boolean {
|
||||
const low = Number(partial.padEnd(field.length, '0'))
|
||||
const high = Number(partial.padEnd(field.length, '9'))
|
||||
return high >= field.min && low <= field.max
|
||||
}
|
||||
|
||||
// Ne conserve que les chiffres qui gardent chaque champ complétable, et s'arrête
|
||||
// au premier chiffre invalide (rien de ce qui suit n'est réinterprété). maska
|
||||
// réinsère ensuite les séparateurs via le masque structurel.
|
||||
function clampDigits(rawDigits: string, fields: Field[]): string {
|
||||
let result = ''
|
||||
let di = 0
|
||||
|
||||
for (const field of fields) {
|
||||
let fieldDigits = ''
|
||||
while (fieldDigits.length < field.length) {
|
||||
if (di >= rawDigits.length) return result + fieldDigits // plus de saisie
|
||||
const candidate = fieldDigits + rawDigits[di]
|
||||
if (!canComplete(candidate, field)) return result + fieldDigits // 1er chiffre invalide → stop
|
||||
fieldDigits = candidate
|
||||
di++
|
||||
}
|
||||
result += fieldDigits
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit les options maska d'un champ date/heure à partir d'un gabarit
|
||||
* d'affichage (ex. `JJ/MM/AAAA`, `JJ/MM/AAAA HH:MM`).
|
||||
*
|
||||
* - `mask` : masque structurel (chiffres + séparateurs), pour le formatage/eager.
|
||||
* - `preProcess` : borne la saisie AVANT masquage, sur le 1er **et** le 2e chiffre
|
||||
* de chaque champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59), si bien
|
||||
* qu'une valeur impossible (99/99/9999, 33, 19 en mois…) ne peut pas être tapée.
|
||||
* Les impossibilités calendaires fines (31/02, 29/02 non bissextile) et les
|
||||
* bornes `min`/`max` restent du ressort de la validation, en filet.
|
||||
*/
|
||||
export function buildBoundedMask(template: string): Pick<MaskInputOptions, 'mask' | 'preProcess'> {
|
||||
const mask = template.replace(/[A-Za-z]/g, '#')
|
||||
const fields = parseFields(template)
|
||||
return {
|
||||
mask,
|
||||
preProcess: (value: string) => clampDigits(value.replace(/\D/g, ''), fields),
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {defineComponent, h, ref} from 'vue'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import {useCalendarPopover} from './useCalendarPopover'
|
||||
|
||||
const mountHost = () => {
|
||||
const api: ReturnType<typeof useCalendarPopover> = {} as never
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
Object.assign(api, useCalendarPopover(root))
|
||||
return () => h('div', {ref: root}, 'host')
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Host, {attachTo: document.body})
|
||||
return {wrapper, api}
|
||||
}
|
||||
|
||||
describe('useCalendarPopover', () => {
|
||||
it('starts closed in days view', () => {
|
||||
const {api} = mountHost()
|
||||
expect(api.isOpen.value).toBe(false)
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
})
|
||||
|
||||
it('open() opens in days view', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
expect(api.isOpen.value).toBe(true)
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
})
|
||||
|
||||
it('toggleView() switches between days and months', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
api.toggleView()
|
||||
expect(api.viewMode.value).toBe('months')
|
||||
api.toggleView()
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
})
|
||||
|
||||
it('close() resets isOpen and viewMode', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
api.toggleView()
|
||||
api.close()
|
||||
expect(api.isOpen.value).toBe(false)
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
})
|
||||
|
||||
it('closes on outside mousedown', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
expect(api.isOpen.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stays open on inside mousedown', () => {
|
||||
const {wrapper, api} = mountHost()
|
||||
api.open()
|
||||
wrapper.element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
expect(api.isOpen.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
|
||||
|
||||
export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
||||
const isOpen = ref(false)
|
||||
const viewMode = ref<'days' | 'months'>('days')
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true
|
||||
viewMode.value = 'days'
|
||||
}
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
viewMode.value = 'days'
|
||||
}
|
||||
const toggleView = () => {
|
||||
viewMode.value = viewMode.value === 'days' ? 'months' : 'days'
|
||||
}
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (!isOpen.value || !rootRef.value) return
|
||||
if (!rootRef.value.contains(event.target as Node)) close()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onMouseDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
|
||||
|
||||
return {isOpen, viewMode, open, close, toggleView}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {ref} from 'vue'
|
||||
import {useCalendarView} from './useCalendarView'
|
||||
|
||||
describe('useCalendarView', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('initialises to the current month and year', () => {
|
||||
const {currentMonth, currentYear} = useCalendarView(ref('days'))
|
||||
expect(currentMonth.value).toBe(4)
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
|
||||
it('goToNext advances the month in days view', () => {
|
||||
const {currentMonth, goToNext} = useCalendarView(ref('days'))
|
||||
goToNext()
|
||||
expect(currentMonth.value).toBe(5)
|
||||
})
|
||||
|
||||
it('rolls December to January and bumps the year', () => {
|
||||
const {currentMonth, currentYear, goToNext} = useCalendarView(ref('days'))
|
||||
currentMonth.value = 11
|
||||
goToNext()
|
||||
expect(currentMonth.value).toBe(0)
|
||||
expect(currentYear.value).toBe(2027)
|
||||
})
|
||||
|
||||
it('rolls January to December backwards', () => {
|
||||
const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days'))
|
||||
currentMonth.value = 0
|
||||
goToPrev()
|
||||
expect(currentMonth.value).toBe(11)
|
||||
expect(currentYear.value).toBe(2025)
|
||||
})
|
||||
|
||||
it('navigates the year in months view', () => {
|
||||
const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months'))
|
||||
goToNext()
|
||||
expect(currentYear.value).toBe(2027)
|
||||
goToPrev()
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
|
||||
it('selectMonth sets the current month', () => {
|
||||
const {currentMonth, selectMonth} = useCalendarView(ref('days'))
|
||||
selectMonth(0)
|
||||
expect(currentMonth.value).toBe(0)
|
||||
})
|
||||
|
||||
it('syncToIso sets month/year from a valid ISO', () => {
|
||||
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
|
||||
syncToIso('2025-12-25')
|
||||
expect(currentMonth.value).toBe(11)
|
||||
expect(currentYear.value).toBe(2025)
|
||||
})
|
||||
|
||||
it('syncToIso falls back to today for null/invalid', () => {
|
||||
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
|
||||
syncToIso('2025-12-25')
|
||||
syncToIso(null)
|
||||
expect(currentMonth.value).toBe(4)
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import {ref, type Ref} from 'vue'
|
||||
import {isValidIso} from './dateFormat'
|
||||
|
||||
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
const today = new Date()
|
||||
const currentMonth = ref(today.getMonth())
|
||||
const currentYear = ref(today.getFullYear())
|
||||
|
||||
const goToPrev = () => {
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value -= 1
|
||||
return
|
||||
}
|
||||
if (currentMonth.value === 0) {
|
||||
currentMonth.value = 11
|
||||
currentYear.value -= 1
|
||||
} else {
|
||||
currentMonth.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value += 1
|
||||
return
|
||||
}
|
||||
if (currentMonth.value === 11) {
|
||||
currentMonth.value = 0
|
||||
currentYear.value += 1
|
||||
} else {
|
||||
currentMonth.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
const selectMonth = (m: number) => {
|
||||
currentMonth.value = m
|
||||
}
|
||||
|
||||
const syncToIso = (iso: string | null) => {
|
||||
if (iso && isValidIso(iso)) {
|
||||
currentMonth.value = Number(iso.slice(5, 7)) - 1
|
||||
currentYear.value = Number(iso.slice(0, 4))
|
||||
} else {
|
||||
const now = new Date()
|
||||
currentMonth.value = now.getMonth()
|
||||
currentYear.value = now.getFullYear()
|
||||
}
|
||||
}
|
||||
|
||||
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {ref} from 'vue'
|
||||
import {useMonthMatrix} from './useMonthMatrix'
|
||||
|
||||
describe('useMonthMatrix', () => {
|
||||
it('always produces 6 weeks of 7 days', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai 2026
|
||||
expect(weeks.value).toHaveLength(6)
|
||||
weeks.value.forEach(week => expect(week.days).toHaveLength(7))
|
||||
})
|
||||
|
||||
it('starts every week on a Monday', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026))
|
||||
weeks.value.forEach(week => {
|
||||
const first = new Date(`${week.days[0].isoDate}T00:00:00`)
|
||||
expect(first.getDay()).toBe(1) // 1 = lundi
|
||||
})
|
||||
})
|
||||
|
||||
it('flags exactly the days of the current month', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai = 31 jours
|
||||
const currentMonthDays = weeks.value
|
||||
.flatMap(w => w.days)
|
||||
.filter(d => d.isCurrentMonth)
|
||||
expect(currentMonthDays).toHaveLength(31)
|
||||
expect(currentMonthDays.every(d => d.isoDate.startsWith('2026-05'))).toBe(true)
|
||||
})
|
||||
|
||||
it('handles leap year February (29 days)', () => {
|
||||
const {weeks} = useMonthMatrix(ref(1), ref(2024)) // février 2024
|
||||
const days = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth)
|
||||
expect(days).toHaveLength(29)
|
||||
})
|
||||
|
||||
it('assigns ISO week 1 to the week containing Jan 4th', () => {
|
||||
const {weeks} = useMonthMatrix(ref(0), ref(2026)) // janvier 2026
|
||||
const weekWithJan4 = weeks.value.find(w =>
|
||||
w.days.some(d => d.isoDate === '2026-01-04'),
|
||||
)
|
||||
expect(weekWithJan4?.weekNumber).toBe(1)
|
||||
})
|
||||
|
||||
it('reacts to month/year changes', () => {
|
||||
const month = ref(4)
|
||||
const year = ref(2026)
|
||||
const {weeks} = useMonthMatrix(month, year)
|
||||
const mayCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
|
||||
month.value = 1 // février
|
||||
year.value = 2024
|
||||
const febCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
|
||||
expect(mayCount).toBe(31)
|
||||
expect(febCount).toBe(29)
|
||||
})
|
||||
|
||||
describe('isToday', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('flags only today', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026))
|
||||
const todays = weeks.value.flatMap(w => w.days).filter(d => d.isToday)
|
||||
expect(todays).toHaveLength(1)
|
||||
expect(todays[0].isoDate).toBe('2026-05-19')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import {computed, type ComputedRef, type Ref} from 'vue'
|
||||
|
||||
export type DayCell = {
|
||||
isoDate: string
|
||||
day: number
|
||||
isCurrentMonth: boolean
|
||||
isToday: boolean
|
||||
}
|
||||
export type WeekRow = {
|
||||
weekNumber: number
|
||||
days: DayCell[]
|
||||
}
|
||||
|
||||
const toIso = (d: Date): string => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
const isoWeek = (d: Date): number => {
|
||||
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
||||
const dayNum = target.getUTCDay() || 7 // dimanche = 7
|
||||
target.setUTCDate(target.getUTCDate() + 4 - dayNum) // jeudi de la semaine
|
||||
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
|
||||
return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
|
||||
}
|
||||
|
||||
export function useMonthMatrix(
|
||||
month: Ref<number>,
|
||||
year: Ref<number>,
|
||||
): {weeks: ComputedRef<WeekRow[]>} {
|
||||
const weeks = computed<WeekRow[]>(() => {
|
||||
const todayIso = toIso(new Date())
|
||||
const first = new Date(year.value, month.value, 1)
|
||||
// recule jusqu'au lundi (getDay : 0 = dimanche)
|
||||
const offset = (first.getDay() + 6) % 7
|
||||
const start = new Date(year.value, month.value, 1 - offset)
|
||||
|
||||
const rows: WeekRow[] = []
|
||||
const cursor = new Date(start)
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const days: DayCell[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const iso = toIso(cursor)
|
||||
days.push({
|
||||
isoDate: iso,
|
||||
day: cursor.getDate(),
|
||||
isCurrentMonth: cursor.getMonth() === month.value,
|
||||
isToday: iso === todayIso,
|
||||
})
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
rows.push({weekNumber: isoWeek(new Date(`${days[0].isoDate}T00:00:00`)), days})
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
return {weeks}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="root"
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="maskaOptions"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
:readonly="inputReadonly"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="editable ? draft : displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
@focus="onFocus(); onKbdFocus()"
|
||||
@input="onInput"
|
||||
@blur="onBlur(); onKbdBlur()"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="showGhost"
|
||||
data-test="format-ghost"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-0 right-0 top-1/2 flex h-10 -translate-y-1/2 items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent pl-3 pr-10 text-lg"
|
||||
><span
|
||||
data-test="ghost-typed"
|
||||
class="text-black"
|
||||
>{{ ghostTyped }}</span><span
|
||||
data-test="ghost-remaining"
|
||||
class="text-m-muted"
|
||||
>{{ ghostRemaining }}</span></div>
|
||||
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<button
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
aria-label="Effacer la date"
|
||||
@click.stop="onClearClick"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:close"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<Icon
|
||||
data-test="calendar-icon"
|
||||
icon="mdi:calendar-blank"
|
||||
:width="24"
|
||||
:height="24"
|
||||
:class="[iconStateClass, (disabled || readonly) ? 'cursor-not-allowed' : 'cursor-pointer']"
|
||||
@click="onFieldClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
data-test="popover"
|
||||
role="dialog"
|
||||
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
|
||||
>
|
||||
<CalendarHeader
|
||||
:view-mode="viewMode"
|
||||
:current-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
@prev="goToPrev"
|
||||
@next="goToNext"
|
||||
@toggle-view="toggleView"
|
||||
/>
|
||||
<slot
|
||||
v-if="viewMode === 'days'"
|
||||
:current-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
:close="closePopover"
|
||||
/>
|
||||
<MonthPicker
|
||||
v-else
|
||||
:selected-month="currentMonth"
|
||||
@select="onSelectMonth"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import {vMaska} from 'maska/vue'
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
import MalioRequiredMark from '../../shared/RequiredMark.vue'
|
||||
import CalendarHeader from './CalendarHeader.vue'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||
import {useCalendarView} from '../composables/useCalendarView'
|
||||
import {buildBoundedMask} from '../composables/maskTemplate'
|
||||
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
displayValue: string
|
||||
syncTo: string | null
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
placeholderTemplate?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
label: '',
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
clearable: true,
|
||||
editable: false,
|
||||
placeholderTemplate: 'JJ/MM/AAAA',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear' | 'close'): void
|
||||
(e: 'commit', value: string): void
|
||||
// Mois affiché (month 0-11) : émis à l'ouverture du popover et à chaque
|
||||
// navigation, pour qu'un consommateur (ex. SIRH) charge les données du mois.
|
||||
(e: 'month-change', value: {month: number, year: number}): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const draft = ref(props.displayValue)
|
||||
// Le masque maska est dérivé du gabarit : masque structurel pour le formatage,
|
||||
// + preProcess qui borne la saisie (1er ET 2e chiffre : jour 1-31, mois 1-12,
|
||||
// heure 0-23, minute 0-59) afin qu'une valeur impossible (99/99/9999, 33, mois 19…)
|
||||
// ne puisse pas être tapée. eager : pose les séparateurs dès qu'un groupe est complet.
|
||||
const maskaOptions = computed<MaskInputOptions>(() => {
|
||||
if (!props.editable) return {eager: false}
|
||||
const {mask, preProcess} = buildBoundedMask(props.placeholderTemplate)
|
||||
return {mask, preProcess, eager: true}
|
||||
})
|
||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||
|
||||
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
|
||||
// par-dessus l'input (dont le texte est rendu transparent en mode editable).
|
||||
// Espaces → insécables : un espace en bord de span (flex-item) serait sinon rogné,
|
||||
// collant la suite du gabarit à la date (« 12/12/1999HH:MM »).
|
||||
const nbsp = (s: string) => s.replace(/ /g, ' ')
|
||||
const ghostTyped = computed(() => nbsp(draft.value))
|
||||
const ghostRemaining = computed(() => nbsp(props.placeholderTemplate.slice(draft.value.length)))
|
||||
|
||||
watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
|
||||
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
||||
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isFilled = computed(() =>
|
||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||
)
|
||||
const describedBy = computed(() =>
|
||||
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
|
||||
)
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
if (!value) emit('close')
|
||||
})
|
||||
|
||||
// Émet le mois affiché tant que le popover est ouvert : une fois à l'ouverture
|
||||
// (isOpen → true, après syncToIso), puis à chaque changement de mois/année.
|
||||
watch([isOpen, currentMonth, currentYear], () => {
|
||||
if (isOpen.value) emit('month-change', {month: currentMonth.value, year: currentYear.value})
|
||||
})
|
||||
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.editable) {
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (props.disabled || props.readonly || !props.editable) return
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
draft.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
// Reset local immédiat : sur saisie invalide, modelValue est déjà null, donc le
|
||||
// watch(displayValue) ne se redéclenche pas — il faut vider le draft soi-même.
|
||||
const onClearClick = () => {
|
||||
draft.value = ''
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
closePopover()
|
||||
}
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (isOpen.value) {
|
||||
e.preventDefault()
|
||||
closePopover()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.editable) {
|
||||
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onFieldClick()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.syncTo, (value) => {
|
||||
if (isOpen.value) syncToIso(value)
|
||||
})
|
||||
|
||||
const onSelectMonth = (m: number) => {
|
||||
selectMonth(m)
|
||||
toggleView()
|
||||
}
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge('relative flex h-12 w-full items-center', props.groupClass),
|
||||
)
|
||||
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
||||
hasError.value
|
||||
? 'border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success'
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
// En mode editable, le texte réel est masqué : c'est le gabarit fantôme qui l'affiche.
|
||||
props.editable ? 'text-transparent caret-black' : '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
|
||||
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: props.disabled
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: isOpen.value
|
||||
? 'text-m-primary'
|
||||
: 'peer-placeholder-shown:text-m-muted text-black',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (props.disabled) return 'text-m-muted'
|
||||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
if (isOpen.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return 'text-m-muted'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-label {
|
||||
background: white;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-prev"
|
||||
class="ml-2 flex self-start rounded"
|
||||
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:chevron-left"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-toggle"
|
||||
class="flex gap-1 rounded text-base font-medium"
|
||||
@click="emit('toggle-view')"
|
||||
>
|
||||
<span class="mt-[2px]">{{ label }}</span>
|
||||
<Icon
|
||||
icon="mdi:chevron-down"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-next"
|
||||
class="mr-2 flex self-start rounded"
|
||||
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
|
||||
@click="emit('next')"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:chevron-right"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
|
||||
defineOptions({name: 'MalioDateCalendarHeader'})
|
||||
|
||||
const props = defineProps<{
|
||||
viewMode: 'days' | 'months'
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'prev' | 'next' | 'toggle-view'): void
|
||||
}>()
|
||||
|
||||
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
||||
|
||||
const label = computed(() => {
|
||||
const name = monthsLong[props.currentMonth]
|
||||
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
|
||||
})
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import MonthGrid from './MonthGrid.vue'
|
||||
|
||||
type MonthGridProps = {
|
||||
month: number
|
||||
year: number
|
||||
selectedDate?: string | null
|
||||
markedDates?: Record<string, 'success' | 'danger'>
|
||||
min?: string
|
||||
max?: string
|
||||
}
|
||||
|
||||
const Grid = MonthGrid as DefineComponent<MonthGridProps>
|
||||
const mountGrid = (props: MonthGridProps) => mount(Grid, {props, attachTo: document.body})
|
||||
|
||||
// Récupère la pastille (span rond) qui porte les classes de `cellClass` pour un jour donné.
|
||||
const pill = (wrapper: ReturnType<typeof mountGrid>, iso: string) =>
|
||||
wrapper.get(`[data-iso="${iso}"]`).get('span.rounded-full')
|
||||
|
||||
describe('MalioDateMonthGrid — markedDates', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('applique un fond success sur un jour marqué', () => {
|
||||
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
|
||||
expect(pill(wrapper, '2026-05-20').classes()).toContain('bg-m-success/15')
|
||||
})
|
||||
|
||||
it('applique un fond danger sur un jour marqué', () => {
|
||||
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-21': 'danger'}})
|
||||
expect(pill(wrapper, '2026-05-21').classes()).toContain('bg-m-danger/15')
|
||||
})
|
||||
|
||||
it('ne marque pas les jours absents de markedDates', () => {
|
||||
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
|
||||
const classes = pill(wrapper, '2026-05-22').classes()
|
||||
expect(classes).not.toContain('bg-m-success/15')
|
||||
expect(classes).not.toContain('bg-m-danger/15')
|
||||
})
|
||||
|
||||
it('précédence : la sélection (primary) prime sur la variante marquée', () => {
|
||||
const wrapper = mountGrid({
|
||||
month: 4,
|
||||
year: 2026,
|
||||
selectedDate: '2026-05-22',
|
||||
markedDates: {'2026-05-22': 'success'},
|
||||
})
|
||||
const classes = pill(wrapper, '2026-05-22').classes()
|
||||
expect(classes).toContain('bg-m-primary')
|
||||
expect(classes).toContain('text-white')
|
||||
expect(classes).not.toContain('bg-m-success/15')
|
||||
})
|
||||
|
||||
it('today marqué : garde sa bordure ET reçoit le fond marqué', () => {
|
||||
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-19': 'success'}})
|
||||
const classes = pill(wrapper, '2026-05-19').classes()
|
||||
expect(classes).toContain('border-m-primary')
|
||||
expect(classes).toContain('bg-m-success/15')
|
||||
})
|
||||
|
||||
it('today non marqué : bordure sans fond marqué', () => {
|
||||
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
|
||||
const classes = pill(wrapper, '2026-05-19').classes()
|
||||
expect(classes).toContain('border-m-primary')
|
||||
expect(classes).not.toContain('bg-m-success/15')
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
data-test="month-grid"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
|
||||
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
|
||||
S
|
||||
</div>
|
||||
<div
|
||||
v-for="d in dayLabels"
|
||||
:key="d"
|
||||
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
|
||||
>
|
||||
{{ d }}
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="(week, wIndex) in weeks"
|
||||
:key="week.days[0].isoDate"
|
||||
>
|
||||
<component
|
||||
:is="interactiveWeekNumber ? 'button' : 'div'"
|
||||
data-test="week-number"
|
||||
:data-week-start="week.days[0].isoDate"
|
||||
:data-marked="markedWeekStart === week.days[0].isoDate"
|
||||
:type="interactiveWeekNumber ? 'button' : undefined"
|
||||
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
|
||||
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
|
||||
:class="[
|
||||
weekNumberClass(week),
|
||||
wIndex === 0 ? 'rounded-t-md' : '',
|
||||
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
|
||||
]"
|
||||
@click="onWeekNumberClick(week)"
|
||||
@mouseenter="onWeekNumberHover(week)"
|
||||
>
|
||||
{{ week.weekNumber }}
|
||||
</component>
|
||||
<button
|
||||
v-for="cell in week.days"
|
||||
:key="cell.isoDate"
|
||||
type="button"
|
||||
data-test="day"
|
||||
:data-iso="cell.isoDate"
|
||||
:data-range-role="roleOf(cell)"
|
||||
:disabled="!inRange(cell.isoDate)"
|
||||
:aria-label="ariaLabel(cell)"
|
||||
:aria-disabled="!inRange(cell.isoDate)"
|
||||
class="relative flex h-[45px] w-full items-center justify-center"
|
||||
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||
@click="onSelect(cell.isoDate)"
|
||||
@mouseenter="emit('hover', cell.isoDate)"
|
||||
>
|
||||
<span
|
||||
v-if="roleOf(cell) === 'in-range'"
|
||||
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 bg-m-primary-light"
|
||||
/>
|
||||
<span
|
||||
v-else-if="roleOf(cell) === 'start'"
|
||||
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-l-full bg-m-primary-light"
|
||||
/>
|
||||
<span
|
||||
v-else-if="roleOf(cell) === 'end'"
|
||||
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-r-full bg-m-primary-light"
|
||||
/>
|
||||
<span
|
||||
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
|
||||
:class="cellClass(cell)"
|
||||
>
|
||||
{{ cell.day }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, toRef} from 'vue'
|
||||
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
|
||||
import {isDateInRange} from '../composables/dateFormat'
|
||||
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
|
||||
|
||||
defineOptions({name: 'MalioDateMonthGrid'})
|
||||
|
||||
// Statut générique par jour : aucune sémantique métier dans le layer, juste un
|
||||
// fond tokenisé. `success` et `danger` suffisent pour l'instant (MUI-45).
|
||||
type MarkedVariant = 'success' | 'danger'
|
||||
const markedBg: Record<MarkedVariant, string> = {
|
||||
success: 'bg-m-success/15',
|
||||
danger: 'bg-m-danger/15',
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
month: number
|
||||
year: number
|
||||
selectedDate?: string | null
|
||||
rangeStart?: string | null
|
||||
rangeEnd?: string | null
|
||||
previewDate?: string | null
|
||||
interactiveWeekNumber?: boolean
|
||||
markedWeekStart?: string | null
|
||||
markedDates?: Record<string, MarkedVariant>
|
||||
min?: string
|
||||
max?: string
|
||||
}>(),
|
||||
{
|
||||
selectedDate: null,
|
||||
rangeStart: undefined,
|
||||
rangeEnd: undefined,
|
||||
previewDate: undefined,
|
||||
interactiveWeekNumber: false,
|
||||
markedWeekStart: null,
|
||||
markedDates: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', iso: string): void
|
||||
(e: 'hover', iso: string | null): void
|
||||
}>()
|
||||
|
||||
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
||||
|
||||
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
|
||||
|
||||
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
|
||||
|
||||
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
|
||||
|
||||
const weekNumberClass = (week: WeekRow) => {
|
||||
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
|
||||
const parts = ['bg-m-primary-light']
|
||||
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
|
||||
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const onWeekNumberClick = (week: WeekRow) => {
|
||||
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
|
||||
emit('select', week.days[0].isoDate)
|
||||
}
|
||||
|
||||
const onWeekNumberHover = (week: WeekRow) => {
|
||||
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
|
||||
emit('hover', week.days[0].isoDate)
|
||||
}
|
||||
|
||||
const isRangeMode = computed(() => props.rangeStart !== undefined)
|
||||
const bounds = computed(() =>
|
||||
isRangeMode.value
|
||||
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
|
||||
: null,
|
||||
)
|
||||
|
||||
const roleOf = (cell: DayCell): DayRangeRole => {
|
||||
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
|
||||
return props.selectedDate === cell.isoDate ? 'single' : 'none'
|
||||
}
|
||||
|
||||
const ariaLabel = (cell: DayCell) => {
|
||||
const [, m, d] = cell.isoDate.split('-')
|
||||
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
|
||||
}
|
||||
|
||||
const cellClass = (cell: DayCell) => {
|
||||
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
|
||||
const role = roleOf(cell)
|
||||
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
|
||||
if (role === 'in-range') return 'text-black'
|
||||
const parts = ['hover:bg-m-primary/10']
|
||||
// Précédence : sélection/range (primary, return ci-dessus) > variante marquée > défaut.
|
||||
// `today` n'est pas exclusif : il garde sa bordure ET peut recevoir le fond marqué.
|
||||
const marked = props.markedDates?.[cell.isoDate]
|
||||
if (marked) parts.push(markedBg[marked])
|
||||
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
|
||||
else if (cell.isCurrentMonth) parts.push('text-black')
|
||||
else parts.push('opacity-[60%]')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const onSelect = (iso: string) => {
|
||||
if (!inRange(iso)) return
|
||||
emit('select', iso)
|
||||
}
|
||||
</script>
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
data-test="month-picker"
|
||||
class="grid grid-cols-3 gap-3"
|
||||
>
|
||||
<button
|
||||
v-for="(name, index) in months"
|
||||
:key="name"
|
||||
type="button"
|
||||
data-test="month"
|
||||
:data-month="index"
|
||||
class="flex h-[45px] w-full items-center justify-center"
|
||||
@click="emit('select', index)"
|
||||
>
|
||||
<span
|
||||
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||
:class="index === selectedMonth
|
||||
? 'bg-m-primary text-white'
|
||||
: 'text-black hover:bg-m-primary/10'"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({name: 'MalioDateMonthPicker'})
|
||||
|
||||
defineProps<{selectedMonth?: number}>()
|
||||
|
||||
const emit = defineEmits<{(e: 'select', month: number): void}>()
|
||||
|
||||
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
</script>
|
||||
@@ -1,340 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import Drawer from './Drawer.vue'
|
||||
|
||||
type DrawerProps = {
|
||||
id?: string
|
||||
modelValue?: boolean
|
||||
side?: 'right' | 'left'
|
||||
showClose?: boolean
|
||||
dismissable?: boolean
|
||||
closeOnEscape?: boolean
|
||||
ariaLabel?: string
|
||||
drawerClass?: string
|
||||
overlayClass?: string
|
||||
headerClass?: string
|
||||
bodyClass?: string
|
||||
footerClass?: string
|
||||
}
|
||||
|
||||
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
|
||||
|
||||
function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>) {
|
||||
return mount(DrawerForTest, {
|
||||
props,
|
||||
slots,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('MalioDrawer', () => {
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('does not render when modelValue is false', () => {
|
||||
const wrapper = mountComponent({ modelValue: false })
|
||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the panel when modelValue is true', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders default slot in the body', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true },
|
||||
{ default: '<p data-test="content">Contenu</p>' },
|
||||
)
|
||||
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
|
||||
})
|
||||
|
||||
it('works in uncontrolled mode (defaults closed)', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses custom id when provided', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, id: 'my-drawer' })
|
||||
expect(wrapper.find('.fixed').attributes('id')).toBe('my-drawer')
|
||||
})
|
||||
|
||||
it('generates an id when not provided', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/)
|
||||
})
|
||||
|
||||
it('has role="dialog" and aria-modal on the panel', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
const panel = wrapper.find('[data-test="panel"]')
|
||||
expect(panel.attributes('role')).toBe('dialog')
|
||||
expect(panel.attributes('aria-modal')).toBe('true')
|
||||
})
|
||||
|
||||
it('applies drawerClass to the panel', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
|
||||
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
||||
})
|
||||
|
||||
it('renders the #header slot inside the header bar', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true },
|
||||
{ header: '<h2 data-test="title">Titre</h2>' },
|
||||
)
|
||||
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
|
||||
})
|
||||
|
||||
it('renders the header bar when showClose is true even without #header', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the header bar when no #header and showClose is false', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, showClose: false })
|
||||
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the close button by default', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the close button when showClose is false', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true, showClose: false },
|
||||
{ header: '<h2>Titre</h2>' },
|
||||
)
|
||||
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('close button renders mdi:cancel-bold icon', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
const icon = wrapper.findComponent(IconifyIcon)
|
||||
expect(icon.props('icon')).toBe('mdi:cancel-bold')
|
||||
})
|
||||
|
||||
it('close button has aria-label "Fermer"', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
|
||||
})
|
||||
|
||||
it('emits update:modelValue false and close on close button click', async () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
await wrapper.find('[data-test="close-button"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sets aria-labelledby to the header id when #header is provided', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true, id: 'test-drawer' },
|
||||
{ header: '<h2>Titre</h2>' },
|
||||
)
|
||||
const panel = wrapper.find('[data-test="panel"]')
|
||||
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-header')
|
||||
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-drawer-header')
|
||||
})
|
||||
|
||||
it('sets aria-label from ariaLabel when no #header is provided', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Panneau latéral' })
|
||||
const panel = wrapper.find('[data-test="panel"]')
|
||||
expect(panel.attributes('aria-label')).toBe('Panneau latéral')
|
||||
expect(panel.attributes('aria-labelledby')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies headerClass to the header bar', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
|
||||
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
||||
})
|
||||
|
||||
it('renders the #footer slot in a footer pinned below the body', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true },
|
||||
{ footer: '<button data-test="save">Enregistrer</button>' },
|
||||
)
|
||||
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the footer wrapper when no #footer slot', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies bodyClass to the body', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
|
||||
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
||||
})
|
||||
|
||||
it('applies footerClass to the footer', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true, footerClass: 'justify-end' },
|
||||
{ footer: '<span>pied</span>' },
|
||||
)
|
||||
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
|
||||
})
|
||||
|
||||
it('aligns to the right by default', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
expect(wrapper.find('.fixed').classes()).toContain('justify-end')
|
||||
})
|
||||
|
||||
it('aligns to the left when side is "left"', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, side: 'left' })
|
||||
expect(wrapper.find('.fixed').classes()).toContain('justify-start')
|
||||
})
|
||||
|
||||
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not close on backdrop click when dismissable is false', async () => {
|
||||
const wrapper = mountComponent({ modelValue: true, dismissable: false })
|
||||
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies overlayClass to the backdrop', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
|
||||
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
|
||||
})
|
||||
|
||||
it('closes on Escape key when closeOnEscape is true', async () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not close on Escape when closeOnEscape is false', async () => {
|
||||
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
|
||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('locks body scroll when opened and restores it when closed', async () => {
|
||||
const wrapper = mountComponent({ modelValue: false })
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
})
|
||||
|
||||
it('moves focus into the panel when opened', async () => {
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: false, showClose: false },
|
||||
slots: { default: '<button data-test="first">OK</button>' },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
const first = wrapper.find('[data-test="first"]').element
|
||||
expect(document.activeElement).toBe(first)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('restores focus to the trigger when closed', async () => {
|
||||
const trigger = document.createElement('button')
|
||||
document.body.appendChild(trigger)
|
||||
trigger.focus()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: false },
|
||||
slots: { default: '<button>OK</button>' },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
wrapper.unmount()
|
||||
trigger.remove()
|
||||
})
|
||||
|
||||
it('moves focus to the close button on open (default showClose)', async () => {
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: false, showClose: true },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: true, showClose: false },
|
||||
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
|
||||
last.focus()
|
||||
expect(document.activeElement).toBe(last)
|
||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
|
||||
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: true, showClose: false },
|
||||
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
|
||||
first.focus()
|
||||
expect(document.activeElement).toBe(first)
|
||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
|
||||
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not release body scroll-lock when one stacked drawer closes while another is still open', async () => {
|
||||
const wrapperA = mount(DrawerForTest, {
|
||||
props: { modelValue: false },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
const wrapperB = mount(DrawerForTest, {
|
||||
props: { modelValue: false },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
|
||||
// Open drawer A → scroll locked
|
||||
await wrapperA.setProps({ modelValue: true })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
// Open drawer B → still locked
|
||||
await wrapperB.setProps({ modelValue: true })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
// Close drawer B → A is still open, scroll must remain locked
|
||||
await wrapperB.setProps({ modelValue: false })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
// Close drawer A → both closed, scroll-lock released
|
||||
await wrapperA.setProps({ modelValue: false })
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,293 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
:name="`drawer-${side}`"
|
||||
appear
|
||||
@after-leave="isRendered = false"
|
||||
>
|
||||
<div
|
||||
v-if="isRendered && isOpen"
|
||||
:id="componentId"
|
||||
class="fixed inset-0 z-50 flex"
|
||||
:class="side === 'right' ? 'justify-end' : 'justify-start'"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<div
|
||||
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
||||
data-test="backdrop"
|
||||
@click="onBackdropClick"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="panelRef"
|
||||
:class="twMerge(
|
||||
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
|
||||
drawerClass,
|
||||
)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="hasHeader ? headerId : undefined"
|
||||
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
||||
tabindex="-1"
|
||||
data-test="panel"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div
|
||||
v-if="hasHeader || showClose"
|
||||
:class="twMerge('flex items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
|
||||
data-test="header"
|
||||
>
|
||||
<div
|
||||
:id="headerId"
|
||||
class="min-w-0 flex-1"
|
||||
data-test="header-content"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<button
|
||||
v-if="showClose"
|
||||
type="button"
|
||||
aria-label="Fermer"
|
||||
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
||||
data-test="close-button"
|
||||
@click="close"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:cancel-bold"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
||||
data-test="body"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
|
||||
data-test="footer"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useAttrs,
|
||||
useId,
|
||||
useSlots,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
defineOptions({ name: 'MalioDrawer', inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
modelValue?: boolean
|
||||
side?: 'right' | 'left'
|
||||
showClose?: boolean
|
||||
dismissable?: boolean
|
||||
closeOnEscape?: boolean
|
||||
ariaLabel?: string
|
||||
drawerClass?: string
|
||||
overlayClass?: string
|
||||
headerClass?: string
|
||||
bodyClass?: string
|
||||
footerClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
modelValue: undefined,
|
||||
side: 'right',
|
||||
showClose: true,
|
||||
dismissable: true,
|
||||
closeOnEscape: true,
|
||||
ariaLabel: '',
|
||||
drawerClass: '',
|
||||
overlayClass: '',
|
||||
headerClass: '',
|
||||
bodyClass: '',
|
||||
footerClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
|
||||
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
|
||||
|
||||
const slots = useSlots()
|
||||
const headerId = computed(() => `${componentId.value}-header`)
|
||||
const hasHeader = computed(() => !!slots.header)
|
||||
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const localValue = ref(false)
|
||||
const isOpen = computed(() =>
|
||||
isControlled.value ? props.modelValue! : localValue.value,
|
||||
)
|
||||
const isRendered = ref(isOpen.value)
|
||||
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
// Per-instance flag: true while this drawer holds a scroll-lock count slot.
|
||||
let lockedByThisInstance = false
|
||||
|
||||
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
|
||||
),
|
||||
).filter((el) => el.tabIndex !== -1)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
||||
if (!lockedByThisInstance) {
|
||||
lockedByThisInstance = true
|
||||
openDrawerCount++
|
||||
if (openDrawerCount === 1) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
const panel = panelRef.value
|
||||
if (!panel) return
|
||||
const focusable = getFocusable(panel)
|
||||
;(focusable[0] ?? panel).focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (lockedByThisInstance) {
|
||||
lockedByThisInstance = false
|
||||
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
||||
if (openDrawerCount === 0) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
previouslyFocused?.focus?.()
|
||||
previouslyFocused = null
|
||||
}
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) {
|
||||
isRendered.value = true
|
||||
onOpen()
|
||||
}
|
||||
else {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (isOpen.value) onOpen()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// If this instance is still holding a scroll-lock slot, release it.
|
||||
if (lockedByThisInstance) {
|
||||
lockedByThisInstance = false
|
||||
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
||||
if (openDrawerCount === 0) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function onBackdropClick() {
|
||||
if (props.dismissable) close()
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.closeOnEscape) {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
const panel = panelRef.value
|
||||
if (!panel) return
|
||||
const focusable = getFocusable(panel)
|
||||
if (focusable.length === 0) {
|
||||
e.preventDefault()
|
||||
panel.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]!
|
||||
const last = focusable[focusable.length - 1]!
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isControlled.value) localValue.value = false
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Shared across all MalioDrawer instances: only the last open drawer releases the body scroll-lock.
|
||||
let openDrawerCount = 0
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-right-enter-active,
|
||||
.drawer-right-leave-active,
|
||||
.drawer-left-enter-active,
|
||||
.drawer-left-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drawer-right-enter-active > div:last-child,
|
||||
.drawer-right-leave-active > div:last-child,
|
||||
.drawer-left-enter-active > div:last-child,
|
||||
.drawer-left-leave-active > div:last-child {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-right-enter-from,
|
||||
.drawer-right-leave-to,
|
||||
.drawer-left-enter-from,
|
||||
.drawer-left-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-right-enter-from > div:last-child,
|
||||
.drawer-right-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-left-enter-from > div:last-child,
|
||||
.drawer-left-leave-to > div:last-child {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,281 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import InputAmount from './InputAmount.vue'
|
||||
|
||||
type InputAmountProps = {
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
modelValue?: string | null
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
required?: boolean
|
||||
maxLength?: number | string
|
||||
minLength?: number | string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
|
||||
|
||||
const mountInputAmount = (props: InputAmountProps = {}) =>
|
||||
mount(InputAmountForTest, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
IconifyIcon: {
|
||||
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('MalioInputAmount', () => {
|
||||
it('renders as a text input with decimal input mode', () => {
|
||||
const wrapper = mountInputAmount()
|
||||
|
||||
expect(wrapper.get('input').attributes('type')).toBe('text')
|
||||
expect(wrapper.get('input').attributes('inputmode')).toBe('decimal')
|
||||
})
|
||||
|
||||
it('renders the default icon with muted styling', () => {
|
||||
const wrapper = mountInputAmount()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('generates an amount-specific id', () => {
|
||||
const wrapper = mountInputAmount({label: 'Montant'})
|
||||
|
||||
const inputId = wrapper.get('input').attributes('id')
|
||||
|
||||
expect(inputId?.startsWith('malio-input-amount-')).toBe(true)
|
||||
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
||||
})
|
||||
|
||||
it('applies the provided input classes', () => {
|
||||
const wrapper = mountInputAmount({inputClass: 'text-right'})
|
||||
|
||||
expect(wrapper.get('input').classes()).toContain('text-right')
|
||||
})
|
||||
|
||||
it('links hint text through aria-describedby', () => {
|
||||
const wrapper = mountInputAmount({hint: 'Saisissez un montant'})
|
||||
|
||||
const inputId = wrapper.get('input').attributes('id')
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
|
||||
expect(wrapper.get('p').attributes('id')).toBe(`${inputId}-describedby`)
|
||||
})
|
||||
|
||||
it('sets aria-invalid and describedby when showing an error', () => {
|
||||
const wrapper = mountInputAmount({error: 'Montant invalide'})
|
||||
|
||||
const inputId = wrapper.get('input').attributes('id')
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Montant invalide')
|
||||
})
|
||||
|
||||
it('keeps dots as the decimal separator on input', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('12.5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||
})
|
||||
|
||||
it('accepts commas but normalizes them to dots', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('0012,345abc')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
|
||||
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||
})
|
||||
|
||||
it('normalizes a leading decimal separator', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue(',5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||
})
|
||||
|
||||
it('keeps the normalized decimal value on blur', async () => {
|
||||
const wrapper = mountInputAmount()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('12.5')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
|
||||
expect(input.element.value).toBe('12,5')
|
||||
})
|
||||
|
||||
it('keeps integer values unchanged on blur', async () => {
|
||||
const wrapper = mountInputAmount()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('12')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12']])
|
||||
expect(input.element.value).toBe('12')
|
||||
})
|
||||
|
||||
it('keeps an empty value empty on blur', async () => {
|
||||
const wrapper = mountInputAmount()
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['']])
|
||||
expect(input.element.value).toBe('')
|
||||
})
|
||||
|
||||
it('supports icon positioning on the left', () => {
|
||||
const wrapper = mountInputAmount({
|
||||
label: 'Montant',
|
||||
iconPosition: 'left',
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||
expect(wrapper.get('label').classes()).toContain('left-11')
|
||||
})
|
||||
|
||||
it('shows primary icon color on focus', async () => {
|
||||
const wrapper = mountInputAmount()
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('shows black icon color when filled and unfocused', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '12,50'})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||
const field = wrapper.get('input')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(field.classes()).not.toContain('grow-height')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly vide : icône en text-m-muted', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir et icône noire', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true, modelValue: '12.50'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||
})
|
||||
|
||||
it('groupe un grand montant avec décimales', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567,89')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('12345')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('maxLength autorise une valeur à la limite', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('1234')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||
})
|
||||
|
||||
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||
const wrapper = mountInputAmount({maxLength: 4})
|
||||
|
||||
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user