17 Commits

Author SHA1 Message Date
0a3cf50576 Merge branch 'feat/333-creation-text-input' into feat/335-ajout-package-histoire 2026-02-25 11:34:19 +01:00
e73733de95 Merge branch 'feat/333-creation-text-input' into feat/335-ajout-package-histoire 2026-02-25 11:33:17 +01:00
1bce3304c9 Merge remote-tracking branch 'origin/feat/333-creation-text-input' into feat/333-creation-text-input 2026-02-25 10:37:52 +01:00
67436a0025 fix : correction du composant 2026-02-25 10:36:50 +01:00
a8e5f2e05a feat : ajout d'histoire et d'une story 2026-02-25 10:26:39 +01:00
a5ced1456e Actualiser app/assets/css/malio.css 2026-02-24 10:24:07 +00:00
480defcd82 feat : ajout des tests et retour sur le composant 2026-02-24 10:48:46 +01:00
33179ade9d Merge branch 'develop' into feat/333-creation-text-input 2026-02-23 11:29:42 +01:00
82ecc9cfe2 feat : ajout config vitest/make/pre-commit/commit-msg + un exemple de test vitest 2026-02-23 11:29:16 +01:00
a9627a6875 feat CHANGELOG.md 2026-02-23 11:14:24 +01:00
71797f0804 Merge branch 'develop' into feat/333-creation-text-input 2026-02-23 11:12:05 +01:00
65d9060e26 feat : ajout du template de MR + CHANGELOG.md 2026-02-23 11:11:31 +01:00
1013c83018 feat : ajout du masque et de l'etat succes sur le texte 2026-02-23 11:01:37 +01:00
9710fd63d7 fix : lint 2026-02-23 09:01:16 +01:00
75a3912727 feat : ajout du masque et de l'etat succes sur le texte 2026-02-23 08:56:27 +01:00
2340256ee1 feat : 333 creation du composant inputtext 2026-02-20 12:06:31 +01:00
ec4c157226 fix: readme.md 2026-02-19 11:18:36 +01:00
22 changed files with 3907 additions and 171 deletions

View File

@@ -0,0 +1,23 @@
---
name: "Merge Request"
about: "Template de MR"
title: "[#NUMERO_TICKET] TITRE TICKET"
ref: "main"
---
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
| | |
## Description de la PR
## Modification du .env
## Check list
- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.13.1

View File

@@ -0,0 +1,173 @@
<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>
<MalioInputText v-model="simpleValue"/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
<MalioInputText
v-model="nameValue"
label="Nom d'utilisateur"
name="username"
autocomplete="username"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône</h2>
<MalioInputText
v-model="searchValue"
label="Recherche"
icon-name="mdi:magnify"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputText
model-value="Valeur verrouillée"
disabled
label="Champ désactivé"
/>
<MalioInputText
disabled
label="Champ désactivé"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputText
model-value="Lecture seule"
readonly
label="Champ readonly"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Hint + icône</h2>
<MalioInputText
v-model="cityValue"
label="Ville"
icon-name="mdi:map-marker-outline"
icon-size="20"
hint="Commencez à taper le nom de la ville"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur + icône</h2>
<MalioInputText
model-value="ab"
label="Code promo"
icon-name="mdi:alert-circle-outline"
icon-size="20"
error="Le code est invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès + icône</h2>
<MalioInputText
label="Code"
success="Code valide"
icon-name="mdi:alert-circle-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly + icône</h2>
<MalioInputText
model-value="Commande #A-2048"
label="Référence"
readonly
icon-name="mdi:lock-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé + icône</h2>
<MalioInputText
model-value="Compte indisponible"
label="Compte"
disabled
icon-name="mdi:account-off-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
<MalioInputText
label="Plaque d'immatriculation"
:mask="maskOptions"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Code dynamique</h2>
<MalioInputText
v-model="dynamicCodeValue"
label="Code d'accès"
hint="Format attendu: 6 à 10 caractères alphanumériques"
:error="dynamicCodeError"
:success="dynamicCodeSuccess"
icon-name="mdi:key-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Hint,erreur et succès</h2>
<MalioInputText
v-model="codeValue"
label="Code"
hint="Format attendu: 6 à 10 caractères alphanumériques"
/>
<div class="mt-4">
<MalioInputText
model-value="invalide"
label="Code"
error="Le code doit contenir au moins 6 caractères"
/>
</div>
<div class="mt-4">
<MalioInputText
model-value="valide"
label="Code"
success="Le code est valide"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const simpleValue = ref('')
const nameValue = ref('')
const searchValue = ref('')
const codeValue = ref('')
const cityValue = ref('')
const dynamicCodeValue = ref('')
const codeRegex = /^[A-Z0-9]{6,10}$/
const normalizedDynamicCode = computed(() => dynamicCodeValue.value.toUpperCase())
const isDynamicCodeValid = computed(() => codeRegex.test(normalizedDynamicCode.value))
const dynamicCodeError = computed(() => {
if (!dynamicCodeValue.value) return ''
return isDynamicCodeValid.value ? '' : 'Code invalide (6 à 10 caractères alphanumériques)'
})
const dynamicCodeSuccess = computed(() => {
if (!dynamicCodeValue.value) return ''
return isDynamicCodeValid.value ? 'Code valide' : ''
})
const maskOptions = {
mask: '@@-###-@@',
tokens: {
'@': {
pattern: /[A-Za-z]/,
transform: (char: string) => char.toUpperCase()
}
}
}
</script>

