generated from gitea_admin/default
303 lines
8.0 KiB
Vue
303 lines
8.0 KiB
Vue
<!-- 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 l’animation "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 }, // 25–40 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 n’aime 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 c’est 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> |