generated from gitea_admin/default
finalisation home page
This commit is contained in:
303
app/components/HorizontalCards.vue
Normal file
303
app/components/HorizontalCards.vue
Normal 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 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>
|
||||
Reference in New Issue
Block a user