From 2ec90ae4faa984eb282a3c903b19280efc68d705 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jason@rasm.me>
Date: Fri, 20 Dec 2024 17:07:50 -0500
Subject: [PATCH] feat: navbar (#48)

---
 package-lock.json                             |  8 ++--
 package.json                                  |  2 +-
 src/app.css                                   |  2 +
 src/docs/components/ExampleLayout.svelte      |  2 +-
 src/docs/constants.ts                         | 14 ++++---
 .../AppShell/AppShellSidebar.svelte           |  6 ++-
 src/lib/components/AppShell/PageLayout.svelte |  2 +-
 src/lib/components/Navbar/NavbarGroup.svelte  | 12 ++++++
 src/lib/components/Navbar/NavbarItem.svelte   | 31 ++++++++++++++
 src/lib/index.ts                              |  2 +
 src/routes/+layout.svelte                     | 28 ++++++-------
 src/routes/+page.svelte                       |  2 +-
 .../components/app-shell/BasicExample.svelte  | 40 ++++++++-----------
 src/routes/components/navbar/+page.svelte     | 14 +++++++
 .../components/navbar/BasicExample.svelte     |  8 ++++
 .../components/navbar/GroupExample.svelte     | 12 ++++++
 tailwind.config.ts                            |  1 +
 17 files changed, 133 insertions(+), 53 deletions(-)
 create mode 100644 src/lib/components/Navbar/NavbarGroup.svelte
 create mode 100644 src/lib/components/Navbar/NavbarItem.svelte
 create mode 100644 src/routes/components/navbar/+page.svelte
 create mode 100644 src/routes/components/navbar/BasicExample.svelte
 create mode 100644 src/routes/components/navbar/GroupExample.svelte

diff --git a/package-lock.json b/package-lock.json
index 6aa80c3..6e3b535 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,7 @@
 			},
 			"devDependencies": {
 				"@sveltejs/adapter-static": "^3.0.6",
-				"@sveltejs/kit": "^2.0.0",
+				"@sveltejs/kit": "^2.13.0",
 				"@sveltejs/package": "^2.0.0",
 				"@sveltejs/vite-plugin-svelte": "^5.0.2",
 				"@types/eslint": "^9.6.0",
@@ -1098,9 +1098,9 @@
 			}
 		},
 		"node_modules/@sveltejs/kit": {
-			"version": "2.11.0",
-			"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.11.0.tgz",
-			"integrity": "sha512-VtHkM5i4qAIeO9hfYwKD6Hxn7Ik+RkYam9842RXw6YdtzuI+gsA8XepZs7FB/o7hjQBJCDmvXahiGAnff1QU6w==",
+			"version": "2.13.0",
+			"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.13.0.tgz",
+			"integrity": "sha512-6t6ne00vZx/TjD6s0Jvwt8wRLKBwbSAN1nhlOzcLUSTYX1hTp4eCBaTPB5Yz/lu+tYcvz4YPEEuPv3yfsNp2gw==",
 			"dev": true,
 			"hasInstallScript": true,
 			"license": "MIT",
diff --git a/package.json b/package.json
index 80730b6..36114d9 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
 	},
 	"devDependencies": {
 		"@sveltejs/adapter-static": "^3.0.6",
-		"@sveltejs/kit": "^2.0.0",
+		"@sveltejs/kit": "^2.13.0",
 		"@sveltejs/package": "^2.0.0",
 		"@sveltejs/vite-plugin-svelte": "^5.0.2",
 		"@types/eslint": "^9.6.0",
diff --git a/src/app.css b/src/app.css
index 83dbf94..23688ec 100644
--- a/src/app.css
+++ b/src/app.css
@@ -12,6 +12,7 @@
 		--immich-danger: 180 0 0;
 		--immich-warning: 255 170 0;
 		--immich-info: 14 165 233;
+		--immich-gray: 246 246 246;
 	}
 
 	.dark {
@@ -23,5 +24,6 @@
 		--immich-danger: 239 68 68;
 		--immich-warning: 255 170 0;
 		--immich-info: 14 165 233;
+		--immich-gray: 33 33 33;
 	}
 }
diff --git a/src/docs/components/ExampleLayout.svelte b/src/docs/components/ExampleLayout.svelte
index 882a55f..ed63c6d 100644
--- a/src/docs/components/ExampleLayout.svelte
+++ b/src/docs/components/ExampleLayout.svelte
@@ -14,7 +14,7 @@
 <div class="flex h-full flex-col">
 	<!-- TODO replace with breadcrumb component -->
 	<nav
-		class="flex shrink-0 justify-between border-b border-gray-300 bg-light px-8 py-2 text-dark dark:border-gray-700"
+		class="flex shrink-0 justify-between border-b border-gray-300 bg-light p-4 text-dark dark:border-gray-700"
 	>
 		<div class="flex items-center gap-2">
 			<a href="/" class="underline">Home</a>
