Design language
This commit is contained in:
@@ -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 & Greet. Start here and we’ll 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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user