[#MUI12] Correction composant Nombre et Taux horaire (#12)
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #12 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #12.
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
64
CLAUDE.md
Normal file
64
CLAUDE.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 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)
|
||||||
@@ -26,11 +26,11 @@ const mountInputNumber = (props: InputNumberProps = {}) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('MalioInputNumber', () => {
|
describe('MalioInputNumber', () => {
|
||||||
it('renders the input with a fixed 20px height', () => {
|
it('renders the input with a fixed 22px height', () => {
|
||||||
const wrapper = mountInputNumber()
|
const wrapper = mountInputNumber()
|
||||||
const input = wrapper.get('input')
|
const input = wrapper.get('input')
|
||||||
|
|
||||||
expect(input.classes()).toContain('h-[20px]')
|
expect(input.classes()).toContain('h-[22px]')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the increment and decrement buttons with a fixed 20px height', () => {
|
it('renders the increment and decrement buttons with a fixed 20px height', () => {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'text-xs ml-[2px] ',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -159,7 +159,6 @@ const inputWidthStyle = computed(() => ({
|
|||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
const isMinusDisabled = computed(() =>
|
const isMinusDisabled = computed(() =>
|
||||||
props.disabled || currentNumericValue.value <= minValue.value,
|
props.disabled || currentNumericValue.value <= minValue.value,
|
||||||
)
|
)
|
||||||
@@ -168,15 +167,17 @@ const isPlusDisabled = computed(() =>
|
|||||||
props.disabled || currentNumericValue.value >= maxValue.value,
|
props.disabled || currentNumericValue.value >= maxValue.value,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative mt-4 flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
' peer h-[20px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
' peer h-[22px] 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',
|
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
||||||
@@ -186,9 +187,10 @@ const mergedInputClass = computed(() =>
|
|||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'radio-text mt-px cursor-pointer text-black mr-3',
|
'cursor-pointer text-black mr-4 text-[18px]',
|
||||||
hasError.value ? 'text-m-error' : '',
|
hasError.value ? 'text-m-error' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
props.disabled ? 'cursor-not-allowed text-black/60' : '',
|
props.disabled ? 'cursor-not-allowed text-black/60' : '',
|
||||||
@@ -198,7 +200,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
|
|
||||||
const mergedButtonMinusClass = computed(() =>
|
const mergedButtonMinusClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'h-[20px] w-[30px] border border-black rounded-s-[3px]',
|
'h-[22px] w-[40px] border border-black rounded-s-[3px]',
|
||||||
isMinusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
isMinusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-error'
|
? 'border-m-error'
|
||||||
@@ -207,9 +209,10 @@ const mergedButtonMinusClass = computed(() =>
|
|||||||
: '',
|
: '',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedButtonPlusClass = computed(() =>
|
const mergedButtonPlusClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'h-[20px] w-[30px] border border-black rounded-e-[3px]',
|
'h-[22px] w-[40px] border border-black rounded-e-[3px]',
|
||||||
isPlusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
isPlusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-error'
|
? 'border-m-error'
|
||||||
|
|||||||
@@ -31,9 +31,10 @@
|
|||||||
@blur="onHoursBlur"
|
@blur="onHoursBlur"
|
||||||
>
|
>
|
||||||
|
|
||||||
<span class="text-lg text-black">:</span>
|
<span class="text-[18px] text-black">:</span>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
ref="minutesInputRef"
|
||||||
:id="minutesInputId"
|
:id="minutesInputId"
|
||||||
:name="minutesName"
|
:name="minutesName"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
import {computed, nextTick, ref, useAttrs, useId, watch} from 'vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
defineOptions({name: 'MalioTime', inheritAttrs: false})
|
defineOptions({name: 'MalioTime', inheritAttrs: false})
|
||||||
@@ -117,6 +118,7 @@ const generatedId = useId()
|
|||||||
const hoursValue = ref('')
|
const hoursValue = ref('')
|
||||||
const minutesValue = ref('')
|
const minutesValue = ref('')
|
||||||
const activeField = ref<'hours' | 'minutes' | null>(null)
|
const activeField = ref<'hours' | 'minutes' | null>(null)
|
||||||
|
const minutesInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const inputId = computed(() => props.id?.toString() || `malio-time-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-time-${generatedId}`)
|
||||||
const hoursInputId = computed(() => `${inputId.value}-hours`)
|
const hoursInputId = computed(() => `${inputId.value}-hours`)
|
||||||
@@ -138,13 +140,15 @@ const sanitizeDigits = (value: string) =>
|
|||||||
const normalizeHours = (value: string) => {
|
const normalizeHours = (value: string) => {
|
||||||
const digits = sanitizeDigits(value)
|
const digits = sanitizeDigits(value)
|
||||||
if (digits === '') return ''
|
if (digits === '') return ''
|
||||||
return String(Math.min(Number.parseInt(digits, 10), 23))
|
if (Number.parseInt(digits, 10) > 23) return '23'
|
||||||
|
return digits
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeMinutes = (value: string) => {
|
const normalizeMinutes = (value: string) => {
|
||||||
const digits = sanitizeDigits(value)
|
const digits = sanitizeDigits(value)
|
||||||
if (digits === '') return ''
|
if (digits === '') return ''
|
||||||
return String(Math.min(Number.parseInt(digits, 10), 59))
|
if (Number.parseInt(digits, 10) > 59) return '59'
|
||||||
|
return digits
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseTimeValue = (value: string | null | undefined) => {
|
const parseTimeValue = (value: string | null | undefined) => {
|
||||||
@@ -159,6 +163,7 @@ const parseTimeValue = (value: string | null | undefined) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncFromModelValue = (value: string | null | undefined) => {
|
const syncFromModelValue = (value: string | null | undefined) => {
|
||||||
|
if (activeField.value) return
|
||||||
const parsedValue = parseTimeValue(value)
|
const parsedValue = parseTimeValue(value)
|
||||||
hoursValue.value = parsedValue.hours
|
hoursValue.value = parsedValue.hours
|
||||||
minutesValue.value = parsedValue.minutes
|
minutesValue.value = parsedValue.minutes
|
||||||
@@ -179,7 +184,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
|
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'radio-text mt-px mr-3 cursor-pointer text-black',
|
'mt-px mr-4 cursor-pointer text-black text-[18px]',
|
||||||
hasError.value ? 'text-m-error' : '',
|
hasError.value ? 'text-m-error' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
props.disabled ? 'cursor-not-allowed text-black/60' : '',
|
props.disabled ? 'cursor-not-allowed text-black/60' : '',
|
||||||
@@ -189,7 +194,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
|
|
||||||
const mergedInputClass = (field: 'hours' | 'minutes') =>
|
const mergedInputClass = (field: 'hours' | 'minutes') =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'h-7 w-9 border bg-white text-center text-lg outline-none rounded-md placeholder:text-m-muted',
|
'h-[30px] w-10 border bg-white text-center text-[18px] outline-none rounded-md placeholder:text-m-muted',
|
||||||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'focus:border-2 border-m-error focus:border-m-error'
|
? 'focus:border-2 border-m-error focus:border-m-error'
|
||||||
@@ -201,16 +206,16 @@ const mergedInputClass = (field: 'hours' | 'minutes') =>
|
|||||||
props.inputClass,
|
props.inputClass,
|
||||||
)
|
)
|
||||||
|
|
||||||
const emitCurrentValue = () => {
|
const emitCurrentValue = (pad = false) => {
|
||||||
const formattedHours = hoursValue.value ? padSegment(hoursValue.value) : '00'
|
|
||||||
const formattedMinutes = minutesValue.value ? padSegment(minutesValue.value) : '00'
|
|
||||||
|
|
||||||
if (!hoursValue.value && !minutesValue.value) {
|
if (!hoursValue.value && !minutesValue.value) {
|
||||||
emit('update:modelValue', '')
|
emit('update:modelValue', '')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('update:modelValue', `${formattedHours}:${formattedMinutes}`)
|
const h = pad ? padSegment(hoursValue.value || '0') : (hoursValue.value || '00')
|
||||||
|
const m = pad ? padSegment(minutesValue.value || '0') : (minutesValue.value || '00')
|
||||||
|
|
||||||
|
emit('update:modelValue', `${h}:${m}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onHoursInput = (event: Event) => {
|
const onHoursInput = (event: Event) => {
|
||||||
@@ -220,6 +225,10 @@ const onHoursInput = (event: Event) => {
|
|||||||
hoursValue.value = normalizedValue
|
hoursValue.value = normalizedValue
|
||||||
target.value = normalizedValue
|
target.value = normalizedValue
|
||||||
emitCurrentValue()
|
emitCurrentValue()
|
||||||
|
|
||||||
|
if (normalizedValue.length === 2) {
|
||||||
|
nextTick(() => minutesInputRef.value?.focus())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMinutesInput = (event: Event) => {
|
const onMinutesInput = (event: Event) => {
|
||||||
@@ -240,7 +249,7 @@ const formatFieldOnBlur = (field: 'hours' | 'minutes') => {
|
|||||||
minutesValue.value = padSegment(minutesValue.value)
|
minutesValue.value = padSegment(minutesValue.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitCurrentValue()
|
emitCurrentValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onHoursBlur = () => {
|
const onHoursBlur = () => {
|
||||||
|
|||||||
3
memory/MEMORY.md
Normal file
3
memory/MEMORY.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Memory Index
|
||||||
|
|
||||||
|
- [user_profile.md](user_profile.md) - User context and project role
|
||||||
7
memory/user_profile.md
Normal file
7
memory/user_profile.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: user_profile
|
||||||
|
description: User works on malio-layer-ui component library and consuming Nuxt applications, communicates in French
|
||||||
|
type: user
|
||||||
|
---
|
||||||
|
|
||||||
|
Développeur sur le projet @malio/layer-ui et les applications Nuxt qui le consomment. Communique en français.
|
||||||
@@ -26,7 +26,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif'],
|
sans: ['"Inter"', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user