Files
HowDoYouConvert/hdyc-svelte/src/lib/components/Sidebar.svelte
T

493 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { categories } from '$lib/data/stats';
import { loadCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
let allCalculators: CalculatorDef[] = [];
let isLoaded = false;
async function loadData() {
if (isLoaded || !browser) return;
const data = await loadCalculators();
allCalculators = data;
isLoaded = true;
}
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: UnitConversionLink[];
};
type UnitBucket = {
label: string;
conversions: UnitConversionLink[];
};
type UnitConversionLink = {
name: string;
slug: string;
sortKey: string;
};
const sortConversionsForUnit = (conversions: UnitConversionLink[]) =>
conversions.slice().sort((a, b) => a.name.localeCompare(b.name));
const toPairKey = (unitA: string, unitB: string) =>
[unitA.toLowerCase(), unitB.toLowerCase()].sort().join('::');
const toDirectionKey = (fromUnit: string, toUnit: string) =>
`${fromUnit.toLowerCase()}::${toUnit.toLowerCase()}`;
function addConversion(
buckets: Map<string, UnitBucket>,
fromUnit: string,
toUnit: string,
slug: string
) {
const bucketKey = fromUnit.toLowerCase();
const directionKey = toDirectionKey(fromUnit, toUnit);
const conversion: UnitConversionLink = {
name: `${fromUnit} to ${toUnit}`,
slug,
sortKey: directionKey,
};
const existing = buckets.get(bucketKey);
if (existing) {
if (!existing.conversions.some(link => link.sortKey === directionKey)) {
existing.conversions.push(conversion);
}
return;
}
buckets.set(bucketKey, {
label: fromUnit,
conversions: [conversion],
});
}
$: categoryUnitGroups = Object.entries(categories).map(([key, meta]) => {
const buckets = new Map<string, UnitBucket>();
if (!isLoaded) {
return { key, meta, units: [] };
}
const calcs = allCalculators.filter(c => c.category === key && !c.hidden);
const canonicalByPair = new Map<string, CalculatorDef>();
calcs.forEach(calc => {
const pairKey = toPairKey(calc.labels.in1, calc.labels.in2);
const existing = canonicalByPair.get(pairKey);
if (!existing || calc.slug.localeCompare(existing.slug) < 0) {
canonicalByPair.set(pairKey, calc);
}
});
canonicalByPair.forEach(calc => {
addConversion(buckets, calc.labels.in1, calc.labels.in2, calc.slug);
addConversion(buckets, calc.labels.in2, calc.labels.in1, calc.slug);
});
const units = [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([, bucket]) => ({
label: bucket.label,
conversions: sortConversionsForUnit(bucket.conversions),
}));
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 as MediaQueryList & {
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).addListener(handleNavChange);
}
return () => {
if (!navBreakpoint) return;
if ('removeEventListener' in navBreakpoint) {
navBreakpoint.removeEventListener('change', handleNavChange);
} else {
(navBreakpoint as MediaQueryList & {
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).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;
$: if (browser && (isDesktop || open)) {
loadData();
}
$: 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 conversion}
<li>
<a
href="/{conversion.slug}"
class:current={currentPath === `/${conversion.slug}`}
aria-current={currentPath === `/${conversion.slug}` ? 'page' : undefined}
>
{conversion.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>