Design language

This commit is contained in:
2026-05-13 09:39:52 +12:00
parent 6c943b14bd
commit de8b60b9c3
11 changed files with 329 additions and 236 deletions
+17 -43
View File
@@ -53,6 +53,23 @@
<Icon name="fab fa-google" />
</a>
</div>
{#if footer.email || footer.phone}
<div class="footer-contact">
{#if footer.email}
<a href="mailto:{footer.email}" class="footer-contact-link">
<Icon name="fas fa-envelope" />
{footer.email}
</a>
{/if}
{#if footer.phone}
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
<Icon name="fas fa-phone" />
{footer.phone}
</a>
{/if}
</div>
{/if}
</div>
<div class="footer-explore">
@@ -80,49 +97,6 @@
{/each}
</ul>
</div>
<div class="footer-action footer-panel footer-panel-accent">
<p class="footer-col-label">Get Started</p>
<h3 class="footer-action-title">Ready when you are</h3>
<p class="footer-action-copy">Questions, pricing, or your first Meet &amp; Greet. Start here and well reply within 24 hours.</p>
<a href="/contact-us" class="footer-book-btn">
Contact Us
<Icon name="fas fa-arrow-right" />
</a>
<p class="footer-book-note">Friendly, no-pressure first step</p>
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="footer-reviews"
>
<img
class="footer-google-logo"
src="/images/google-g-logo.svg"
alt=""
width="16"
height="17"
/>
<span>30+ five-star Google reviews</span>
</a>
{#if footer.email || footer.phone}
<div class="footer-contact">
{#if footer.email}
<a href="mailto:{footer.email}" class="footer-contact-link">
<Icon name="fas fa-envelope" />
{footer.email}
</a>
{/if}
{#if footer.phone}
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
<Icon name="fas fa-phone" />
{footer.phone}
</a>
{/if}
</div>
{/if}
</div>
</div>
<div class="footer-bottom">
+23 -3
View File
@@ -13,6 +13,8 @@
export let navigation: NavigationContent;
let mobileMenuOpen = false;
let headerElement: HTMLElement;
let mobileMenuTop = 0;
const mobilePhoneDisplay = '(022) 642 1011';
const mobilePhoneHref = '+64226421011';
@@ -32,6 +34,11 @@
mobileMenuOpen = !mobileMenuOpen;
}
function updateMobileMenuTop() {
if (!headerElement) return;
mobileMenuTop = Math.max(headerElement.getBoundingClientRect().bottom, 0);
}
function mobileLinkIcon(href: string) {
if (href === '/') return 'fas fa-house';
if (href === '/pack-walks') return 'fas fa-paw';
@@ -91,11 +98,17 @@
}
function handleViewportChange() {
updateMobileMenuTop();
if (window.innerWidth > 768) {
mobileMenuOpen = false;
}
}
$: if (mobileMenuOpen && typeof window !== 'undefined') {
updateMobileMenuTop();
}
$: if (typeof document !== 'undefined') {
document.body.classList.toggle('mobile-menu-open', mobileMenuOpen);
}
@@ -103,17 +116,19 @@
onMount(() => {
handleViewportChange();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', updateMobileMenuTop, { passive: true });
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', updateMobileMenuTop);
window.removeEventListener('keydown', handleKeydown);
document.body.classList.remove('mobile-menu-open');
};
});
</script>
<header>
<header bind:this={headerElement}>
<nav>
<ul class="nav-links">
{#each navigation.desktopLinks as link, i}
@@ -231,8 +246,13 @@
</div>
{/if}
<div class:open={mobileMenuOpen} class="mobile-menu-shell">
<div class="mobile-menu" id="mobile-menu">
<div
class:open={mobileMenuOpen}
class="mobile-menu-shell"
style={`--mobile-menu-top: ${mobileMenuTop}px;`}
on:click={closeMenu}
>
<div class="mobile-menu" id="mobile-menu" on:click|stopPropagation>
<div class="mobile-menu-links">
{#each navigation.mobileLinks as link}
<a
+26 -2
View File
@@ -28,16 +28,40 @@ describe('Header', () => {
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
const mobileMenu = container.querySelector('.mobile-menu') as HTMLDivElement;
const mobileMenuShell = container.querySelector('.mobile-menu-shell') as HTMLDivElement;
const firstMobileLink = mobileMenu.querySelector('a') as HTMLAnchorElement;
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
await fireEvent.click(menuToggle);
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
expect(mobileMenu.classList.contains('open')).toBe(true);
expect(mobileMenuShell.classList.contains('open')).toBe(true);
await fireEvent.click(firstMobileLink);
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
expect(mobileMenu.classList.contains('open')).toBe(false);
expect(mobileMenuShell.classList.contains('open')).toBe(false);
});
it('closes the mobile menu when tapping outside the menu panel', async () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 390
});
const { container } = render(Header, {
navigation: homepageContent.navigation
});
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
const mobileMenuShell = container.querySelector('.mobile-menu-shell') as HTMLDivElement;
await fireEvent.click(menuToggle);
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
expect(mobileMenuShell.classList.contains('open')).toBe(true);
await fireEvent.click(mobileMenuShell);
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
expect(mobileMenuShell.classList.contains('open')).toBe(false);
});
});
+3 -17
View File
@@ -152,8 +152,6 @@
{/if}
</div>
<p class="service-benefit-mobile-hint">Swipe through the reasons Tiny Gang works so well.</p>
<div class="service-benefit-shell">
<div bind:this={benefitScroller} class="service-benefit-grid">
{#each benefitCards as benefit}
@@ -566,13 +564,9 @@
overflow: visible;
}
.service-benefit-mobile-hint {
display: none;
}
.service-benefit-mobile-controls {
display: none;
}
.service-benefit-mobile-controls {
display: none;
}
.service-section-heading {
text-align: center;
@@ -1133,14 +1127,6 @@
line-height: 1.68;
}
.service-benefit-mobile-hint {
display: block;
margin: -10px 0 16px;
color: var(--gray);
font-size: 13px;
line-height: 1.5;
}
.service-benefit-mobile-controls {
display: flex;
justify-content: flex-end;
+68 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
@@ -52,6 +52,7 @@
let inView = false;
let prefersReducedMotion = false;
let carouselEl: HTMLDivElement | undefined;
let stageEl: HTMLDivElement | undefined;
let slideSignature = '';
$: slides = testimonials
@@ -89,6 +90,7 @@
}
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
syncMobileStage();
}
function showNext() {
@@ -97,6 +99,34 @@
}
activeIndex = (activeIndex + 1) % slides.length;
syncMobileStage();
}
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth <= 767;
}
async function syncMobileStage(behavior: ScrollBehavior = 'smooth') {
if (!stageEl || !isMobileViewport()) {
return;
}
await tick();
stageEl.scrollTo({
left: stageEl.clientWidth * activeIndex,
behavior
});
}
function handleStageScroll() {
if (!stageEl || !isMobileViewport()) {
return;
}
const nextIndex = Math.round(stageEl.scrollLeft / Math.max(stageEl.clientWidth, 1));
if (nextIndex !== activeIndex) {
activeIndex = nextIndex;
}
}
onMount(() => {
@@ -120,6 +150,13 @@
observer.observe(carouselEl);
}
const handleResize = () => {
syncMobileStage('auto');
};
window.addEventListener('resize', handleResize);
syncMobileStage('auto');
const interval = window.setInterval(() => {
if (!paused && !prefersReducedMotion && inView && slides.length > 1) {
showNext();
@@ -128,6 +165,7 @@
return () => {
window.clearInterval(interval);
window.removeEventListener('resize', handleResize);
motionQuery.removeEventListener('change', onMotionChange);
observer?.disconnect();
};
@@ -162,7 +200,7 @@
<Icon name="fas fa-chevron-left" />
</button>
<div class="testimonial-stage">
<div bind:this={stageEl} class="testimonial-stage" on:scroll={handleStageScroll}>
<div class="testimonial-woof" aria-hidden="true">
<span class="testimonial-woof-text">WOOF</span>
<span class="testimonial-ray testimonial-ray-1"></span>
@@ -640,14 +678,28 @@
.testimonial-stage {
min-height: unset;
display: flex;
padding-bottom: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.testimonial-stage::-webkit-scrollbar {
display: none;
}
.testimonial-slide {
position: relative;
display: none;
display: grid;
flex: 0 0 100%;
grid-template-columns: 1fr;
opacity: 1;
pointer-events: auto;
transform: none;
scroll-snap-align: start;
}
.testimonial-slide-active {
@@ -689,17 +741,27 @@
.testimonial-mobile-controls {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: 100%;
gap: 12px;
margin-top: 20px;
}
.testimonial-arrow-inline {
position: static;
width: 48px;
height: 48px;
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-size: 18px;
transform: none;
box-shadow: 0 10px 22px rgba(20, 24, 20, 0.08);
box-shadow: none;
}
.testimonial-arrow-inline:active {
transform: scale(0.95);
}
.testimonial-google {
+125 -11
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
import type { IconCard } from '$lib/types';
export let values: IconCard[];
let valuesScroller: HTMLDivElement | undefined;
$: orderedValues = values
.map((value, index) => ({ value, index }))
@@ -17,6 +19,36 @@
return a.index - b.index;
})
.map(({ value }) => value);
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth <= 768;
}
async function scrollValues(direction: 1 | -1) {
if (!valuesScroller || !isMobileViewport()) {
return;
}
const firstCard = valuesScroller.querySelector<HTMLElement>('.value-card');
if (!firstCard) {
return;
}
const cardStyle = window.getComputedStyle(valuesScroller);
const gap = Number.parseFloat(cardStyle.columnGap || cardStyle.gap || '0') || 0;
const step = firstCard.offsetWidth + gap;
await tick();
valuesScroller.scrollBy({ left: direction * step, behavior: 'smooth' });
}
onMount(() => {
if (!valuesScroller || !isMobileViewport()) {
return;
}
valuesScroller.scrollTo({ left: 0, behavior: 'auto' });
});
</script>
<section id="values">
@@ -27,18 +59,29 @@
Everything is designed to make life easier for busy Auckland dog owners and safer, happier for the dogs in our care.
</p>
<div class="values-grid">
{#each orderedValues as value}
<div class="value-card">
<div class="value-icon-wrap">
<Icon name={value.icon} className="value-card-icon" />
<div class="values-shell">
<div bind:this={valuesScroller} class="values-grid">
{#each orderedValues as value}
<div class="value-card">
<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>
<div class="value-text">
<h3>{value.title}</h3>
<p>{value.body}</p>
</div>
</div>
{/each}
{/each}
</div>
<div class="values-mobile-controls" aria-label="Value cards navigation">
<button type="button" class="values-mobile-button" aria-label="Previous value" on:click={() => scrollValues(-1)}>
<Icon name="fas fa-chevron-left" />
</button>
<button type="button" class="values-mobile-button" aria-label="Next value" on:click={() => scrollValues(1)}>
<Icon name="fas fa-chevron-right" />
</button>
</div>
</div>
</div>
</section>
@@ -68,7 +111,68 @@
line-height: 1.65;
}
.values-mobile-controls {
display: none;
}
@media (max-width: 768px) {
.values-shell {
margin-top: 32px;
}
.values-grid {
grid-auto-flow: column;
grid-auto-columns: minmax(272px, 84vw);
grid-template-columns: none;
gap: 14px;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scroll-padding-left: 24px;
padding: 0 24px 8px 0;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.values-grid::-webkit-scrollbar {
display: none;
}
.values-mobile-controls {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.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;
}
.value-card {
min-height: 100%;
padding: 24px 22px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
background: rgba(255, 255, 255, 0.05);
scroll-snap-align: start;
}
.value-card:nth-child(odd) {
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
.values-eyebrow {
margin-bottom: 8px;
padding: 6px 10px;
@@ -81,4 +185,14 @@
line-height: 1.55;
}
}
@media (hover: hover) {
.values-mobile-button:hover {
background: rgba(255, 255, 255, 0.18);
}
}
.values-mobile-button:active {
transform: scale(0.95);
}
</style>