diff --git a/src/docs/constants.ts b/src/docs/constants.ts
index 736fce4..4af3afb 100644
--- a/src/docs/constants.ts
+++ b/src/docs/constants.ts
@@ -5,7 +5,7 @@ import {
 	mdiCardOutline,
 	mdiCheckboxMarked,
 	mdiCloseCircle,
-	mdiCreditCardOutline,
+	mdiDotsCircle,
 	mdiFormatHeaderPound,
 	mdiFormTextbox,
 	mdiFormTextboxPassword,
@@ -13,6 +13,7 @@ import {
 	mdiImage,
 	mdiLink,
 	mdiListBoxOutline,
+	mdiMenu,
 	mdiNumeric,
 	mdiPartyPopper,
 	mdiViewSequential,
@@ -40,16 +41,17 @@ export type ExampleCardProps = ExampleItem & { theme: Theme };
 
 export const componentGroups = [
 	{
-		name: 'Layout',
+		title: 'Layout',
 		components: [
 			{ name: 'Alert', icon: mdiAlertCircleOutline },
 			{ name: 'AppShell', icon: mdiApplicationOutline },
 			{ name: 'Card', icon: mdiCardOutline },
+			{ name: 'Navbar', icon: mdiMenu },
 			{ name: 'Stack', icon: mdiViewSequential },
 		],
 	},
 	{
-		name: 'Forms',
+		title: 'Forms',
 		components: [
 			{ name: 'Button', icon: mdiButtonCursor },
 			{ name: 'IconButton', icon: mdiHomeCircle },
@@ -57,12 +59,12 @@ export const componentGroups = [
 			{ name: 'CloseButton', icon: mdiCloseCircle },
 			{ name: 'Field', icon: mdiListBoxOutline },
 			{ name: 'Input', icon: mdiFormTextbox },
-			{ name: 'LoadingSpinner', icon: mdiCreditCardOutline },
+			{ name: 'LoadingSpinner', icon: mdiDotsCircle },
 			{ name: 'PasswordInput', icon: mdiFormTextboxPassword },
 		],
 	},
 	{
-		name: 'Text',
+		title: 'Text',
 		components: [
 			{ name: 'Text', icon: mdiFormatHeaderPound },
 			{ name: 'Heading', icon: mdiFormTextbox },
@@ -71,7 +73,7 @@ export const componentGroups = [
 		],
 	},
 	{
-		name: 'Immich',
+		title: 'Immich',
 		components: [
 			{ name: 'Logo', icon: mdiImage },
 			{ name: 'SupporterBadge', icon: mdiPartyPopper },
diff --git a/src/lib/components/AppShell/AppShellSidebar.svelte b/src/lib/components/AppShell/AppShellSidebar.svelte
index a98e048..0978ffd 100644
--- a/src/lib/components/AppShell/AppShellSidebar.svelte
+++ b/src/lib/components/AppShell/AppShellSidebar.svelte
@@ -8,16 +8,18 @@
 	type Props = {
 		class?: string;
 		children: Snippet;
+		noBorder?: boolean;
 	};
 
-	let { class: className, children }: Props = $props();
+	let { class: className, children, noBorder = false }: Props = $props();
 </script>
 
 <Child for={ChildKey.AppShell} as={ChildKey.AppShellSidebar}>
 	<Scrollable
 		class={cleanClass(
-			'hidden h-full shrink-0 border-r border-gray-200 dark:border-gray-700 lg:block',
+			'hidden h-full shrink-0 border-gray-200 dark:border-gray-700 lg:block',
 			className,
+			noBorder || 'border-r',
 		)}
 	>
 		{@render children?.()}
diff --git a/src/lib/components/AppShell/PageLayout.svelte b/src/lib/components/AppShell/PageLayout.svelte
index 51be798..0b8c972 100644
--- a/src/lib/components/AppShell/PageLayout.svelte
+++ b/src/lib/components/AppShell/PageLayout.svelte
@@ -24,7 +24,7 @@
 <section class="relative">
 	{#if title || buttons}
 		<div
-			class="dark:border-immich-dark-gray dark:text-immich-dark-fg absolute flex h-16 w-full place-items-center justify-between border-b p-4"
+			class="dark:border-immich-neutral dark:text-immich-dark-fg absolute flex h-16 w-full place-items-center justify-between border-b p-4"
 		>
 			<div class="flex items-center gap-2">
 				{#if title}
diff --git a/src/lib/components/Navbar/NavbarGroup.svelte b/src/lib/components/Navbar/NavbarGroup.svelte
new file mode 100644
index 0000000..24b2f6f
--- /dev/null
+++ b/src/lib/components/Navbar/NavbarGroup.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+	type Props = {
+		title: string;
+	};
+
+	let { title }: Props = $props();
+</script>
+
+<div class="text-xs transition-all duration-200">
+	<p class="hidden px-6 py-4 uppercase group-hover:sm:block md:block">{title}</p>
+	<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
+</div>
diff --git a/src/lib/components/Navbar/NavbarItem.svelte b/src/lib/components/Navbar/NavbarItem.svelte
new file mode 100644
index 0000000..a61a4d6
--- /dev/null
+++ b/src/lib/components/Navbar/NavbarItem.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+	import Icon from '$lib/components/Icon/Icon.svelte';
+	import type { IconProps } from '$lib/types.js';
+	import { tv } from 'tailwind-variants';
+
+	type Props = {
+		title: string;
+		active?: boolean;
+		href: string;
+	} & IconProps;
+
+	let { href, title, active = false, ...iconProps }: Props = $props();
+
+	const styles = tv({
+		base: 'hover:bg-neutral hover:text-primary flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 pl-5 group-hover:sm:px-5 md:px-5',
+		variants: {
+			active: {
+				true: 'bg-primary/10 text-primary',
+				false: '',
+			},
+		},
+	});
+</script>
+
+<a {href} draggable="false" aria-current={active ? 'page' : undefined} class={styles({ active })}>
+	<div class="flex w-full place-items-center gap-4 overflow-hidden truncate">
+		<Icon size="1.5em" class="shrink-0" aria-hidden={true} {...iconProps} />
+		<span class="text-sm font-medium">{title}</span>
+	</div>
+	<div></div>
+</a>
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 944ef80..b8a40f7 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -23,6 +23,8 @@ export { default as IconButton } from '$lib/components/IconButton/IconButton.sve
 export { default as Link } from '$lib/components/Link/Link.svelte';
 export { default as LoadingSpinner } from '$lib/components/LoadingSpinner/LoadingSpinner.svelte';
 export { default as Logo } from '$lib/components/Logo/Logo.svelte';
+export { default as NavbarGroup } from '$lib/components/Navbar/NavbarGroup.svelte';
+export { default as NavbarItem } from '$lib/components/Navbar/NavbarItem.svelte';
 export { default as Scrollable } from '$lib/components/Scrollable/Scrollable.svelte';
 export { default as HStack } from '$lib/components/Stack/HStack.svelte';
 export { default as Stack } from '$lib/components/Stack/Stack.svelte';
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index dfdccfb..edde63e 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,4 +1,5 @@
 <script lang="ts">
+	import { page } from '$app/state';
 	import Navbar from '$docs/components/Navbar.svelte';
 	import { componentGroups, Theme } from '$docs/constants.js';
 	import { asComponentHref } from '$docs/utilities.js';
@@ -7,19 +8,20 @@
 		AppShell,
 		AppShellHeader,
 		AppShellSidebar,
-		Heading,
 		IconButton,
-		Link,
-		Stack,
+		NavbarGroup,
+		NavbarItem,
 	} from '@immich/ui';
-	import { mdiWeatherNight, mdiWeatherSunny } from '@mdi/js';
+	import { mdiHome, mdiWeatherNight, mdiWeatherSunny } from '@mdi/js';
 	import '../app.css';
+
 	let { children } = $props();
 
 	const handleToggleTheme = () =>
 		(theme.value = theme.value === Theme.Dark ? Theme.Light : Theme.Dark);
 
 	const themeIcon = $derived(theme.value === Theme.Light ? mdiWeatherSunny : mdiWeatherNight);
+	const isActive = (path: string) => path === page.url.pathname;
 </script>
 
 <AppShell class="{theme.value} bg-light text-dark">
@@ -36,17 +38,15 @@
 		</Navbar>
 	</AppShellHeader>
 
-	<AppShellSidebar class="p-4">
-		<Stack class="min-w-[200px]">
-			{#each componentGroups as group}
-				<Heading size="tiny">{group.name}</Heading>
-				<Stack class="pl-4">
-					{#each group.components as component}
-						<Link href={asComponentHref(component.name)}>{component.name}</Link>
-					{/each}
-				</Stack>
+	<AppShellSidebar class="min-w-[225px] py-4 pr-4">
+		<NavbarItem active={isActive('/')} title="Home" icon={mdiHome} href="/" />
+		{#each componentGroups as group}
+			<NavbarGroup title={group.title} />
+			{#each group.components as component}
+				{@const href = asComponentHref(component.name)}
+				<NavbarItem active={isActive(href)} title={component.name} icon={component.icon} {href} />
 			{/each}
-		</Stack>
+		{/each}
 	</AppShellSidebar>
 
 	<div class="h-full">
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 048a717..cae1d92 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -25,7 +25,7 @@
 		<Stack gap={8}>
 			{#each componentGroups as group}
 				<Stack>
-					<Heading size="medium">{group.name}</Heading>
+					<Heading size="medium">{group.title}</Heading>
 					<Grid>
 						{#each group.components as component}
 							<ComponentCard {component} />
diff --git a/src/routes/components/app-shell/BasicExample.svelte b/src/routes/components/app-shell/BasicExample.svelte
index 7495a05..2c5ad46 100644
--- a/src/routes/components/app-shell/BasicExample.svelte
+++ b/src/routes/components/app-shell/BasicExample.svelte
@@ -1,6 +1,13 @@
 <script lang="ts">
-	import DecorativeBlock from '$docs/components/DecorativeBlock.svelte';
-	import { AppShell, AppShellHeader, AppShellSidebar, Heading, Stack } from '@immich/ui';
+	import {
+		AppShell,
+		AppShellHeader,
+		AppShellSidebar,
+		Heading,
+		NavbarItem,
+		Stack,
+	} from '@immich/ui';
+	import { mdiHome } from '@mdi/js';
 </script>
 
 <Stack>
@@ -46,30 +53,17 @@
 				</div>
 			</AppShellHeader>
 
-			<AppShellSidebar>
-				<div class="p-4">
-					<Heading size="tiny">Sidebar Overflow</Heading>
-					<Stack>
-						<DecorativeBlock />
-						<DecorativeBlock />
-						<DecorativeBlock />
-						<DecorativeBlock />
-						<DecorativeBlock />
-						<DecorativeBlock />
-					</Stack>
-				</div>
+			<AppShellSidebar noBorder class="pt-2">
+				<Stack>
+					<NavbarItem icon={mdiHome} title="Home" href="/" active />
+					<NavbarItem icon={mdiHome} title="Home" href="/" />
+					<NavbarItem icon={mdiHome} title="Home" href="/" />
+					<NavbarItem icon={mdiHome} title="Home" href="/" />
+				</Stack>
 			</AppShellSidebar>
 
 			<div class="p-4">
-				<Heading size="tiny">Content Overflow</Heading>
-				<Stack>
-					<DecorativeBlock />
-					<DecorativeBlock />
-					<DecorativeBlock />
-					<DecorativeBlock />
-					<DecorativeBlock />
-					<DecorativeBlock />
-				</Stack>
+				<Heading size="tiny">Content</Heading>
 			</div>
 		</AppShell>
 	</div>
diff --git a/src/routes/components/navbar/+page.svelte b/src/routes/components/navbar/+page.svelte
new file mode 100644
index 0000000..d824b4f
--- /dev/null
+++ b/src/routes/components/navbar/+page.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+	import ExampleLayout from '$docs/components/ExampleLayout.svelte';
+	import BasicExample from './BasicExample.svelte';
+	import basicExample from './BasicExample.svelte?raw';
+	import GroupExample from './GroupExample.svelte';
+	import groupExample from './GroupExample.svelte?raw';
+
+	const examples = [
+		{ title: 'Basic', code: basicExample, component: BasicExample },
+		{ title: 'Group', code: groupExample, component: GroupExample },
+	];
+</script>
+
+<ExampleLayout name="Basic" {examples} />
diff --git a/src/routes/components/navbar/BasicExample.svelte b/src/routes/components/navbar/BasicExample.svelte
new file mode 100644
index 0000000..bdaca84
--- /dev/null
+++ b/src/routes/components/navbar/BasicExample.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+	import { NavbarItem } from '@immich/ui';
+	import { mdiHome } from '@mdi/js';
+</script>
+
+<div class="w-[200px]">
+	<NavbarItem icon={mdiHome} title="Home" href="#" active />
+</div>
diff --git a/src/routes/components/navbar/GroupExample.svelte b/src/routes/components/navbar/GroupExample.svelte
new file mode 100644
index 0000000..953b0b8
--- /dev/null
+++ b/src/routes/components/navbar/GroupExample.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+	import NavbarGroup from '$lib/components/Navbar/NavbarGroup.svelte';
+	import { NavbarItem } from '@immich/ui';
+	import { mdiButtonPointer, mdiCardOutline, mdiHome } from '@mdi/js';
+</script>
+
+<div class="w-[200px]">
+	<NavbarItem icon={mdiHome} title="Home" href="#" active />
+	<NavbarGroup title="Components" />
+	<NavbarItem icon={mdiButtonPointer} title="Button" href="#" />
+	<NavbarItem icon={mdiCardOutline} title="Card" href="#" />
+</div>
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 3e823e7..c31645e 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -13,6 +13,7 @@ export default {
 				danger: 'rgb(var(--immich-danger) / <alpha-value>)',
 				warning: 'rgb(var(--immich-warning) / <alpha-value>)',
 				info: 'rgb(var(--immich-info) / <alpha-value>)',
+				neutral: 'rgb(var(--immich-gray) / <alpha-value>)',
 			},
 		},
 	},