View File

@@ -1,11 +1,107 @@
<template>
<div class="p-6 space-y-4">
<MalioInput v-model="v" label="Email" placeholder="you@example.com" />
<pre class="text-xs">{{ v }}</pre>
<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">
const v = ref('')
</script>
type LoadedModule = {
default: unknown
}
type Item = {
name: string
label: string
demoComponent?: unknown
}
const componentModules = import.meta.glob('../../app/components/malio/*.vue', { eager: true }) as Record<string, LoadedModule>
const demoModules = import.meta.glob('./composant/*.vue', { eager: true }) as Record<string, LoadedModule>
const demoByName = Object.fromEntries(
Object.entries(demoModules).map(([file, mod]) => {
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
return [name.toLowerCase(), mod.default]
}),
)
const items = computed(() =>
Object.entries(componentModules).map(([file]) => {
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
return {
name,
label: name,
demoComponent: demoByName[name.toLowerCase()],
}
}) as Item[],
)
const selectedName = ref('')
const hasInitializedSelection = ref(false)
watchEffect(() => {
if (!hasInitializedSelection.value && items.value.length > 0) {
selectedName.value = items.value[0].name
hasInitializedSelection.value = true
}
})
function selectOrToggle(name: string) {
selectedName.value = selectedName.value === name ? '' : name
}
function clearSelection() {
selectedName.value = ''
}
const selectedDemoComponent = computed(() =>
items.value.find((item) => item.name === selectedName.value)?.demoComponent,
)
const selectedDemoFileName = computed(() => {
const name = selectedName.value
if (!name) return ''
return name.charAt(0).toLowerCase() + name.slice(1)
})
</script>

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
Liste des évolutions de la librairie Malio layer UI
## [0.0.0]
### Parameters
### Added
* [#333] Création d'un composant text
### Changed
### Fixed

View File

@@ -55,26 +55,34 @@ Prévisualiser le build :
npm run preview
```
### Livraison / publication du layer
### Livraison / publication du layer (CI)
Vérifier le contenu qui sera publié :
La publication est automatique via `.gitea/workflows/release.yml` sur push `main` / `master`.
Le job CI :
1. Installe les dépendances
2. Lance `npm run dev:prepare`
3. Lance `npm run lint`
4. Lance `semantic-release` (version automatique + publish sur Gitea Packages)
Les versions sont calculées via Conventional Commits :
- `fix: ...` -> patch (`1.0.0` -> `1.0.1`)
- `feat: ...` -> minor (`1.0.0` -> `1.1.0`)
- `feat!: ...` ou `BREAKING CHANGE:` -> major (`1.0.0` -> `2.0.0`)
Secrets requis dans le repo Gitea :
- `NPM_TOKEN` : token avec droits publish package
- `RELEASE_TOKEN` : token avec droits write repo (tags/releases)
Commande locale utile avant push :
```bash
npm pack --dry-run
```
Publier sur le registry NPM configuré :
```bash
npm publish
```
Publier explicitement sur un registry Gitea :
```bash
npm publish --registry https://<gitea-host>/api/packages/<owner>/npm/
```
## Tester un composant dans le playground
Le playground étend déjà le layer via `.playground/nuxt.config.ts`.
@@ -117,13 +125,28 @@ npm run dev
## Utiliser ce layer dans un autre projet Nuxt
Installer le package :
### 1) Configurer le `.npmrc` du projet consommateur
Option simple :
```ini
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
```
Puis :
```bash
export NPM_TOKEN=TON_TOKEN_GITEA
```
### 2) Installer le package
```bash
npm install @malio/layer-ui
```
Étendre le layer dans `nuxt.config.ts` du projet consommateur :
### 3) Étendre le layer
Dans `nuxt.config.ts` du projet consommateur :
```ts
export default defineNuxtConfig({

19
app/assets/css/malio.css Normal file
View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* 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 */
--m-error: 155 17 30; /* rouge pour les erreurs */
--m-success: 15 149 70; /* vert pour les succès */
}
}

View File

@@ -0,0 +1,319 @@
import {describe, expect, it} from 'vitest'
import {config, mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Input from './InputText.vue'
type InputProps = {
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null
textSize?: string
labelClass?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconSize?: string | number
iconColor?: string
}
const InputForTest = Input as DefineComponent<InputProps>
const iconStub = {
template: '<span data-test="icon" v-bind="$attrs" />',
}
config.global.stubs = {
...(config.global.stubs ?? {}),
Icon: iconStub,
}
describe('MalioInput', () => {
// Props de base: valeur, label, name, id, autocomplete
it('renders the initial input value', () => {
const wrapper = mount(InputForTest, {
props: {modelValue: 'initialValueTest'},
})
expect(wrapper.get('input').element.value).toBe('initialValueTest')
})
it('renders the label text', () => {
const wrapper = mount(InputForTest, {
props: {label: 'labelTest'},
})
expect(wrapper.get('label').text()).toBe('labelTest')
})
it('applies the name attribute', () => {
const wrapper = mount(InputForTest, {
props: {name: 'nameTest'},
})
expect(wrapper.get('input').attributes('name')).toBe('nameTest')
})
it('uses provided id on input and label', () => {
const wrapper = mount(InputForTest, {
props: {id: 'custom-id', label: 'Label'},
})
expect(wrapper.get('input').attributes('id')).toBe('custom-id')
expect(wrapper.get('label').attributes('for')).toBe('custom-id')
})
it('applies a different size of rounded', () => {
const wrapper = mount(InputForTest, {
props: {rounded: 'rounded-md'},
})
expect(wrapper.get('input').classes()).toContain('rounded-md')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mount(InputForTest, {
props: {label: 'Label'},
})
const inputId = wrapper.get('input').attributes('id')
expect(inputId).toBeDefined()
expect(inputId?.startsWith('malio-input-text-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('applies the autocomplete attribute', () => {
const wrapper = mount(InputForTest, {
props: {autocomplete: 'autocompleteTest'},
})
expect(wrapper.get('input').attributes('autocomplete')).toBe('autocompleteTest')
})
// États HTML: required, readonly, disabled
it('does not set required when false', () => {
const wrapper = mount(InputForTest, {
props: {required: false},
})
expect(wrapper.get('input').attributes('required')).toBeUndefined()
})
it('sets required when true', () => {
const wrapper = mount(InputForTest, {
props: {required: true},
})
expect(wrapper.get('input').attributes('required')).toBeDefined()
})
it('does not set readonly when false', () => {
const wrapper = mount(InputForTest, {
props: {readonly: false},
})
expect(wrapper.get('input').attributes('readonly')).toBeUndefined()
})
it('sets readonly when true', () => {
const wrapper = mount(InputForTest, {
props: {readonly: true},
})
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
})
it('does not set disabled and keeps text cursor when false', () => {
const wrapper = mount(InputForTest, {
props: {disabled: false},
})
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
expect(wrapper.get('input').classes()).toContain('cursor-text')
})
it('sets disabled styles when true', () => {
const wrapper = mount(InputForTest, {
props: {disabled: true},
})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
expect(wrapper.get('input').classes()).toContain('text-black/60')
})
// Émission d'événements
it('emits update:modelValue on input change', async () => {
const wrapper = mount(InputForTest, {
props: {modelValue: ''},
})
await wrapper.get('input').setValue('new value')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value'])
})
// Contraintes et classes de texte
it('applies maxLength to input', () => {
const wrapper = mount(InputForTest, {
props: {maxLength: 25},
})
expect(wrapper.get('input').attributes('maxlength')).toBe('25')
})
it('applies minLength to input', () => {
const wrapper = mount(InputForTest, {
props: {minLength: 25},
})
expect(wrapper.get('input').attributes('minlength')).toBe('25')
})
it('applies textSize class on label', () => {
const wrapper = mount(InputForTest, {
props: {label: 'Label', textLabel: 'text-sm'},
})
expect(wrapper.get('label').classes()).toContain('text-sm')
})
// États visuels: erreur et succès
it('shows error message without label and icon', () => {
const wrapper = mount(InputForTest, {
props: {error: 'Error message test'},
})
expect(wrapper.get('p.text-m-error').text()).toBe('Error message test')
expect(wrapper.get('input').classes()).toContain('border-m-error')
expect(wrapper.get('p').classes()).toContain('text-m-error')
})
it('shows error message with label and without icon', () => {
const wrapper = mount(InputForTest, {
props: {error: 'Error message test', label: 'Error message'},
})
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('label').classes()).toContain('text-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', () => {
const wrapper = mount(InputForTest, {
props: {error: 'Error message test', label: 'Error message', iconName: 'mdi:key-outline'},
})
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('label').classes()).toContain('text-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 = mount(InputForTest, {
props: {error: 'Error message test', iconName: 'mdi:key-outline'},
})
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', () => {
const wrapper = mount(InputForTest, {
props: {success: 'Success message test'},
})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
})
it('shows success message with label and without icon', () => {
const wrapper = mount(InputForTest, {
props: {success: 'Success message test', label: 'Success message'},
})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
})
it('shows success message with label and icon', () => {
const wrapper = mount(InputForTest, {
props: {success: 'Success message test', label: 'Success message', iconName: 'mdi:key-outline'},
})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
})
it('shows success message with icon and without label', () => {
const wrapper = mount(InputForTest, {
props: {success: 'Success message test', iconName: 'mdi:key-outline'},
})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
})
it('prioritizes error over success when both are provided', () => {
const wrapper = mount(InputForTest, {
props: {
error: 'Error message test',
success: 'Success 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-error')
expect(wrapper.get('input').classes()).not.toContain('border-m-success')
})
// Aide et classes de label
it('shows hint message', () => {
const wrapper = mount(InputForTest, {
props: {hint: 'Hint message test'},
})
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
})
it('applies labelClass on label', () => {
const wrapper = mount(InputForTest, {
props: {label: 'Label', textLabel: 'text-red-500'},
})
expect(wrapper.get('label').classes()).toContain('text-red-500')
})
it('does not render label when label prop is missing', () => {
const wrapper = mount(InputForTest, {
props: {labelClass: 'text-red-500'},
})
expect(wrapper.find('label').exists()).toBe(false)
})
// Icône : rendu et options
it('renders icon with default positioning and muted color', () => {
const wrapper = mount(InputForTest, {
props: {iconName: 'mdi:key-outline'},
})
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-2')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('top-1/2')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2')
})
it('passes icon size prop to icon component', () => {
const wrapper = mount(InputForTest, {
props: {iconName: 'mdi:key-outline', iconSize: '24'},
})
expect(wrapper.get('[data-test="icon"]').attributes('height')).toBe('24')
})
it('applies icon color class', () => {
const wrapper = mount(InputForTest, {
props: {iconName: 'mdi:key-outline', iconColor: 'text-m-primary'},
})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
})

View File

@@ -1,38 +0,0 @@
<template>
<div class="space-y-1">
<label v-if="label" :for="id" class="text-sm font-medium text-gray-700">{{ label }}</label>
<input
:id="id"
:value="props.modelValue"
:type="props.type"
:placeholder="props.placeholder"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm outline-none transition focus:border-gray-500"
@input="onInput"
>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue?: string
type?: string
label?: string
placeholder?: string
id?: string
}>(), {
modelValue: '',
type: 'text',
label: '',
placeholder: '',
id: undefined,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
function onInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<div
class="relative mt-4 flex h-12 w-full items-center"
:class="[minWidth, maxWidth]"
>
<input
:id="inputId"
v-maska="mask"
:name="name"
:autocomplete="autocomplete"
class="floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none focus:border-2"
:class="[
disabled ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError
? 'border-m-error focus:border-m-error focus:pl-[11px] [&:not(:placeholder-shown)]:border-m-error'
: hasSuccess
? 'border-m-success focus:border-m-success focus:pl-[11px] [&:not(:placeholder-shown)]:border-m-success'
: 'border-m-muted focus:border-m-primary focus:pl-[11px]',
textInput,
iconInputPaddingClass,
rounded,
]"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="modelValue ?? ''"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder=" "
type="text"
@input="onInput"
>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 origin-left transition-transform duration-150 font-medium"
:class="[
disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
textLabel,
]"
>
{{ label }}
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError ? 'text-m-error' : hasSuccess ? 'text-m-success' : '',
'pointer-events-none absolute right-2 top-1/2 -translate-y-1/2',
iconColor,
]"
/>
</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 type {MaskInputOptions} from 'maska'
import {vMaska} from 'maska/vue'
import {computed, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
defineOptions({name: 'MalioInputText', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
minWidth?: string
maxWidth?: string
textInput?: string
textLabel?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
rounded?: string
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
}>(),
{
id: '',
name: '',
autocomplete: '',
modelValue: undefined,
iconName: '',
label: '',
minWidth: 'w-96',
maxWidth: '',
textInput: 'text-lg',
required: false,
maxLength: undefined,
minLength: undefined,
readonly: false,
textLabel: 'text-sm',
disabled: false,
rounded: 'rounded-md',
hint: '',
error: '',
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
mask: undefined,
},
)
const attrs = useAttrs()
const generatedId = useId()
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
if (hasError.value) ids.push(`${inputId.value}-error`)
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
return ids.length ? ids.join(' ') : undefined
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const iconInputPaddingClass = computed(() => {
return props.iconName ? 'pr-10' : ''
})
</script>
<style scoped>
.floating-input:focus + label,
.floating-input:not(:placeholder-shown) + label {
transform: translateY(-1.15rem) scale(0.9);
}
.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>

View File

@@ -0,0 +1,56 @@
<template>
<Story title="Input/Text">
<MalioInputText/>
</Story>
</template>
<docs lang="md">
# Input Text
## Liste des props
Le composant Input Text permet de saisir du texte. Il peut afficher des messages d'erreur, de succès ou d'information.
On peut lui passer plusieurs props pour personnaliser son comportement et son apparence.
- id: Identifiant HTML du champ. Si non fourni, un id est généré automatiquement.
- label: Texte du label affiché au-dessus du champ (floating label).
- name: Attribut name de linput.
- autocomplete: Attribut autocomplete de linput.
- modelValue: Valeur du champ (utilisée avec v-model) ou si valeur brut mettre des " ".
- minWidth: Classe utilitaire pour la largeur minimale du conteneur. Classe Tailwind de largeur (ex: w-64, w-full).
- maxWidth: Classe utilitaire pour la largeur maximale du conteneur. Classe Tailwind de largeur maximale.
- textInput: Classe(s) de style du texte de linput. Classe(s) Tailwind de couleur ou de typographie (ex : text-gray-700, text-sm).
- textLabel: Classe(s) de style du texte du label. Classe(s) Tailwind de couleur ou de typographie (ex : text-gray-700, text-sm).
- required: Rend le champ obligatoire (required).
- maxLength: Nombre de caractère maximal autorisé.
- minLength: Nombre de caractère minimal autorisé.
- disabled: Désactive le champ et applique le style désactivé.
- readonly: Met le champ en lecture seule.
- hint: Message informatif affiché sous le champ.
- error: Message derreur affiché sous le champ. Active le style erreur.
- success: Message de succès affiché sous le champ. Active le style succès.
- iconName: Nom de licône affichée à droite dans le champ.
- rounded: Classe utilitaire pour le rayon des coins ( rounded- ).
- iconSize: Taille de licône. (ex : 24, 26, 85 ,99, ... ).
- iconColor: Classe(s) personnalisée(s) de couleur pour licône ( text- ).
- mask: Masque de saisie pour formater la valeur :
- \# : chiffre
- A : lettre majuscule
- a : lettre minuscule
- \* : chiffre ou lettre
Événement émis :
- update:modelValue: Émis à chaque saisie pour mettre à jour v-model.
Règles daffichage des messages :
Priorité daffichage :
1) error
2) success
3) hint
</docs>
<script setup lang="ts">
import MalioInputText from '../components/malio/InputText.vue'
</script>

31
commit-msg Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
MSG_FILE="${1}"
FIRST_LINE="$(head -n 1 "$MSG_FILE" | tr -d '\r')"
# Autoriser commits auto-générés par git
if [[ "$FIRST_LINE" =~ ^Merge\ ]]; then
exit 0
fi
# Types autorisés (MINUSCULES uniquement)
# Optionnel: scope => feat(auth) : ...
REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._-]+\))?\ :\ .+'
if [[ ! "$FIRST_LINE" =~ $REGEX ]]; then
echo "❌ Message de commit invalide."
echo ""
echo "➡️ Format attendu : <type>(<scope optionnel>) : <message>"
echo "➡️ Types autorisés (minuscules uniquement) :"
echo " build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test"
echo ""
echo "✅ Exemples :"
echo " feat : add login page"
echo " fix(auth) : prevent null token crash"
echo " docs : update README"
echo ""
echo "❌ Exemple refusé :"
echo " Feat : add login page"
exit 1
fi

