finalisation home page

This commit is contained in:
2026-02-06 20:20:01 +01:00
parent 91c1b03a2f
commit d9ac2b4cc5
45 changed files with 1892 additions and 148 deletions

View File

@@ -0,0 +1,303 @@
<!-- 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>