draggable / touchable card v0.1
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 18s

This commit is contained in:
valere
2025-12-24 06:00:15 +01:00
parent 1f4f7868ca
commit ad938abf79
11 changed files with 414 additions and 201 deletions

View File

@@ -6,9 +6,9 @@
<template v-if="box.type === 'compilation'">
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" :key="`${box.id}-${box.activeSide}`"
@click.stop="" />
@click.stop />
</template>
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop="" />
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop />
</template>
</box>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="bucket" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
<div class="bucket" ref="bucket" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
<div v-if="tracks.length === 0" class="bucket-empty">
Drop cards here
@@ -14,10 +14,14 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, defineEmits, onMounted } from 'vue'
import draggable from 'vuedraggable'
import { useDataStore } from '~/store/data'
const emit = defineEmits<{
(e: 'card-dropped', track: any): void
}>()
const props = defineProps<{
modelValue?: any[]
boxId?: string
@@ -27,6 +31,7 @@ const dataStore = useDataStore()
const isDragOver = ref(false)
const drag = ref(false)
const tracks = ref<any[]>(props.modelValue || [])
const bucket = ref()
watch(() => props.modelValue, (newValue) => {
if (newValue) {
@@ -37,7 +42,9 @@ watch(() => props.modelValue, (newValue) => {
if (props.boxId) {
onMounted(async () => {
await dataStore.loadData()
tracks.value = dataStore.getTracksByboxId(props.boxId)
if (props.boxId) {
tracks.value = dataStore.getTracksByboxId(props.boxId)
}
})
}
@@ -83,6 +90,13 @@ const onDrop = (e: DragEvent) => {
const flipCard = (track: any) => {
track.isFaceUp = !track.isFaceUp
}
onMounted(() => {
// Écouter aussi les événements tactiles personnalisés
bucket.value?.addEventListener('card-dropped-touch', (e: CustomEvent) => {
emit('card-dropped', e.detail)
})
})
</script>
<style scoped>

View File

@@ -1,14 +1,16 @@
<template>
<article class="card w-56 h-80 min-w-56 min-h-80" :class="[
isFaceUp ? 'face-up' : 'face-down',
{ 'current-track': playerStore.currentTrack?.id === track.id }
]">
<div class="flip-inner">
<article role="button" @click.stop="cardClick" @keydown.enter.stop="cardClick" @keydown.space.prevent.stop="cardClick"
@touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" class="card" :class="[
isFaceUp ? 'face-up' : 'face-down',
{ 'current-track': playerStore.currentTrack?.id === track.id },
{ 'is-dragging': isDragging }
]">
<div class="flip-inner" ref="cardElement">
<!-- Face-Up -->
<main draggable="true" @dragstart="dragStart" @dragend="dragEnd"
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
@@ -25,38 +27,31 @@
</div>
<!-- Cover -->
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer"
@click="playerStore.playTrack(track)">
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
<playButton :objectToPlay="track" />
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
class="w-full h-full object-cover object-center" />
</figure>
<!-- Body -->
<div
class="p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
<div v-if="isOrder" class="label">
{{ props.track.order }}
</div>
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
{{ props.track.title }}
{{ props.track.title || 'title' }}
</h2>
<p v-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ props.track.artist.name }}
{{ props.track.artist.name || 'artist' }}
</p>
<!-- <p class="select-text">
{{ props.track.url.split('/')[4]?.split('__')[0] }}
</p> -->
</div>
</main>
<!-- Face-Down -->
<footer
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden">
<figure @click.stop="playerStore.playTrack(track)"
class="h-full flex text-center rounded-xl justify-center cursor-pointer"
<figure class="h-full flex text-center rounded-xl justify-center cursor-pointer"
:style="{ backgroundColor: cardColor }">
<playButton :objectToPlay="track" />
<img draggable="false" src="/face-down.svg" />
@@ -64,6 +59,36 @@
</footer>
</div>
</article>
<!-- Clone fantôme pour le drag tactile -->
<Teleport to="body">
<div v-if="isDragging && touchClone" ref="ghostElement"
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
left: touchClone.x + 'px',
top: touchClone.y + 'px',
transform: 'translate(-50%, -50%)'
}">
<div class="flip-inner">
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
class="w-full h-full object-cover object-center" />
</figure>
</main>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -91,17 +116,166 @@ const dataStore = useDataStore()
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
const isDragging = ref(false)
const cardElement = ref<HTMLElement | null>(null)
const ghostElement = ref<HTMLElement | null>(null)
// État pour le tactile
const touchClone = ref<{ x: number, y: number } | null>(null)
const touchStartPos = ref<{ x: number, y: number } | null>(null)
const longPressTimer = ref<number | null>(null)
const LONG_PRESS_DURATION = 200 // ms
const hasMovedDuringPress = ref(false)
const cardClick = () => {
if (!isDragging.value && !hasMovedDuringPress.value) {
console.log('card click')
playerStore.playTrack(props.track)
}
hasMovedDuringPress.value = false
}
// Drag desktop
const dragStart = (event: DragEvent) => {
if (event.dataTransfer) {
if (event.dataTransfer && cardElement.value) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
isDragging.value = true
}
};
const instance = getCurrentInstance();
const dragEnd = (event: DragEvent) => {
console.log('drag end');
isDragging.value = false
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
const parent = instance.parent;
if (parent?.update) {
parent.update();
}
}
}
// Touch events
const touchStart = (event: TouchEvent) => {
const touch = event.touches[0]
touchStartPos.value = { x: touch.clientX, y: touch.clientY }
hasMovedDuringPress.value = false
// Démarrer un timer pour le long press
longPressTimer.value = window.setTimeout(() => {
startTouchDrag(touch)
}, LONG_PRESS_DURATION)
}
const startTouchDrag = (touch: Touch) => {
isDragging.value = true
touchClone.value = {
x: touch.clientX,
y: touch.clientY
}
// Vibration feedback si disponible
if (navigator.vibrate) {
navigator.vibrate(50)
}
}
const touchMove = (event: TouchEvent) => {
if (longPressTimer.value) {
// Annuler le long press si l'utilisateur bouge trop
const touch = event.touches[0]
const dx = touch.clientX - (touchStartPos.value?.x || 0)
const dy = touch.clientY - (touchStartPos.value?.y || 0)
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > 10) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
hasMovedDuringPress.value = true
}
}
if (isDragging.value && touchClone.value) {
event.preventDefault()
const touch = event.touches[0]
touchClone.value = {
x: touch.clientX,
y: touch.clientY
}
// Déterminer l'élément sous le doigt
checkDropTarget(touch.clientX, touch.clientY)
}
}
const touchEnd = (event: TouchEvent) => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (isDragging.value) {
event.preventDefault()
const touch = event.changedTouches[0]
// Trouver l'élément de drop
const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY)
const bucket = dropTarget?.closest('.bucket')
// Nettoyer les classes de drop target
document.querySelectorAll('.bucket').forEach(b => {
b.classList.remove('drop-target-active')
})
if (bucket) {
// Émettre un événement personnalisé pour le bucket
const dropEvent = new CustomEvent('card-dropped-touch', {
detail: props.track,
bubbles: true
})
bucket.dispatchEvent(dropEvent)
// Vibration de confirmation
if (navigator.vibrate) {
navigator.vibrate(100)
}
// Supprimer la carte
if (instance?.vnode?.el?.parentNode) {
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
const parent = instance.parent;
if (parent?.update) {
parent.update();
}
}
}
isDragging.value = false
touchClone.value = null
}
}
const checkDropTarget = (x: number, y: number) => {
const element = document.elementFromPoint(x, y)
const bucket = element?.closest('.bucket')
if (bucket) {
bucket.classList.add('drop-target-active')
} else {
document.querySelectorAll('.bucket').forEach(b => {
b.classList.remove('drop-target-active')
})
}
}
// Cleanup
onUnmounted(() => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
}
})
</script>
<style lang="scss">
@@ -121,7 +295,8 @@ const dragEnd = (event: DragEvent) => {
/* Flip effect */
.card {
perspective: 1000px;
@apply transition-all scale-100;
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
touch-action: none;
.flip-inner {
position: relative;
@@ -171,7 +346,7 @@ const dragEnd = (event: DragEvent) => {
&.current-track,
&:focus {
@apply z-50;
@apply z-50 scale-110;
.face-up {
@apply shadow-2xl;
@@ -180,7 +355,9 @@ const dragEnd = (event: DragEvent) => {
transform 0.6s;
}
@apply scale-110;
.play-button {
@apply opacity-100;
}
}
.play-button {
@@ -207,5 +384,37 @@ const dragEnd = (event: DragEvent) => {
@apply scale-90;
}
}
&.is-dragging {
@apply opacity-50 scale-95 rotate-6;
cursor: grabbing !important;
.face-up {
@apply shadow-2xl;
}
.play-button,
.card-body {
display: none;
}
}
}
/* Ghost card styles */
.ghost-card {
transition: none;
.card {
@apply shadow-2xl scale-95 rotate-6;
.play-button,
.card-body {
display: none;
}
}
.flip-inner {
perspective: 1000px;
}
}
</style>

View File

@@ -1,7 +1,8 @@
<template>
<div class="platine" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
<div class="disc" ref="discRef" :style="'background-image: url(' + coverUrl + ')'" id="disc">
<card v-if="currentTrack" :track="currentTrack" />
<div class="disc fixed" ref="discRef" :style="'background-image: url(/card-dock.svg)'" id="disc">
<div
class="bobine bg-slate-800 bg-opacity-50 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full"
:style="{ height: progressPercentage + '%', width: progressPercentage + '%' }">
@@ -37,12 +38,13 @@ const isDragOver = ref(false)
const isFirstDrag = ref(true)
const isLoadingTrack = ref(false)
const isPlaying = ref(false)
const coverUrl = computed(() => props.track?.coverId || '/card-dock.svg')
const coverUrl = computed(() => currentTrack.value?.coverId || '/card-dock.svg')
let disc: Disc | null = null
let sampler: Sampler | null = null
const currentTrack = ref()
const updateTurns = (disc: Disc) => {
currentTurns.value = disc.secondsPlayed * 0.75 // 0.75 tours par seconde (RPS)
totalTurns.value = (disc as any)._duration * 0.75 // Accès à la propriété _duration privée
@@ -50,7 +52,7 @@ const updateTurns = (disc: Disc) => {
};
const startPlayback = () => {
if (!disc || !sampler || !props.track || isPlaying.value) return
if (!disc || !sampler || !currentTrack.value || isPlaying.value) return
isPlaying.value = true
sampler.play(disc.secondsPlayed)
@@ -58,7 +60,7 @@ const startPlayback = () => {
}
const togglePlay = () => {
if (!disc || !sampler || !props.track) return
if (!disc || !sampler || !currentTrack.value) return
isPlaying.value = !isPlaying.value
@@ -75,27 +77,19 @@ const toggleMute = () => {
sampler.mute()
}
const handleRewind = () => {
if (!disc || !sampler || !props.track) return
sampler.pause()
disc.rewind()
if (isPlaying.value) {
sampler.play(0)
}
};
const loadTrack = async (track: Track) => {
if (!sampler || !track) return
currentTrack.value = track
isLoadingTrack.value = true
try {
await sampler.loadTrack(track.url)
await sampler.loadTrack(currentTrack.value.url)
if (disc) {
disc.setDuration(sampler.duration)
updateTurns(disc)
sampler.play(0)
disc.secondsPlayed = 0
disc.powerOn()
disc.powerOff()
}
} finally {
isLoadingTrack.value = false
@@ -134,6 +128,7 @@ const onDrop = (e: DragEvent) => {
const newTrack = JSON.parse(cardData)
if (newTrack && newTrack.url) {
loadTrack(newTrack)
disc?.powerOn()
}
} catch (error) {
console.error('Erreur lors du traitement de la carte déposée', error)
@@ -175,9 +170,9 @@ onMounted(async () => {
})
watch(() => props.track, (newTrack) => {
if (newTrack) {
loadTrack(newTrack)
watch(() => props.track, (propTrack) => {
if (propTrack) {
loadTrack(propTrack)
}
})
@@ -199,6 +194,14 @@ onUnmounted(() => {
width: 100%;
height: 100%;
padding: 20px;
.card {
position: absolute !important;
z-index: 99;
left: 50%;
bottom: 0;
transform: translate(-50%, 50%);
}
}
.disc {

View File

@@ -6,9 +6,6 @@
</button>
</div>
<div class="controls flex justify-center z-50 relative">
<button class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors" @click="toggleAllCards">
{{ allCardsRevealed ? 'Hide All' : 'Reveal All' }}
</button>
<SearchInput @search="onSearch" />
<SelectCardRank @change="onRankChange" />
<SelectCardSuit @change="onSuitChange" />
@@ -17,10 +14,6 @@
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" :tabindex="i"
:is-face-up="isCardRevealed(track.id)" />
</div>
<div class="dock">
<Bucket @card-dropped="onCardDropped" />
<Platine />
</div>
</template>
<script setup lang="ts">
@@ -52,20 +45,10 @@ const selectedSuit = ref('')
const selectedRank = ref('')
const searchQuery = ref('')
const isCardRevealed = (trackId: number) => cardStore.isCardRevealed(trackId)
// Vérifie si toutes les cartes sont révélées
const allCardsRevealed = computed(() => {
return tracks.value.every(track => cardStore.isCardRevealed(track.id))
})
// Fonction pour basculer l'état de toutes les cartes
const toggleAllCards = () => {
if (allCardsRevealed.value) {
cardStore.hideAllCards(tracks.value)
} else {
cardStore.revealAllCards(tracks.value)
}
const isCardRevealed = (trackId: number) => {
// Si une recherche est en cours, révéler automatiquement les cartes correspondantes
if (searchQuery.value) return true
return cardStore.isCardRevealed(trackId)
}
const closeDatBox = () => {
@@ -77,11 +60,6 @@ const onSuitChange = (suit: string) => {
applyFilters()
}
const onCardDropped = (card: Track) => {
console.log('Carte déposée dans le bucket:', card)
// Vous pouvez ajouter ici la logique pour supprimer la carte de la liste actuelle si nécessaire
}
const onRankChange = (rank: string) => {
selectedRank.value = rank
applyFilters()
@@ -124,3 +102,14 @@ const applyFilters = () => {
filteredTracks.value = result
}
</script>
<style>
.deck {
position: relative;
}
.docked {
position: fixed;
bottom: 0;
}
</style>

View File

@@ -1,117 +0,0 @@
<template>
<div class="deck">
<draggable v-model="tracks" item-key="id" class="draggable-container" @start="drag = true" @end="onDragEnd">
<template #item="{ element: track }">
<card :key="track.id" :track="track" tabindex="0" is-face-up class="draggable-item" @click="flipCard(track)" />
</template>
</draggable>
</div>
</template>
<script setup>
import { useDataStore } from '~/store/data'
import draggable from 'vuedraggable'
const drag = ref(false)
const tracks = ref([])
// Configuration du layout
definePageMeta({
layout: 'default'
})
onMounted(async () => {
const dataStore = useDataStore()
await dataStore.loadData()
tracks.value = dataStore.getTracksByboxId('ESPLAYLIST')
})
function flipCard(track) {
track.isFaceUp = !track.isFaceUp
}
function onDragEnd() {
drag.value = false
// Ici vous pouvez ajouter une logique supplémentaire après le drop si nécessaire
}
</script>
<style lang="scss" scoped>
.logo {
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
}
.deck {
position: relative;
height: 80vh;
.draggable-container {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
min-height: 100%;
}
.draggable-item {
cursor: grab;
transition: transform 0.2s;
&:active {
cursor: grabbing;
}
&.sortable-ghost {
opacity: 0.5;
background: #c8ebfb;
border-radius: 1rem;
}
&.sortable-drag {
transform: rotate(2deg);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
}
.card {
z-index: 10;
position: relative;
}
/* noise tools */
$size: 130px;
$scale: 1.05;
$border-radius: calc($size / 2);
$grad-position: 100% 0;
$grad-start: 25%;
$grad-stop: 65%;
$duration: 3.5s;
$noise: url('data:image/png;base64,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');
@mixin dithered-gradient($position, $start, $stop, $color) {
background: radial-gradient(circle at $position, transparent $start, $color $stop);
mask: $noise, radial-gradient(circle at $position, transparent $start, #000 ($stop + 10%));
}
&::before,
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
z-index: 0;
display: block;
width: 100%;
height: 100%;
}
&::before {
@include dithered-gradient(50%, 30%, 60%, #6cc8ff);
}
&::after {
mask-image:
$noise, linear-gradient(45deg, #000 0%, transparent 25%, transparent 75%, #000 100%);
background: linear-gradient(45deg, #6d6dff 10%, transparent 25%, transparent 75%, #6af789 90%);
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div draggable="true" @dragstart="onDragStart" @dragend="onDragEnd"
:class="['draggable', { 'is-dragging': isDragging }]">
<slot :is-dragging="isDragging" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
data: any
type?: string
}>()
const emit = defineEmits(['dragStart', 'dragEnd'])
const isDragging = ref(false)
const onDragStart = (e: DragEvent) => {
isDragging.value = true
e.dataTransfer?.setData('application/json', JSON.stringify(props.data))
emit('dragStart', props.data)
}
const onDragEnd = () => {
isDragging.value = false
emit('dragEnd')
}
</script>
<style scoped>
.draggable {
cursor: grab;
user-select: none;
}
.draggable.is-dragging {
opacity: 0.5;
cursor: grabbing;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div @dragover.prevent="onDragOver" @dragenter.prevent="onDragEnter" @dragleave="onDragLeave" @drop.prevent="onDrop"
:class="['droppable', { 'is-drag-over': isDragOver }]">
<slot :is-dragging-over="isDragOver" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
accept?: string
onDrop: (data: any) => void
}>()
const isDragOver = ref(false)
const onDragOver = (e: DragEvent) => {
if (!isDragOver.value) isDragOver.value = true
}
const onDragEnter = (e: DragEvent) => {
isDragOver.value = true
}
const onDragLeave = () => {
isDragOver.value = false
}
const onDrop = (e: DragEvent) => {
isDragOver.value = false
const data = e.dataTransfer?.getData('application/json')
if (data) {
try {
props.onDrop(JSON.parse(data))
} catch (e) {
console.error('Erreur lors du drop', e)
}
}
}
</script>
<style scoped>
.droppable {
min-height: 100px;
transition: all 0.2s ease;
}
.droppable.is-drag-over {
background-color: rgba(0, 0, 0, 0.1);
border: 2px dashed #4CAF50;
}
</style>

View File

@@ -1,3 +1,32 @@
<template>
<slot />
<Bucket @card-dropped="onCardDropped" />
<Platine />
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
const onCardDropped = (card: Track) => {
console.log('Carte déposée dans le bucket:', card)
}
</script>
<style scoped>
.bucket,
.platine {
position: fixed;
bottom: 0;
right: 0;
}
.bucket {
z-index: 70;
bottom: 0;
}
.platine {
z-index: 60;
bottom: -70%;
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<card />
<platine />
<card />
</template>

View File

@@ -83,15 +83,6 @@ export default defineNuxtPlugin((nuxtApp) => {
ui.closeBox()
break
// Gestion de la touche Entrée pour ouvrir une boîte
case 'Enter':
if (document.activeElement?.id) {
e.preventDefault()
ui.selectBox(document.activeElement.id)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
break
// Gestion des touches fléchées (à implémenter si nécessaire)
case 'ArrowUp':
case 'ArrowDown':