24
histoire.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'histoire'
import { HstVue } from '@histoire/plugin-vue'
import vue from '@vitejs/plugin-vue'
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'
import path from 'path'
export default defineConfig({
setupFile: './histoire.setup.ts',
vite: {
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
css: {
postcss: {
plugins: [tailwindcss(), autoprefixer()],
},
},
},
plugins: [HstVue()],
})

4
histoire.setup.ts Normal file
View File

@@ -0,0 +1,4 @@
import './app/assets/css/malio.css'
export function setupVue3() {}
export function setupVanilla() {}

33
makefile Normal file
View File

@@ -0,0 +1,33 @@
.PHONY: start install dev dev-prepare lint test pre-commit copy-git-hook node-use
start: copy-git-hook node-use install
install:
npm install
dev:
npm run dev
dev-histoire:
npm run story:dev
dev-prepare:
npm run dev:prepare
lint: dev-prepare
npm run lint
test:
npm run test
pre-commit: lint test
copy-git-hook:
cp pre-commit .git/hooks/pre-commit
cp commit-msg .git/hooks/commit-msg
chmod a+x .git/hooks/pre-commit
chmod a+x .git/hooks/commit-msg
# Force la version node
node-use:
bash -lc 'source "$$HOME/.nvm/nvm.sh" && nvm install && nvm use'

