Skip to content

Instantly share code, notes, and snippets.

@waffleflopper
Last active July 17, 2023 23:55
Show Gist options
  • Save waffleflopper/08ef7fe0795f7b54ad126f89f7fa5cd9 to your computer and use it in GitHub Desktop.
Save waffleflopper/08ef7fe0795f7b54ad126f89f7fa5cd9 to your computer and use it in GitHub Desktop.
Melt-UI Navbar MVP Example (before pre-processor release)
<!-- Derived from https://github.com/skeletonlabs/skeleton/blob/dev/packages/skeleton/src/lib/utilities/LightSwitch/LightSwitch.svelte -->
<script lang="ts">
import { Moon, Sun } from 'lucide-svelte';
import { onMount } from 'svelte';
import { twc } from '$utils/class';
let className = '';
export { className as class };
import { getModeOsPrefers, modeCurrent, setModeCurrent, setModeUserPrefers } from './darkSwitch';
import type { OnKeyDownEvent } from '.';
function toggleTheme(): void {
$modeCurrent = !$modeCurrent;
setModeUserPrefers($modeCurrent);
setModeCurrent($modeCurrent);
}
function onKeyDown(event: Event): void {
const keyboardEvent = event as OnKeyDownEvent;
if (['Enter', 'Space'].includes(keyboardEvent.code)) {
event.preventDefault();
keyboardEvent.currentTarget.click();
}
}
onMount(() => {
if (!('modeCurrent' in localStorage)) {
setModeCurrent(getModeOsPrefers());
}
});
</script>
<button
on:click={toggleTheme}
on:keydown={onKeyDown}
role="switch"
aria-label="Dark Switch"
aria-checked={$modeCurrent}
title="Toggle {$modeCurrent ? 'light' : 'dark'} mode"
class={twc('btn-ghost w-9 px-0', className)}
>
{#if $modeCurrent}
<Moon class="w-5 h-5" />
<span class="sr-only">Dark Mode</span>
{:else}
<Sun class="w-5 h-5" />
<span class="sr-only">Light Mode</span>
{/if}
</button>
<!--
@component DarkSwitch
@selector dark-switch
@description A switch to toggle between light and dark mode.
@state $modeCurrent - The current mode.
@state $modeUserPrefers - The user's preferred mode.
@state $modeOsPrefers - The OS's preferred mode.
-->
<!-- Shamelessly stolen from https://github.com/skeletonlabs/skeleton/blob/dev/packages/skeleton/src/lib/utilities/LightSwitch/lightswitch.ts -->
import { get } from 'svelte/store';
import { localStorageStore } from '$lib/utils/store';
//stores
export const modeOsPrefers = localStorageStore<boolean>('modeOsPrefers', false);
export const modeUserPrefers = localStorageStore<boolean | undefined>('modeUserPrefers', undefined);
export const modeCurrent = localStorageStore<boolean>('modeCurrent', false);
//get
export function getModeOsPrefers(): boolean {
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
modeOsPrefers.set(prefersLight);
return get(modeOsPrefers);
}
export function getModeUserPrefers(): boolean | undefined {
return get(modeUserPrefers);
}
export function getModeAutoPrefers(): boolean {
const os = getModeOsPrefers();
const user = getModeUserPrefers();
const modeValue = user !== undefined ? user : os;
return modeValue;
}
//set
export function setModeUserPrefers(value: boolean): void {
modeUserPrefers.set(value);
}
export function setModeCurrent(value: boolean) {
const elemHtmlClasses = document.documentElement.classList;
const classDark = `dark`;
value == true ? elemHtmlClasses.remove(classDark) : elemHtmlClasses.add(classDark);
modeCurrent.set(value);
}
/**
* Set the initial class state of the html element based on the user's preferences or the OS preferences. If the user has not set a preference, the OS preference will be used.
* Shamelessly stolen from Skeleton-UI https://skeleton.dev
* @example
* <svelte:head>
*
* {@html `<\u{73}cript nonce="%sveltekit.nonce%">(${setInitialClassState.toString()})();</script>`}
*
* </svelte:head>
*/
export function setInitialClassState() {
const elemHtmlClasses = document.documentElement.classList;
const localStorageUserPrefs = localStorage.getItem('modeUserPrefers') === 'false';
if (localStorageUserPrefs) {
elemHtmlClasses.add('dark');
} else {
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
prefersLight ? elemHtmlClasses.add('dark') : elemHtmlClasses.remove('dark');
}
}
export function autoModeWatcher(): void {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
function setMode(value: boolean) {
const elemHtmlClasses = document.documentElement.classList;
const classDark = `dark`;
value === true ? elemHtmlClasses.remove(classDark) : elemHtmlClasses.add(classDark);
}
setMode(mql.matches);
mql.onchange = () => {
setMode(mql.matches);
};
}
export function toggleMode(): void {
const mode = get(modeCurrent);
modeUserPrefers.set(!mode);
setModeCurrent(!mode);
}
<script lang="ts">
import { setInitialClassState } from './darkSwitch';
</script>
<svelte:head>
{@html `<\u{73}cript nonce="%sveltekit.nonce%">(${setInitialClassState.toString()})();</script>`}
</svelte:head>
<!--
@component
This component is used to prevent the flash of unstyled content (FOUC) when the page is loaded. It should be placed in your main +layout.svelte file. Under the hood it injects the setInitialClassState function into the head of the document as a string. This function is then called immediately after the page is loaded and sets the initial class state of the document based on the user's preference.
Method originally discovered in Skeleton-UI source code in a layout file.
```html
<script>
import { FOUC } from '$lib/components/DarkSwitch';
</script>
<FOUC />
```
-->
<script lang="ts">
import '../app.postcss';
import { FOUC, toggleMode } from '$components/DarkSwitch';
import { ChevronDown, Menu, Sun, Moon } from 'lucide-svelte';
import { createDropdownMenu, createCollapsible } from '@melt-ui/svelte';
import { slide } from 'svelte/transition';
import { modeCurrent } from '$components/DarkSwitch';
const { trigger, menu, item } = createDropdownMenu();
const {
open: navOpen,
root: navRoot,
content: navContent,
trigger: navTrigger
} = createCollapsible();
const {
open: accountOpen,
root: accountRoot,
content: accountContent,
trigger: accountTrigger
} = createCollapsible();
const navLinks = ['Home', 'Documentation', 'Examples', 'Blog', 'Contact'];
const accountLinks = ['Profile', 'Settings', 'Logout'];
//simple click outside handler for closing dropdowns
//svelte action
//could also be done through svelte:document most likely
function clickOutside(node: HTMLElement) {
function handleClick(event: MouseEvent) {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
navOpen.set(false);
accountOpen.set(false); //could leave out to 'remember'
}
}
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<!-- My Custom Component that injects script into the head onLoad to prevent flash of unstyled content bug -->
<FOUC />
<header class="fixed w-full z-50 bg-card text-card-foreground shadow-lg" use:clickOutside>
<div class="container mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between h-16">
<!-- Mobile menu button-->
<div class="flex items-center flex-1">
<!-- Logo area -->
<div class="flex-shrink-0"><a href="/" class="hover:text-accent font-bold">Company</a></div>
</div>
<!-- Navigation links (default layout) -->
<div class="hidden md:ml-6 md:flex md:space-x-8 flex-1">
{#each navLinks as link}
<a href="#{link.toLowerCase()}" class="text-card-foreground hover:text-accent">{link}</a>
{/each}
</div>
<!-- Profile dropdown -->
<div class="hidden md:flex md:items-center flex-1 justify-end">
<button
type="button"
class="trigger"
{...$trigger}
use:trigger.action
aria-label="Open profile menu"
>
<img class="h-8 w-8 rounded-full" src="https://picsum.photos/200" alt="" />
</button>
<div
class="menu z-50 bg-card border border-black/20 text-card-foreground p-4 rounded-md shadow-lg min-w-[20ch]"
{...$menu}
use:menu.action
>
{#each accountLinks as link}
<a
class="text-card-foreground hover:text-accent block"
{...item}
use:item.action
href="#{link.toLowerCase()}">{link}</a
>
{/each}
<button
class="text-card-foreground hover:text-accent pt-4 flex justify-between items-center w-full group"
{...item}
on:click={toggleMode}
>
<span>
Toggle {$modeCurrent ? 'Dark' : 'Light'} Mode
</span>
<span class="opacity-0 group-hover:opacity-100 transition-all duration-200">
{#if $modeCurrent}
<Moon class="h-4 w-4" />
{:else}
<Sun class="h-4 w-4" />
{/if}
</span>
</button>
</div>
</div>
<div class="md:hidden flex ml-4 items-center">
<button
class="mobile-menu-button"
aria-label="Open mobile menu"
{...$navTrigger}
use:navTrigger.action
>
<Menu class="h-6 w-6" />
</button>
</div>
</div>
</div>
{#if $navOpen}
<div class="relative" transition:slide {...$navRoot}>
<div {...$navContent} class="px-2 pt-2 pb-3 space-y-1">
{#each navLinks as link}
<a
href="#{link.toLowerCase()}"
class="text-card-foreground hover:text-accent block hover:bg-background p-2 rounded"
>{link}</a
>
{/each}
</div>
<div class="px-2 pt-2 pb-3 space-y-1">
<!-- Collapsible for Subnavigation -->
<div
{...$accountRoot}
class="text-card-foreground hover:text-accent block hover:bg-background p-2 rounded"
>
<button
{...$accountTrigger}
use:accountTrigger.action
class="w-full flex items-center justify-between"
>
<span>Account</span>
<span
class="transition-transform duration-200 ease-in-out transform"
class:rotate-180={$accountOpen}
>
<ChevronDown class="h-5 w-5" />
</span>
</button>
</div>
{#if $accountOpen}
<div {...$accountContent} transition:slide>
{#each accountLinks as link}
<a
href="#{link.toLowerCase()}"
class="text-card-foreground hover:text-accent block hover:bg-background p-2 rounded"
{...item}
use:item.action
>
{link}
</a>
{/each}
<button
class="text-card-foreground hover:text-accent flex justify-between items-center hover:bg-background p-2 rounded w-full"
{...item}
on:click={toggleMode}
>
<span>
Toggle {$modeCurrent ? 'Dark' : 'Light'} Mode
</span>
{#if $modeCurrent}
<Moon class="h-5 w-5" />
{:else}
<Sun class="h-5 w-5" />
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
</header>
<div class="w-full h-screen flex flex-col">
<slot />
</div>
/**
* Source: https://github.com/joshnuss/svelte-local-storage-store
* License: MIT
* Modified by: @waffleflopper
*
* This file is used to create a writable store that is saved to local storage.
*
*/
import type { Readable, Writable } from 'svelte/store';
import { browser } from '$app/environment'; //or use esm-env
import { get, writable } from 'svelte/store';
declare type Updater<T> = (value: T) => T;
declare type StoreDict<T> = {
[key: string]: Writable<T>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stores: StoreDict<any> = {};
interface Serializer<T> {
parse(text: string): T;
stringify(value: T): string;
}
type StorageType = 'local' | 'session';
interface Options<T> {
serializer?: Serializer<T>;
storage?: StorageType;
}
function getStorage(type: StorageType): Storage {
return type === 'local' ? localStorage : sessionStorage;
}
export function localStorageStore<T>(
key: string,
initialValue: T,
options?: Options<T>
): Writable<T> {
const serializer = options?.serializer ?? JSON;
const storageType = options?.storage ?? 'local';
const fullKey = `${key}`;
function updateStorage(key: string, value: T) {
if (!browser) return;
getStorage(storageType).setItem(key, serializer.stringify(value));
}
if (!stores[fullKey]) {
const store = writable(initialValue, (set) => {
const json = browser ? getStorage(storageType).getItem(fullKey) : null;
if (json) {
set(<T>serializer.parse(json));
}
if (browser) {
const handleStorage = (event: StorageEvent) => {
if (event.key === fullKey) {
set(event.newValue ? serializer.parse(event.newValue) : null);
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}
});
const { subscribe, set } = store;
stores[fullKey] = {
set(value: T) {
updateStorage(fullKey, value);
set(value);
},
update(updater: Updater<T>) {
const value = updater(get(store));
updateStorage(fullKey, value);
set(value);
},
subscribe
};
}
return stores[fullKey];
}
export function toReadable<T>(store: Writable<T>): Readable<T> {
return {
subscribe: store.subscribe
};
}
@waffleflopper
Copy link
Author

I went ahead and added the dark/light implementation (including the localStorage store), both of which are sourced from OSS which is linked in the appropriate gist files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment