438 lines
11 KiB
Svelte
438 lines
11 KiB
Svelte
<script lang="ts">
|
||
import { browser } from '$app/environment';
|
||
import { onMount } from 'svelte';
|
||
import { page } from '$app/stores';
|
||
import { categories, getCalculatorsByCategory, type CalculatorDef } from '$lib/data/calculators';
|
||
|
||
let expandedCategory = '';
|
||
let expandedUnits: Record<string, string> = {};
|
||
let isDesktop = false;
|
||
let navBreakpoint: MediaQueryList | null = null;
|
||
let lastPath = '';
|
||
let lastDesktop = isDesktop;
|
||
let autoExpandedCategory = '';
|
||
|
||
const categoryPathRegex = /^\/category\/([^/]+)(?:\/|$)/;
|
||
|
||
function getCategorySlugFromPath(path: string) {
|
||
const match = path.match(categoryPathRegex);
|
||
return match?.[1] ?? '';
|
||
}
|
||
|
||
$: currentPath = $page.url.pathname;
|
||
|
||
type UnitGroup = {
|
||
label: string;
|
||
conversions: CalculatorDef[];
|
||
};
|
||
|
||
type UnitBucket = {
|
||
label: string;
|
||
conversions: CalculatorDef[];
|
||
};
|
||
|
||
const sortConversionsForUnit = (conversions: CalculatorDef[], unitLabel: string) => {
|
||
const normalizedUnit = unitLabel.toLowerCase();
|
||
return conversions.slice().sort((a, b) => {
|
||
const aIsSource = a.labels.in1?.toLowerCase() === normalizedUnit;
|
||
const bIsSource = b.labels.in1?.toLowerCase() === normalizedUnit;
|
||
if (aIsSource !== bIsSource) {
|
||
return aIsSource ? -1 : 1;
|
||
}
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
};
|
||
|
||
$: categoryUnitGroups = Object.entries(categories).map(([key, meta]) => {
|
||
const buckets = new Map<string, UnitBucket>();
|
||
const calcs = getCalculatorsByCategory(key);
|
||
|
||
calcs.forEach(calc => {
|
||
[calc.labels.in1, calc.labels.in2].forEach(unit => {
|
||
const key = unit.toLowerCase();
|
||
const existing = buckets.get(key);
|
||
if (existing) {
|
||
existing.conversions.push(calc);
|
||
} else {
|
||
buckets.set(key, {
|
||
label: unit,
|
||
conversions: [calc],
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
const units = [...buckets.entries()]
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([, bucket]) => ({
|
||
label: bucket.label,
|
||
conversions: sortConversionsForUnit(bucket.conversions, bucket.label),
|
||
}));
|
||
|
||
return { key, meta, units };
|
||
});
|
||
|
||
function toggle(cat: string) {
|
||
const wasOpen = expandedCategory === cat;
|
||
expandedCategory = wasOpen ? '' : cat;
|
||
if (wasOpen) {
|
||
expandedUnits = { ...expandedUnits, [cat]: '' };
|
||
}
|
||
}
|
||
|
||
function toggleUnit(category: string, unitLabel: string) {
|
||
expandedUnits = {
|
||
...expandedUnits,
|
||
[category]: expandedUnits[category] === unitLabel ? '' : unitLabel,
|
||
};
|
||
}
|
||
|
||
onMount(() => {
|
||
if (!browser) return;
|
||
|
||
navBreakpoint = window.matchMedia('(max-width: 1024px)');
|
||
const updateDesktop = (event?: MediaQueryListEvent) => {
|
||
const matches = event?.matches ?? navBreakpoint!.matches;
|
||
isDesktop = !matches;
|
||
};
|
||
|
||
updateDesktop();
|
||
|
||
const handleNavChange = (event: MediaQueryListEvent) => updateDesktop(event);
|
||
if ('addEventListener' in navBreakpoint) {
|
||
navBreakpoint.addEventListener('change', handleNavChange);
|
||
} else {
|
||
navBreakpoint.addListener(handleNavChange);
|
||
}
|
||
|
||
return () => {
|
||
if (!navBreakpoint) return;
|
||
if ('removeEventListener' in navBreakpoint) {
|
||
navBreakpoint.removeEventListener('change', handleNavChange);
|
||
} else {
|
||
navBreakpoint.removeListener(handleNavChange);
|
||
}
|
||
};
|
||
});
|
||
|
||
$: if (browser && (currentPath !== lastPath || isDesktop !== lastDesktop)) {
|
||
const slug = getCategorySlugFromPath(currentPath);
|
||
if (isDesktop && slug) {
|
||
expandedCategory = slug;
|
||
autoExpandedCategory = slug;
|
||
} else if (autoExpandedCategory && (!isDesktop || !slug)) {
|
||
if (expandedCategory === autoExpandedCategory) {
|
||
expandedCategory = '';
|
||
}
|
||
autoExpandedCategory = '';
|
||
}
|
||
lastPath = currentPath;
|
||
lastDesktop = isDesktop;
|
||
}
|
||
|
||
export let open = false;
|
||
$: isSidebarHidden = !isDesktop && !open;
|
||
|
||
function closeSidebar() {
|
||
open = false;
|
||
}
|
||
|
||
function handleWindowKeydown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape' && open && !isDesktop) {
|
||
closeSidebar();
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:window on:keydown={handleWindowKeydown} />
|
||
|
||
<aside
|
||
class="sidebar"
|
||
class:open={open}
|
||
id="site-navigation"
|
||
aria-hidden={isSidebarHidden ? 'true' : undefined}
|
||
inert={isSidebarHidden}
|
||
>
|
||
<div class="sidebar-header">
|
||
<h3>All Converters</h3>
|
||
<button type="button" class="close-btn" on:click={closeSidebar} aria-label="Close sidebar">✕</button>
|
||
</div>
|
||
<nav aria-label="Calculator categories">
|
||
{#each categoryUnitGroups as group}
|
||
<div class="cat-section">
|
||
<button
|
||
type="button"
|
||
class="cat-toggle"
|
||
class:active={expandedCategory === group.key || currentPath.includes(`/category/${group.key}`)}
|
||
on:click={() => toggle(group.key)}
|
||
aria-expanded={expandedCategory === group.key}
|
||
aria-controls={`cat-list-${group.key}`}
|
||
>
|
||
<span class="cat-icon">{group.meta.icon}</span>
|
||
<span class="cat-label">{group.meta.label}</span>
|
||
<span class="chevron" class:expanded={expandedCategory === group.key}>›</span>
|
||
</button>
|
||
{#if expandedCategory === group.key}
|
||
<ul class="cat-list" id={`cat-list-${group.key}`}>
|
||
{#each group.units as unit (unit.label)}
|
||
<li class="unit-item">
|
||
<button
|
||
type="button"
|
||
class="unit-toggle"
|
||
class:expanded={expandedUnits[group.key] === unit.label}
|
||
aria-expanded={expandedUnits[group.key] === unit.label}
|
||
on:click={() => toggleUnit(group.key, unit.label)}
|
||
>
|
||
<span class="unit-label">{unit.label}</span>
|
||
<span class="chevron" class:expanded={expandedUnits[group.key] === unit.label}>›</span>
|
||
</button>
|
||
{#if expandedUnits[group.key] === unit.label}
|
||
<ul class="unit-list">
|
||
{#each unit.conversions as calc}
|
||
<li>
|
||
<a
|
||
href="/{calc.slug}"
|
||
class:current={currentPath === `/${calc.slug}`}
|
||
aria-current={currentPath === `/${calc.slug}` ? 'page' : undefined}
|
||
>
|
||
{calc.name}
|
||
</a>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
<li>
|
||
<a
|
||
href="/category/{group.key}"
|
||
class="view-all"
|
||
aria-current={currentPath === `/category/${group.key}` ? 'page' : undefined}
|
||
>
|
||
View all {group.meta.label} →
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</nav>
|
||
</aside>
|
||
|
||
{#if open && !isDesktop}
|
||
<button type="button" class="overlay" aria-label="Close sidebar" on:click={closeSidebar}></button>
|
||
{/if}
|
||
|
||
<style>
|
||
.sidebar {
|
||
flex: 0 0 280px;
|
||
width: 280px;
|
||
min-width: 280px;
|
||
position: sticky;
|
||
top: var(--header-h);
|
||
height: calc(100vh - var(--header-h));
|
||
overflow-y: auto;
|
||
background: var(--sidebar-bg);
|
||
border-right: 1px solid var(--border);
|
||
padding: 0;
|
||
}
|
||
.sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 1.25rem 1rem;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.sidebar-header h3 {
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--accent);
|
||
}
|
||
.close-btn {
|
||
display: none;
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-muted);
|
||
font-size: 1.2rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem;
|
||
}
|
||
.close-btn:focus-visible {
|
||
outline: none;
|
||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
nav {
|
||
padding: 0.5rem 0;
|
||
}
|
||
|
||
.cat-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 0.6rem 1rem;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
font-size: 0.88rem;
|
||
color: var(--text);
|
||
gap: 0.5rem;
|
||
transition: background 0.15s;
|
||
text-align: left;
|
||
}
|
||
.cat-toggle:hover {
|
||
background: var(--hover-bg);
|
||
}
|
||
.cat-toggle:focus-visible {
|
||
outline: none;
|
||
box-shadow: inset 0 0 0 2px var(--accent-glow);
|
||
}
|
||
.cat-toggle.active {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.cat-icon {
|
||
font-size: 1rem;
|
||
flex-shrink: 0;
|
||
width: 1.4rem;
|
||
text-align: center;
|
||
}
|
||
.cat-label {
|
||
flex: 1;
|
||
}
|
||
.chevron {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
color: var(--text-muted);
|
||
transition: transform 0.2s;
|
||
}
|
||
.chevron.expanded {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.cat-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0 0 0.5rem;
|
||
}
|
||
.cat-list li a {
|
||
display: block;
|
||
padding: 0.35rem 1rem 0.35rem 2.8rem;
|
||
font-size: 0.82rem;
|
||
color: var(--text-muted);
|
||
text-decoration: none;
|
||
transition: color 0.15s, background 0.15s;
|
||
border-radius: 0;
|
||
}
|
||
.cat-list li a:hover {
|
||
color: var(--accent);
|
||
background: var(--hover-bg);
|
||
}
|
||
.cat-list li a:focus-visible {
|
||
outline: none;
|
||
color: var(--accent);
|
||
background: var(--hover-bg);
|
||
}
|
||
.cat-list li a.current {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
background: var(--accent-glow);
|
||
}
|
||
.view-all {
|
||
font-weight: 600 !important;
|
||
color: var(--accent) !important;
|
||
}
|
||
|
||
.unit-item {
|
||
margin: 0;
|
||
}
|
||
|
||
.unit-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 0.4rem 1rem 0.4rem 2.5rem;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
gap: 0.4rem;
|
||
text-align: left;
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
.unit-toggle:hover {
|
||
background: var(--hover-bg);
|
||
}
|
||
.unit-toggle:focus-visible {
|
||
outline: none;
|
||
box-shadow: inset 0 0 0 2px var(--accent-glow);
|
||
}
|
||
.unit-toggle.expanded {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.unit-label {
|
||
flex: 1;
|
||
}
|
||
.unit-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
.unit-list li a {
|
||
display: block;
|
||
padding: 0.25rem 1rem 0.25rem 3.4rem;
|
||
font-size: 0.78rem;
|
||
color: var(--text-muted);
|
||
text-decoration: none;
|
||
transition: color 0.15s, background 0.15s;
|
||
border-radius: 0;
|
||
}
|
||
.unit-list li a:hover {
|
||
color: var(--accent);
|
||
background: var(--hover-bg);
|
||
}
|
||
.unit-list li a:focus-visible {
|
||
outline: none;
|
||
color: var(--accent);
|
||
background: var(--hover-bg);
|
||
}
|
||
|
||
.overlay {
|
||
display: none;
|
||
border: 0;
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.sidebar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 100;
|
||
height: 100vh;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.3s ease;
|
||
will-change: transform;
|
||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
|
||
}
|
||
.sidebar.open {
|
||
transform: translateX(0);
|
||
}
|
||
.close-btn {
|
||
display: block;
|
||
}
|
||
.overlay {
|
||
display: block;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 99;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
}
|
||
}
|
||
</style>
|