View File

@@ -1,9 +1,34 @@
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const currentDir = dirname(fileURLToPath(import.meta.url))
const dir = dirname(fileURLToPath(import.meta.url))
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
css: [join(currentDir, './app/assets/css/tailwind.css')],
modules: ['@nuxtjs/tailwindcss','@nuxt/icon'],
css: [join(dir, 'app/assets/css/malio.css')],
tailwindcss: {
config: {
theme: {
extend: {
borderRadius: {
malio: 'var(--m-radius)',
},
colors: {
m: {
primary: 'rgb(var(--m-primary) / <alpha-value>)',
secondary: 'rgb(var(--m-secondary) / <alpha-value>)',
tertiary: 'rgb(var(--m-tertiary) / <alpha-value>)',
border: 'rgb(var(--m-border) / <alpha-value>)',
text: 'rgb(var(--m-text) / <alpha-value>)',
muted: 'rgb(var(--m-muted) / <alpha-value>)',
bg: 'rgb(var(--m-bg) / <alpha-value>)',
error: 'rgb(var(--m-error) / <alpha-value>)',
success: 'rgb(var(--m-success) / <alpha-value>)',
}
}
}
}
}
}
})

2839
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,27 +3,43 @@
"type": "module",
"version": "0.0.1",
"main": "./nuxt.config.ts",
"files": ["app/**", "nuxt.config.ts", "README.md"],
"files": [
"app/**",
"nuxt.config.ts",
"README.md"
],
"scripts": {
"dev": "nuxi dev .playground",
"dev:prepare": "nuxt prepare .playground",
"build": "nuxt build .playground",
"generate": "nuxt generate .playground",
"preview": "nuxt preview .playground",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run",
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
},
"peerDependencies": {
"nuxt": "^4.0.0"
},
"devDependencies": {
"@histoire/plugin-vue": "^1.0.0-beta.1",
"@nuxt/eslint": "latest",
"@types/node": "^24.10.13",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^10.0.0",
"histoire": "^1.0.0-beta.1",
"jsdom": "^27.0.1",
"nuxt": "^4.3.1",
"typescript": "^5.9.3",
"vitest": "^3.2.4",
"vue": "latest"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"maska": "^3.2.0"
}
}

