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

438 lines
11 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, 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>