Files
gw-svelte/src/lib/components/ValuesSection.svelte
T

401 lines
9.8 KiB
Svelte
Raw Normal View History

2026-05-02 08:26:18 +12:00
<script lang="ts">
2026-05-13 09:39:52 +12:00
import { onMount, tick } from 'svelte';
2026-05-02 08:26:18 +12:00
import Icon from '$lib/components/Icon.svelte';
import type { IconCard } from '$lib/types';
export let values: IconCard[];
2026-05-13 09:39:52 +12:00
let valuesScroller: HTMLDivElement | undefined;
2026-05-13 20:44:01 +12:00
let activeIndex = 0;
let mobileCardsObserver: IntersectionObserver | null = null;
2026-05-02 09:43:32 +12:00
$: orderedValues = values
.map((value, index) => ({ value, index }))
.sort((a, b) => {
const aOrder = a.value.order ?? Number.POSITIVE_INFINITY;
const bOrder = b.value.order ?? Number.POSITIVE_INFINITY;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.index - b.index;
})
.map(({ value }) => value);
2026-05-13 09:39:52 +12:00
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth <= 768;
}
2026-05-13 20:44:01 +12:00
function cardScrollLeft(card: HTMLElement) {
return Math.max(0, card.offsetLeft - 8);
}
2026-05-13 09:39:52 +12:00
async function scrollValues(direction: 1 | -1) {
if (!valuesScroller || !isMobileViewport()) {
return;
}
2026-05-13 20:44:01 +12:00
const nextIndex = Math.max(0, Math.min(activeIndex + direction, orderedValues.length - 1));
await scrollToValue(nextIndex, 'smooth');
}
async function scrollToValue(index: number, behavior: ScrollBehavior = 'smooth') {
if (!valuesScroller || !isMobileViewport()) {
2026-05-13 09:39:52 +12:00
return;
}
2026-05-13 20:44:01 +12:00
const cards = valuesScroller.querySelectorAll<HTMLElement>('.value-card');
const targetCard = cards[index];
if (!targetCard) {
return;
}
2026-05-13 09:39:52 +12:00
2026-05-13 20:44:01 +12:00
activeIndex = index;
2026-05-13 09:39:52 +12:00
await tick();
2026-05-13 20:44:01 +12:00
valuesScroller.scrollTo({
left: cardScrollLeft(targetCard),
behavior
});
2026-05-13 09:39:52 +12:00
}
2026-05-13 20:44:01 +12:00
function bindMobileCardObserver() {
mobileCardsObserver?.disconnect();
2026-05-13 09:39:52 +12:00
if (!valuesScroller || !isMobileViewport()) {
return;
}
2026-05-13 20:44:01 +12:00
const cards = valuesScroller.querySelectorAll<HTMLElement>('.value-card');
if (!cards.length) {
return;
}
mobileCardsObserver = new IntersectionObserver(
(entries) => {
const visibleEntry = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (!visibleEntry) {
return;
}
const nextIndex = cards.length ? [...cards].indexOf(visibleEntry.target as HTMLElement) : -1;
if (nextIndex >= 0) {
activeIndex = nextIndex;
}
},
{
root: valuesScroller,
threshold: [0.6, 0.75, 0.9]
}
);
cards.forEach((card) => mobileCardsObserver?.observe(card));
}
onMount(() => {
const handleResize = () => {
if (!valuesScroller) {
return;
}
if (isMobileViewport()) {
if (activeIndex === 0) {
valuesScroller.scrollTo({ left: 0, behavior: 'auto' });
}
bindMobileCardObserver();
void scrollToValue(activeIndex, 'auto');
} else {
mobileCardsObserver?.disconnect();
}
};
if (valuesScroller && isMobileViewport()) {
valuesScroller.scrollTo({ left: 0, behavior: 'auto' });
bindMobileCardObserver();
}
window.addEventListener('resize', handleResize);
return () => {
mobileCardsObserver?.disconnect();
window.removeEventListener('resize', handleResize);
};
2026-05-13 09:39:52 +12:00
});
2026-05-02 08:26:18 +12:00
</script>
<section id="values">
<div class="values-inner">
2026-05-07 21:47:42 +12:00
<span class="values-eyebrow">Why owners stay</span>
<h2 class="section-heading">Calmer dogs. Clearer routines. Less worry.</h2>
<p class="values-intro">
Everything is designed to make life easier for busy Auckland dog owners and safer, happier for the dogs in our care.
</p>
2026-05-02 08:26:18 +12:00
2026-05-13 09:39:52 +12:00
<div class="values-shell">
<div bind:this={valuesScroller} class="values-grid">
2026-05-13 20:44:01 +12:00
{#each orderedValues as value, index}
<div class:active={index === activeIndex} class="value-card">
2026-05-13 09:39:52 +12:00
<div class="value-icon-wrap">
<Icon name={value.icon} className="value-card-icon" />
</div>
<div class="value-text">
<h3>{value.title}</h3>
<p>{value.body}</p>
</div>
</div>
2026-05-13 09:39:52 +12:00
{/each}
</div>
<div class="values-mobile-controls" aria-label="Value cards navigation">
2026-05-13 20:44:01 +12:00
<button
type="button"
class="values-mobile-button"
aria-label="Previous value"
disabled={activeIndex === 0}
on:click={() => scrollValues(-1)}
>
2026-05-13 09:39:52 +12:00
<Icon name="fas fa-chevron-left" />
</button>
2026-05-13 20:44:01 +12:00
<div class="values-mobile-pager" aria-label="Current value">
{#each orderedValues as _, index}
<button
type="button"
class:active={index === activeIndex}
class="values-mobile-dot"
aria-label={`Go to value ${index + 1}`}
aria-pressed={index === activeIndex}
on:click={() => scrollToValue(index)}
/>
{/each}
</div>
<button
type="button"
class="values-mobile-button"
aria-label="Next value"
disabled={activeIndex === orderedValues.length - 1}
on:click={() => scrollValues(1)}
>
2026-05-13 09:39:52 +12:00
<Icon name="fas fa-chevron-right" />
</button>
</div>
2026-05-02 08:26:18 +12:00
</div>
</div>
</section>
2026-05-07 21:47:42 +12:00
<style>
.values-eyebrow {
display: block;
width: fit-content;
margin: 0 auto 10px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--yellow);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.values-intro {
max-width: 760px;
margin: 18px auto 0;
text-align: center;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.65;
}
2026-05-13 09:39:52 +12:00
.values-mobile-controls {
display: none;
}
2026-05-07 21:47:42 +12:00
@media (max-width: 768px) {
2026-05-13 09:39:52 +12:00
.values-shell {
2026-05-13 20:44:01 +12:00
margin-top: 24px;
overflow: hidden;
2026-05-13 09:39:52 +12:00
}
.values-grid {
2026-05-13 20:44:01 +12:00
display: grid;
2026-05-13 09:39:52 +12:00
grid-auto-flow: column;
2026-05-13 20:44:01 +12:00
grid-auto-columns: calc(100% - 64px);
2026-05-13 09:39:52 +12:00
grid-template-columns: none;
2026-05-13 20:44:01 +12:00
align-items: stretch;
gap: 10px;
margin-top: 0;
border-top: none;
2026-05-13 09:39:52 +12:00
overflow-x: auto;
overscroll-behavior-x: contain;
2026-05-13 20:44:01 +12:00
scroll-snap-type: x mandatory;
scroll-padding-left: 8px;
padding: 0 14px 8px 8px;
2026-05-13 09:39:52 +12:00
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
2026-05-13 20:44:01 +12:00
touch-action: pan-x pinch-zoom;
2026-05-13 09:39:52 +12:00
}
.values-grid::-webkit-scrollbar {
display: none;
}
.values-mobile-controls {
display: flex;
2026-05-13 20:44:01 +12:00
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 12px;
2026-05-13 09:39:52 +12:00
}
.values-mobile-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
2026-05-13 20:44:01 +12:00
transition:
background 0.18s ease,
opacity 0.18s ease,
transform 0.18s ease;
}
.values-mobile-button:disabled {
opacity: 0.35;
}
.values-mobile-pager {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.values-mobile-dot {
width: 9px;
height: 9px;
padding: 0;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
transition:
width 0.22s ease,
background 0.22s ease,
transform 0.22s ease;
}
.values-mobile-dot.active {
width: 28px;
background: var(--yellow);
2026-05-13 09:39:52 +12:00
}
.value-card {
2026-05-13 20:44:01 +12:00
display: flex;
min-height: clamp(230px, 42svh, 320px);
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 20px 18px 22px;
border: 1px solid rgba(255, 255, 255, 0.12);
2026-05-13 09:39:52 +12:00
border-radius: 24px;
2026-05-13 20:44:01 +12:00
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.07));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 6px 16px rgba(0, 0, 0, 0.06);
2026-05-13 09:39:52 +12:00
scroll-snap-align: start;
2026-05-13 20:44:01 +12:00
scroll-snap-stop: always;
flex-direction: column;
justify-content: flex-start;
gap: 14px;
transition:
background 0.24s ease,
box-shadow 0.24s ease,
border-color 0.24s ease;
touch-action: pan-x;
user-select: none;
-webkit-user-select: none;
}
.value-card.active {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.09));
border-color: rgba(255, 255, 255, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 10px 22px rgba(0, 0, 0, 0.08);
2026-05-13 09:39:52 +12:00
}
.value-card:nth-child(odd) {
2026-05-13 20:44:01 +12:00
border-right: none;
}
.value-card:last-child {
margin-right: 2px;
}
.value-icon-wrap {
width: 56px;
height: 56px;
border-radius: 18px;
margin-top: 0;
background: rgba(255, 255, 255, 0.1);
}
.value-card .value-card-icon {
font-size: 23px;
}
.value-text {
max-width: 30ch;
min-width: 0;
margin-top: 0;
}
.value-text h3 {
margin-bottom: 8px;
font-size: 21px;
line-height: 1.08;
}
.value-card p {
font-size: 14px;
line-height: 1.55;
opacity: 0.9;
2026-05-13 09:39:52 +12:00
}
2026-05-07 21:47:42 +12:00
.values-eyebrow {
margin-bottom: 8px;
padding: 6px 10px;
font-size: 11px;
}
.values-intro {
margin-top: 14px;
font-size: 15px;
line-height: 1.55;
}
}
2026-05-13 09:39:52 +12:00
@media (hover: hover) {
.values-mobile-button:hover {
background: rgba(255, 255, 255, 0.18);
}
}
.values-mobile-button:active {
transform: scale(0.95);
}
2026-05-07 21:47:42 +12:00
</style>