28
pre-commit Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/sh
set -e
echo "######### Pre-commit hook start #############"
# Prefer the exact Node version from .nvmrc for hooks (IDE + CLI consistency).
if [ -f ".nvmrc" ]; then
NVM_VERSION="$(tr -d '\r\n' < .nvmrc)"
NVM_VERSION="${NVM_VERSION#v}"
NVM_BIN="$HOME/.nvm/versions/node/v$NVM_VERSION/bin"
if [ -x "$NVM_BIN/node" ] && [ -x "$NVM_BIN/npm" ]; then
PATH="$NVM_BIN:$PATH"
export PATH
fi
fi
if ! command -v npm >/dev/null 2>&1; then
echo "npm introuvable dans le hook. Abandon du commit."
exit 1
fi
echo "Node $(node -v) / npm $(npm -v)"
echo "--- make pre-commit start ---"
make pre-commit
echo "--- make pre-commit finished ---"
echo "All checks passed. Proceeding with commit."
exit 0

38
tailwind.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import type {Config} from 'tailwindcss'
export default {
content: [
'./app/**/*.{vue,js,ts}',
'./app/**/*.story.{vue,js,ts}',
'./histoire.setup.ts',
'./histoire.config.ts',
],
safelist: [
{
pattern: /(sm:|md:|lg:|xl:|2xl:)?(text|rounded|w|min-w|max-w)-.+/,
},
],
theme: {
extend: {
borderRadius: {
malio: 'var(--m-radius)',
},
colors: {
m: {
primary: 'rgb(var(--m-primary) / <alpha-value>)',
secondary: 'rgb(var(--m-secondary) / <alpha-value>)',
tertiary: 'rgb(var(--m-tertiary) / <alpha-value>)',
border: 'rgb(var(--m-border) / <alpha-value>)',
text: 'rgb(var(--m-text) / <alpha-value>)',
muted: 'rgb(var(--m-muted) / <alpha-value>)',
bg: 'rgb(var(--m-bg) / <alpha-value>)',
error: 'rgb(var(--m-error) / <alpha-value>)',
success: 'rgb(var(--m-success) / <alpha-value>)',
},
},
fontFamily: {
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif'],
},
},
},
} satisfies Partial<Config>

View File

@@ -1,3 +1,21 @@
{
"extends": "./.playground/.nuxt/tsconfig.json"
"extends": "./.playground/.nuxt/tsconfig.json",
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"lib": [
"esnext"
],
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"jsx": "preserve"
},
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
]
}

10
vitest.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
include: ['app/**/*.test.ts'],
},
})