feat(upload) : anneau focus clavier, activation Entrée/Espace et prop clearable
- Anneau de focus clavier sur le champ (via useKbdFocusRing) - Ouverture du sélecteur de fichier au clavier (Entrée / Espace) - Nouveau prop `clearable` (défaut false) : croix `mdi:close` focusable (Entrée/Espace, anneau clavier) qui vide le champ et émet l'event `clear` - Playground : carte de démonstration "Clearable" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,17 @@
|
|||||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
||||||
</div>
|
</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">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
@@ -94,6 +105,11 @@ import { computed, ref } from 'vue'
|
|||||||
const readonlyFilledUpload = ref('document.pdf')
|
const readonlyFilledUpload = ref('document.pdf')
|
||||||
const uploadValue = ref('')
|
const uploadValue = ref('')
|
||||||
const dynamicUpload = ref('')
|
const dynamicUpload = ref('')
|
||||||
|
const clearableUpload = ref('rapport-2026.pdf')
|
||||||
|
|
||||||
|
const onClearUpload = () => {
|
||||||
|
clearableUpload.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicError = computed(() => {
|
const dynamicError = computed(() => {
|
||||||
if (!dynamicUpload.value) return ''
|
if (!dynamicUpload.value) return ''
|
||||||
|
|||||||
@@ -26,8 +26,10 @@
|
|||||||
placeholder="_"
|
placeholder="_"
|
||||||
type="text"
|
type="text"
|
||||||
@click="openFilePicker"
|
@click="openFilePicker"
|
||||||
@focus="isFocused = true"
|
@keydown.enter.prevent="openFilePicker"
|
||||||
@blur="isFocused = false"
|
@keydown.space.prevent="openFilePicker"
|
||||||
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -38,17 +40,33 @@
|
|||||||
{{ label }}<MalioRequiredMark v-if="required" />
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<div
|
||||||
v-if="displayIcon"
|
v-if="displayIcon || showClear"
|
||||||
icon="mdi:cloud-arrow-up-outline"
|
class="absolute right-[10px] top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||||
:width="24"
|
>
|
||||||
:height="24"
|
<button
|
||||||
data-test="icon"
|
v-if="showClear"
|
||||||
:class="[
|
type="button"
|
||||||
iconStateClass,
|
data-test="clear"
|
||||||
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||||
]"
|
aria-label="Retirer le fichier"
|
||||||
/>
|
@click.stop="onClear"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="mdi:close"
|
||||||
|
:width="16"
|
||||||
|
:height="16"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="displayIcon"
|
||||||
|
icon="mdi:cloud-arrow-up-outline"
|
||||||
|
:width="24"
|
||||||
|
:height="24"
|
||||||
|
data-test="icon"
|
||||||
|
:class="[iconStateClass, 'pointer-events-none']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -75,9 +93,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: 'MalioInputUpload', inheritAttrs: false})
|
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -94,6 +115,7 @@ const props = withDefaults(
|
|||||||
displayIcon?: boolean
|
displayIcon?: boolean
|
||||||
accept?: string
|
accept?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
|
clearable?: boolean
|
||||||
reserveMessageSpace?: boolean
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -111,6 +133,7 @@ const props = withDefaults(
|
|||||||
displayIcon: true,
|
displayIcon: true,
|
||||||
accept: '',
|
accept: '',
|
||||||
required: false,
|
required: false,
|
||||||
|
clearable: false,
|
||||||
reserveMessageSpace: true,
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -143,6 +166,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 cursor-pointer',
|
'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 cursor-pointer',
|
||||||
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
isReadonly.value ? '' : 'grow-height',
|
isReadonly.value ? '' : 'grow-height',
|
||||||
isReadonly.value
|
isReadonly.value
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
@@ -153,7 +177,9 @@ const mergedInputClass = computed(() =>
|
|||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
props.displayIcon ? '!pr-10' : '',
|
showClear.value
|
||||||
|
? (props.displayIcon ? '!pr-16' : '!pr-10')
|
||||||
|
: (props.displayIcon ? '!pr-10' : ''),
|
||||||
isReadonly.value ? '' : 'focus:pl-[11px]',
|
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||||
isReadonly.value ? 'cursor-default' : '',
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
disabled.value ? 'cursor-not-allowed' : '',
|
disabled.value ? 'cursor-not-allowed' : '',
|
||||||
@@ -191,8 +217,21 @@ const describedBy = computed(() => {
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update:modelValue', value: string): void
|
(event: 'update:modelValue', value: string): void
|
||||||
(event: 'file-selected', file: File): void
|
(event: 'file-selected', file: File): void
|
||||||
|
(event: 'clear'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showClear = computed(() =>
|
||||||
|
props.clearable && isFilled.value && !props.disabled && !isReadonly.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
if (props.disabled || isReadonly.value) return
|
||||||
|
if (!isControlled.value) localValue.value = ''
|
||||||
|
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
emit('clear')
|
||||||
|
}
|
||||||
|
|
||||||
const openFilePicker = () => {
|
const openFilePicker = () => {
|
||||||
if (props.disabled || props.readonly) return
|
if (props.disabled || props.readonly) return
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
|
|||||||
Reference in New Issue
Block a user