feat(input) : anneau de focus clavier sur InputText et InputEmail
Anneau outline m-primary affiché uniquement à la navigation clavier (Tab), pas au clic souris. Composable partagé useKbdFocusRing (détection de modalité clavier/souris) + utilitaire CSS .m-focus-ring-kbd. Le visuel existant (grossissement, label flottant, bordure bleue) reste inchangé. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,24 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* ── Globales ── */
|
/* ── Globales ── */
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
type="email"
|
type="email"
|
||||||
inputmode="email"
|
inputmode="email"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -82,9 +82,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
|||||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -164,6 +167,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
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',
|
'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 ? '' : 'grow-height',
|
||||||
isReadonly.value
|
isReadonly.value
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
placeholder="_"
|
placeholder="_"
|
||||||
type="text"
|
type="text"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -69,9 +69,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
|||||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -150,6 +153,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
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',
|
'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 ? '' : 'grow-height',
|
||||||
isReadonly.value
|
isReadonly.value
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détection de la modalité de focus (clavier vs souris/tactile).
|
||||||
|
*
|
||||||
|
* Sur les champs texte, `:focus-visible` natif se déclenche AUSSI au clic souris
|
||||||
|
* (le navigateur suppose qu'on va taper). Pour n'afficher l'anneau de focus qu'à
|
||||||
|
* la navigation clavier (Tab), on suit la dernière interaction au niveau document
|
||||||
|
* et on n'arme l'anneau que si le focus a été précédé d'un évènement clavier.
|
||||||
|
*
|
||||||
|
* Le visuel « champ actif » existant (grossissement, label flottant, bordure bleue)
|
||||||
|
* reste piloté par `:focus` et n'est pas affecté : ce composable ne gère QUE l'anneau.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let hadKeyboardEvent = false
|
||||||
|
let listenersAttached = false
|
||||||
|
|
||||||
|
function ensureGlobalListeners() {
|
||||||
|
if (listenersAttached || typeof document === 'undefined') return
|
||||||
|
listenersAttached = true
|
||||||
|
|
||||||
|
// capture=true pour observer l'évènement avant qu'il n'atteigne sa cible
|
||||||
|
document.addEventListener('keydown', () => {
|
||||||
|
hadKeyboardEvent = true
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
const markPointer = () => {
|
||||||
|
hadKeyboardEvent = false
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', markPointer, true)
|
||||||
|
document.addEventListener('pointerdown', markPointer, true)
|
||||||
|
document.addEventListener('touchstart', markPointer, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKbdFocusRing() {
|
||||||
|
ensureGlobalListeners()
|
||||||
|
|
||||||
|
const keyboardFocused = ref(false)
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
keyboardFocused.value = hadKeyboardEvent
|
||||||
|
}
|
||||||
|
const onBlur = () => {
|
||||||
|
keyboardFocused.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {keyboardFocused, onFocus, onBlur}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user