Files
wondif_vue/app/components/HorizontalCards.vue
2026-02-06 20:20:01 +01:00

303 lines
8.0 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- app/components/HorizontalCards.vue -->
<!-- Faire défiler des cartes qui se déplacent horizontalement -->
<!-- Les cartes sont dans le composant qui appelle celui-çi, donc cela vaut pour tous types de cartes-->
<template>
<!-- Root: classe is-scrolled pour piloter fade + hint -->
<div
class="hc"
:class="[
rootClass,
{ 'is-scrolled': hasScrolled }
]"
>
<!-- Optional title slot -->
<div v-if="$slots.title" class="hc__header">
<slot name="title" />
</div>
<!-- Hint icon (micro affordance) -->
<div v-if="showHint" class="hc__hint" aria-hidden="true">
<span class="hc__hint-icon">{{ hintIcon }}</span>
</div>
<div v-if="showHint" class="hc__hint--left" aria-hidden="true">
<span class="hc__hint-icon">{{ hintIcon }}</span>
</div>
<!-- Scroller -->
<div
ref="scroller"
class="hc__scroller"
:class="scrollerClass"
tabindex="0"
role="region"
:aria-label="ariaLabel"
@scroll.passive="onScroll"
>
<div class="hc__track" :class="trackClass">
<!-- Cards -->
<slot />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
ariaLabel: { type: String, default: 'Horizontal content' },
/** Active lanimation "nudge" une seule fois (localStorage) */
nudgeOnce: { type: Boolean, default: true },
hintKey: { type: String, default: 'horizontal-cards-hint-seen' },
/** Nudge settings */
nudgePx: { type: Number, default: 48 },
nudgeDelayMs: { type: Number, default: 350 },
nudgeReturnDelayMs: { type: Number, default: 450 },
/** UI affordances */
showHint: { type: Boolean, default: true },
hintIcon: { type: String, default: '⇆' },
/** Peek: % de padding-right du scroller pour montrer la carte suivante */
peek: { type: Number, default: 30 }, // 2540 conseillé
/** Fade (px) : largeur du dégradé */
fadeWidth: { type: Number, default: 64 },
/** Classes hooks */
rootClass: { type: [String, Array, Object], default: '' },
scrollerClass: { type: [String, Array, Object], default: '' },
trackClass: { type: [String, Array, Object], default: '' },
// Reset scroll (ex: changement de filtre)
resetKey: { type: [String, Number], default: null },
resetBehavior: { type: String, default: 'smooth' }, // 'smooth' ou 'auto'
resetDelayMs: { type: Number, default: 0 },
})
const scroller = ref(null)
const hasScrolled = ref(false)
let t = null
const cssVars = computed(() => ({
'--hc-peek': `${props.peek}%`,
'--hc-fade-w': `${props.fadeWidth}px`
}))
const markSeen = () => {
try { localStorage.setItem(props.hintKey, '1') } catch (_) {}
}
const isSeen = () => {
try { return localStorage.getItem(props.hintKey) === '1' } catch (_) { return true }
}
const onScroll = () => {
if (!hasScrolled.value) {
hasScrolled.value = true
markSeen()
}
if (t) clearTimeout(t)
t = setTimeout(() => {}, 80)
}
onMounted(() => {
const el = scroller.value
if (!el) return
// Inject vars on root element
// (Vue naime pas style binding + class binding sur le root via computed uniquement,
// donc on set ici pour être sûr)
el.closest('.hc')?.style?.setProperty('--hc-peek', `${props.peek}%`)
el.closest('.hc')?.style?.setProperty('--hc-fade-w', `${props.fadeWidth}px`)
if (!props.nudgeOnce) return
if (isSeen()) return
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
if (reduce) return
const canScroll = el.scrollWidth > el.clientWidth + 4
if (!canScroll) return
setTimeout(() => {
el.scrollBy({ left: props.nudgePx, behavior: 'smooth' })
setTimeout(() => {
el.scrollBy({ left: -props.nudgePx, behavior: 'smooth' })
markSeen()
}, props.nudgeReturnDelayMs)
}, props.nudgeDelayMs)
})
onBeforeUnmount(() => {
if (t) clearTimeout(t)
})
// Pour revenir à la première carte sur changement de filtre
const resetToStart = async () => {
const el = scroller.value
if (!el) return
await nextTick()
// Important : si tu changes le contenu + transition, un petit délai peut aider
const run = () => el.scrollTo({ left: 0, behavior: props.resetBehavior })
if (props.resetDelayMs > 0) {
setTimeout(run, props.resetDelayMs)
} else {
run()
}
}
watch(
() => props.resetKey,
(nv, ov) => {
// si cest la première fois (ov === null) tu peux choisir de reset ou pas
if (nv === ov) return
resetToStart()
}
)
</script>
<style lang="scss">
/* ==========================================================================
HorizontalCards (no lib)
- scroll-snap
- peek (last card cut)
- fade right
- hint icon
- nudge once (JS minimal)
========================================================================== */
.hc {
position: relative;
/* Vars (fallbacks) */
--hc-peek: 30%;
--hc-fade-w: 64px;
}
/* Title slot */
.hc__header {
margin-bottom: 0.75rem;
}
/* Hint icon */
.hc__hint {
pointer-events: none;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 6px 18px rgba(0,0,0,0.12);
opacity: 1;
transition: opacity 180ms ease, transform 180ms ease;
z-index: 2;
}
.hc__hint--left {
pointer-events: none;
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 6px 18px rgba(0,0,0,0.12);
opacity: 1;
transition: opacity 180ms ease, transform 180ms ease;
z-index: 2;
}
.hc__hint-icon {
font-size: 18px;
line-height: 1;
}
/* Scroller */
.hc__scroller {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
// Cacher la barre de défilement horizontal
/* Firefox */
scrollbar-width: none;
/* IE / Edge legacy */
-ms-overflow-style: none;
/* WebKit (Chrome, Safari, Edge Chromium) */
&::-webkit-scrollbar {
display: none;
}
/* ✅ peek */
padding: 0.25rem var(--hc-peek) 0.25rem 0;
outline: none;
/* Fade right */
&::after {
content: '';
pointer-events: none;
position: absolute;
top: 0;
right: 0;
width: var(--hc-fade-w);
height: 100%;
background: linear-gradient(
to left,
rgb(172 207 207 / 59%),
rgba(172, 207, 207, 0)
);
opacity: 1;
transition: opacity 180ms ease;
z-index: 1;
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 6px;
}
}
/* Track */
.hc__track {
display: flex;
flex-wrap: nowrap;
gap: 20px;
align-items: stretch;
}
/* Snap + “rail-friendly” defaults for children */
.hc__track > * {
flex: 0 0 auto;
scroll-snap-align: start;
}
/* After first scroll: calm UI */
.hc.is-scrolled {
.hc__hint, .hc__hint--left {
background: rgba(172, 207, 207, 0.9);
//opacity: 0;
transform: translateY(-50%) scale(0.96);
}
.hc__scroller::after {
opacity: 0.55;
}
}
</style>