feat: basic song ui + settings

This commit is contained in:
2024-11-24 00:11:36 +01:00
parent 2c9051a265
commit 7ae47d02f2
99 changed files with 3680 additions and 64 deletions

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { page } from '$app/stores';
import * as Sidebar from '$lib/components/ui/sidebar';
import { Home, Library, ListMusic, Music2, Settings } from 'lucide-svelte';
</script>
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/'} class="transition-all">
{#snippet child({ props })}
<a href="/" {...props}>
<Home />
<span>Home</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Library</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/songs'} class="transition-all">
{#snippet child({ props })}
<a href="/songs" {...props}>
<Music2 />
<span>Songs</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/albums'} class="transition-all">
{#snippet child({ props })}
<a href="/albums" {...props}>
<Library />
<span>Albums</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/playlists'} class="transition-all">
{#snippet child({ props })}
<a href="/playlists" {...props}>
<ListMusic />
<span>Playlists</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/settings'} class="transition-all">
{#snippet child({ props })}
<a href="/settings" {...props}>
<Settings />
<span>Settings</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Footer>
</Sidebar.Root>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import {
Play,
Pause,
SkipBack,
SkipForward,
VolumeX,
Volume1,
Volume2,
List
} from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { Progress } from '$lib/components/ui/progress';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { Separator } from '$lib/components/ui/separator';
import { currentlyPlaying, volume } from '$lib/stores/player';
import { enhance } from '$app/forms';
dayjs.extend(duration);
let progressValue = $state(0);
let isPlaying = $state(false);
/* async function seekProgressValue(e: MouseEvent) {
if (!$currentlyPlaying) {
return;
}
const target = e.currentTarget! as HTMLElement;
const _targetProgress = Math.max(0.0, Math.min(1.0, e.offsetX / target.offsetWidth));
} */
async function skipToQueueIndex(index: number) {
console.log(`SKIP TO QUEUE INDEX ${index}`);
}
let volumeValue = {
get value() {
return [$volume];
},
set value(v: number[]) {
$volume = v[0];
// TODO: Send message to player
}
};
</script>
<footer class="border border-x-0 border-t border-border/40 bg-background/95 px-4">
<nav class="my-2 grid w-full grid-cols-5 items-center">
<div class="col-span-1 flex items-end gap-2">
{#if $currentlyPlaying}
<img
class="w-16 rounded-md shadow-2xl shadow-primary/50"
src="https://i.scdn.co/image/ab67616d0000b2732c0ead8ce0dd1c6e2fca817f"
alt={$currentlyPlaying}
/>
<div class="space-y-1 text-sm">
<h3 class="font-medium leading-none">Song name</h3>
<p class="text-xs text-muted-foreground">Artist name</p>
</div>
{:else}
<div class="h-16 w-16 rounded-md bg-muted shadow-2xl shadow-primary/50"></div>
{/if}
</div>
<form class="col-span-3 flex justify-center gap-1" method="POST" use:enhance>
<Button variant="outline" size="icon">
<SkipBack />
</Button>
<Button type="submit" formaction="/player?/pause" variant="outline" size="icon">
{#if isPlaying}
<Pause />
{:else}
<Play />
{/if}
</Button>
<Button variant="outline" size="icon">
<SkipForward />
</Button>
</form>
<div class="col-span-1 flex items-center">
<Popover.Root>
<Popover.Trigger class="mr-8 flex items-center text-sm">
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm">
<List class="mr-2 size-4" />
<span>15</span>
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="flex max-h-[max(50vh,300px)] w-64 flex-col overflow-y-hidden">
<p class="font-semibold">Next up</p>
<Separator class="my-1" />
<div class="flex h-full flex-col gap-1 overflow-y-auto pr-3">
{#each [] as _song, i}
<button
class="flex flex-row items-center gap-2 rounded-lg px-3 py-2 transition-all hover:bg-secondary"
onclick={() => skipToQueueIndex(i)}
>
<div class="min-w-8 overflow-hidden rounded-md">
<img
src="https://i.scdn.co/image/ab67616d0000b2732c0ead8ce0dd1c6e2fca817f"
class="aspect-square size-8"
alt="Cover"
/>
</div>
<div class="flex flex-col overflow-hidden">
<p
class="w-full self-start overflow-hidden text-ellipsis text-nowrap text-left text-sm text-foreground/80"
>
Song name
</p>
<p
class="w-full self-start overflow-hidden text-left text-xs text-muted-foreground"
>
Song artist
</p>
</div>
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
<div class="flex grow flex-row items-center gap-4">
<div class="min-w-6 text-muted-foreground">
{#if $volume <= 0}
<VolumeX class="size-full" />
{:else if $volume < 50.0}
<Volume1 class="size-full" />
{:else}
<Volume2 class="size-full" />
{/if}
</div>
<div class="flex-1">
<Slider class="w-full" min={0} max={1} step={0.01} bind:value={volumeValue.value} />
</div>
<div class="min-w-10 self-center">
<p class="text-sm text-muted-foreground">
{Math.round($volume * 100.0)}%
</p>
</div>
</div>
</div>
</nav>
<div class="mb-2 flex flex-row items-center gap-2">
<span class="w-12 text-left text-sm">{dayjs.duration(0, 'seconds').format('mm:ss')}</span>
<button class="w-full">
{#key progressValue}
<Progress class="pointer-events-none w-full" value={progressValue} max={100} />
{/key}
</button>
<span class="w-12 text-right text-sm">{dayjs.duration(0, 'seconds').format('mm:ss')}</span>
</div>
</footer>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { getCoverUrl } from '$lib/covers';
// import { AudioLines } from 'lucide-svelte';
import type { Song } from '$lib/song';
interface Props {
song: Song;
}
let { song }: Props = $props();
async function playSong(e: MouseEvent) {
e.preventDefault();
// TODO: Play song
}
</script>
<form class="flex flex-col gap-3" method="POST" action="/songs/{song.hash}?/play" use:enhance>
<div class="relative">
<button type="submit" class="relative overflow-hidden rounded-lg" oncontextmenu={playSong}>
<img
class="aspect-square w-auto transition-all duration-150 hover:scale-105 hover:saturate-150"
src={getCoverUrl(song.hash)}
alt={song.name}
/>
</button>
<!-- <div
class="absolute bottom-6 left-2 size-4 animate-pulse"
class:hidden={$currentlyPlaying?.entry.hash !== song.entry.hash}
>
<AudioLines class="text-primary" />
</div> -->
</div>
<div class="relative space-y-1 text-sm">
<h3 class="font-medium leading-none">{song.name}</h3>
<p class="text-xs text-muted-foreground">{song.artistName}</p>
</div>
</form>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from 'lucide-svelte';
import { toggleMode, mode } from 'mode-watcher';
</script>
<Button variant="outline" size="icon" onclick={toggleMode}>
{#if $mode === 'light'}
<Sun />
{:else}
<Moon />
{/if}
</Button>

View File

@@ -0,0 +1,69 @@
<script lang="ts" module>
import type { WithElementRef } from 'bits-ui';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: 'focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
outline:
'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a bind:this={ref} class={cn(buttonVariants({ variant, size, className }))} {href} {...restProps}>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size, className }))}
{type}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import Check from 'lucide-svelte/icons/check';
import Minus from 'lucide-svelte/icons/minus';
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
indeterminate = $bindable(false),
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<Minus class="size-4" />
{:else}
<Check class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: ContextMenuPrimitive.PortalProps;
} = $props();
</script>
<ContextMenuPrimitive.Portal {...portalProps}>
<ContextMenuPrimitive.Content
class={cn(
'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none',
className
)}
{...restProps}
bind:ref
/>
</ContextMenuPrimitive.Portal>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
class={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
{...restProps}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.Item
class={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
bind:ref
{...restProps}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import Circle from 'lucide-svelte/icons/circle';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<Circle class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
class={cn('-mx-1 my-1 h-px bg-border', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
class={cn(
'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
inset,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
class={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRight class="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,49 @@
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import Item from './context-menu-item.svelte';
import GroupHeading from './context-menu-group-heading.svelte';
import Content from './context-menu-content.svelte';
import Shortcut from './context-menu-shortcut.svelte';
import RadioItem from './context-menu-radio-item.svelte';
import Separator from './context-menu-separator.svelte';
import SubContent from './context-menu-sub-content.svelte';
import SubTrigger from './context-menu-sub-trigger.svelte';
import CheckboxItem from './context-menu-checkbox-item.svelte';
const Sub: typeof ContextMenuPrimitive.Sub = ContextMenuPrimitive.Sub;
const Root: typeof ContextMenuPrimitive.Root = ContextMenuPrimitive.Root;
const Trigger: typeof ContextMenuPrimitive.Trigger = ContextMenuPrimitive.Trigger;
const Group: typeof ContextMenuPrimitive.Group = ContextMenuPrimitive.Group;
const RadioGroup: typeof ContextMenuPrimitive.RadioGroup = ContextMenuPrimitive.RadioGroup;
export {
Sub,
Root,
Item,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
GroupHeading,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Item as ContextMenuItem,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
GroupHeading as ContextMenuGroupHeading,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem
};

View File

@@ -0,0 +1,7 @@
import Root from './input.svelte';
export {
Root,
//
Root as Input
};

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithElementRef<HTMLInputAttributes> = $props();
</script>
<input
bind:this={ref}
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from './popover-content.svelte';
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose
};

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
align = 'center',
sideOffset = 4,
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
{align}
{sideOffset}
class={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

@@ -0,0 +1,7 @@
import Root from './progress.svelte';
export {
Root,
//
Root as Progress
};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Progress as ProgressPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
max = 100,
value,
...restProps
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
</script>
<ProgressPrimitive.Root
bind:ref
{value}
class={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
{...restProps}
>
<div
class="h-full w-full flex-1 bg-primary transition-all"
style={`transform: translateX(-${100 - (100 * (value ?? 0)) / (max ?? 1)}%)`}
></div>
</ProgressPrimitive.Root>

View File

@@ -0,0 +1,10 @@
import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive, type WithoutChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
{orientation}
class={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 w-full border-t border-t-transparent p-px',
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
class={cn('relative rounded-full bg-border', orientation === 'vertical' && 'flex-1')}
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive, type WithoutChild } from 'bits-ui';
import { Scrollbar } from './index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
scrollbarXClasses = '',
scrollbarYClasses = '',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
} = $props();
</script>
<ScrollAreaPrimitive.Root bind:ref {...restProps} class={cn('relative overflow-hidden', className)}>
<ScrollAreaPrimitive.Viewport class="h-full w-full rounded-[inherit]">
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View File

@@ -0,0 +1,7 @@
import Root from './separator.svelte';
export {
Root,
//
Root as Separator
};

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
orientation = 'horizontal',
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
class={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'min-h-full w-[1px]',
className
)}
{orientation}
{...restProps}
/>

View File

@@ -0,0 +1,37 @@
import { Dialog as SheetPrimitive } from 'bits-ui';
import Overlay from './sheet-overlay.svelte';
import Content from './sheet-content.svelte';
import Header from './sheet-header.svelte';
import Footer from './sheet-footer.svelte';
import Title from './sheet-title.svelte';
import Description from './sheet-description.svelte';
const Root = SheetPrimitive.Root;
const Close = SheetPrimitive.Close;
const Trigger = SheetPrimitive.Trigger;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription
};

View File

@@ -0,0 +1,56 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const sheetVariants = tv({
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 gap-4 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
variants: {
side: {
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b',
bottom:
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t',
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
right:
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm'
}
},
defaultVariants: {
side: 'right'
}
});
export type Side = VariantProps<typeof sheetVariants>['side'];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import type { Snippet } from 'svelte';
import SheetOverlay from './sheet-overlay.svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
side = 'right',
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content bind:ref class={cn(sheetVariants({ side }), className)} {...restProps}>
{@render children?.()}
<SheetPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
class={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
class={cn('text-lg font-semibold text-foreground', className)}
{...restProps}
/>

View File

@@ -0,0 +1,6 @@
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';

View File

@@ -0,0 +1,79 @@
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { getContext, setContext } from 'svelte';
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
type Getter<T> = () => T;
export type SidebarStateProps = {
/**
* A getter function that returns the current open state of the sidebar.
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
* component.
*/
open: Getter<boolean>;
/**
* A function that sets the open state of the sidebar. To support `bind:open`, we need
* a source of truth for changing the open state to ensure it will be synced throughout
* the sub-components and any `bind:` references.
*/
setOpen: (open: boolean) => void;
};
class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
setOpen: SidebarStateProps['setOpen'];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
constructor(props: SidebarStateProps) {
this.setOpen = props.setOpen;
this.#isMobile = new IsMobile();
this.props = props;
}
// Convenience getter for checking if the sidebar is mobile
// without this, we would need to use `sidebar.isMobile.current` everywhere
get isMobile() {
return this.#isMobile.current;
}
// Event handler to apply to the `<svelte:window>`
handleShortcutKeydown = (e: KeyboardEvent) => {
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
}
};
setOpenMobile = (value: boolean) => {
this.openMobile = value;
};
toggle = () => {
return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open);
};
}
const SYMBOL_KEY = 'scn-sidebar';
/**
* Instantiates a new `SidebarState` instance and sets it in the context.
*
* @param props The constructor props for the `SidebarState` class.
* @returns The `SidebarState` instance.
*/
export function setSidebar(props: SidebarStateProps): SidebarState {
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
}
/**
* Retrieves the `SidebarState` instance from the context. This is a class instance,
* so you cannot destructure it.
* @returns The `SidebarState` instance.
*/
export function useSidebar(): SidebarState {
return getContext(Symbol.for(SYMBOL_KEY));
}

View File

@@ -0,0 +1,75 @@
import { useSidebar } from './context.svelte.js';
import Content from './sidebar-content.svelte';
import Footer from './sidebar-footer.svelte';
import GroupAction from './sidebar-group-action.svelte';
import GroupContent from './sidebar-group-content.svelte';
import GroupLabel from './sidebar-group-label.svelte';
import Group from './sidebar-group.svelte';
import Header from './sidebar-header.svelte';
import Input from './sidebar-input.svelte';
import Inset from './sidebar-inset.svelte';
import MenuAction from './sidebar-menu-action.svelte';
import MenuBadge from './sidebar-menu-badge.svelte';
import MenuButton from './sidebar-menu-button.svelte';
import MenuItem from './sidebar-menu-item.svelte';
import MenuSkeleton from './sidebar-menu-skeleton.svelte';
import MenuSubButton from './sidebar-menu-sub-button.svelte';
import MenuSubItem from './sidebar-menu-sub-item.svelte';
import MenuSub from './sidebar-menu-sub.svelte';
import Menu from './sidebar-menu.svelte';
import Provider from './sidebar-provider.svelte';
import Rail from './sidebar-rail.svelte';
import Separator from './sidebar-separator.svelte';
import Trigger from './sidebar-trigger.svelte';
import Root from './sidebar.svelte';
export {
Content,
Footer,
Group,
GroupAction,
GroupContent,
GroupLabel,
Header,
Input,
Inset,
Menu,
MenuAction,
MenuBadge,
MenuButton,
MenuItem,
MenuSkeleton,
MenuSub,
MenuSubButton,
MenuSubItem,
Provider,
Rail,
Root,
Separator,
//
Root as Sidebar,
Content as SidebarContent,
Footer as SidebarFooter,
Group as SidebarGroup,
GroupAction as SidebarGroupAction,
GroupContent as SidebarGroupContent,
GroupLabel as SidebarGroupLabel,
Header as SidebarHeader,
Input as SidebarInput,
Inset as SidebarInset,
Menu as SidebarMenu,
MenuAction as SidebarMenuAction,
MenuBadge as SidebarMenuBadge,
MenuButton as SidebarMenuButton,
MenuItem as SidebarMenuItem,
MenuSkeleton as SidebarMenuSkeleton,
MenuSub as SidebarMenuSub,
MenuSubButton as SidebarMenuSubButton,
MenuSubItem as SidebarMenuSubItem,
Provider as SidebarProvider,
Rail as SidebarRail,
Separator as SidebarSeparator,
Trigger as SidebarTrigger,
Trigger,
useSidebar
};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="content"
class={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="footer"
class={cn('flex flex-col gap-2 p-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const propObj = $derived({
class: cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden',
className
),
'data-sidebar': 'group-action',
...restProps
});
</script>
{#if child}
{@render child({ props: propObj })}
{:else}
<button bind:this={ref} {...propObj}>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="group-content"
class={cn('w-full text-sm', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
children,
child,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
),
'data-sidebar': 'group-label',
...restProps
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render children?.()}
</div>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="group"
class={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="header"
class={cn('flex flex-col gap-2 p-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
value = $bindable(''),
class: className,
...restProps
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
bind:value
data-sidebar="input"
class={cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<main
bind:this={ref}
class={cn(
'relative flex min-h-svh flex-1 flex-col bg-background',
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
className
)}
{...restProps}
>
{@render children?.()}
</main>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
showOnHover = false,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
showOnHover?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 after:md:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className
),
'data-sidebar': 'menu-action',
...restProps
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="menu-badge"
class={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,95 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const sidebarMenuButtonVariants = tv({
base: 'peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]'
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type SidebarMenuButtonVariant = VariantProps<typeof sidebarMenuButtonVariants>['variant'];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>['size'];
</script>
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn } from '$lib/utils.js';
import { mergeProps, type WithElementRef, type WithoutChildrenOrChild } from 'bits-ui';
import type { ComponentProps, Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
child,
variant = 'default',
size = 'default',
isActive = false,
tooltipContent,
tooltipContentProps,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
isActive?: boolean;
variant?: SidebarMenuButtonVariant;
size?: SidebarMenuButtonSize;
tooltipContent?: Snippet;
tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const sidebar = useSidebar();
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
'data-sidebar': 'menu-button',
'data-size': size,
'data-active': isActive,
...restProps
});
</script>
{#snippet Button({ props }: { props?: Record<string, unknown> })}
{@const mergedProps = mergeProps(buttonProps, props)}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
{/snippet}
{#if !tooltipContent}
{@render Button({})}
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render Button({ props })}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
children={tooltipContent}
{...tooltipContentProps}
/>
</Tooltip.Root>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
</script>
<li
bind:this={ref}
data-sidebar="menu-item"
class={cn('group/menu-item relative', className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
showIcon = false,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
showIcon?: boolean;
} = $props();
// Random width between 50% and 90%
const width = `${Math.floor(Math.random() * 40) + 50}%`;
</script>
<div
bind:this={ref}
data-sidebar="menu-skeleton"
class={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...restProps}
>
{#if showIcon}
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
{/if}
<Skeleton
class="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style="--skeleton-width: {width};"
/>
{@render children?.()}
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
children,
child,
class: className,
size = 'md',
isActive,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
size?: 'sm' | 'md';
isActive?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
),
'data-sidebar': 'menu-sub-button',
'data-size': size,
'data-active': isActive,
...restProps
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a bind:this={ref} {...mergedProps}>
{@render children?.()}
</a>
{/if}

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
</script>
<li bind:this={ref} data-sidebar="menu-sub-item" {...restProps}>
{@render children?.()}
</li>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-sidebar="menu-sub"
class={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...restProps}
>
{@render children?.()}
</ul>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
</script>
<ul
bind:this={ref}
data-sidebar="menu"
class={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...restProps}
>
{@render children?.()}
</ul>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON
} from './constants.js';
import { setSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
open = $bindable(true),
onOpenChange = () => {},
controlledOpen = false,
class: className,
style,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
controlledOpen?: boolean;
} = $props();
const sidebar = setSidebar({
open: () => open,
setOpen: (value: boolean) => {
if (controlledOpen) {
onOpenChange(value);
} else {
open = value;
onOpenChange(value);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}
});
</script>
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
</Tooltip.Provider>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onclick={() => sidebar.toggle()}
title="Toggle Sidebar"
class={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...restProps}
>
{@render children?.()}
</button>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-sidebar="separator"
class={cn('mx-2 w-auto bg-sidebar-border', className)}
{...restProps}
/>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import PanelLeft from 'lucide-svelte/icons/panel-left';
import type { ComponentProps } from 'svelte';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
onclick,
...restProps
}: ComponentProps<typeof Button> & {
onclick?: (e: MouseEvent) => void;
} = $props();
const sidebar = useSidebar();
</script>
<Button
type="button"
onclick={(e) => {
onclick?.(e);
sidebar.toggle();
}}
data-sidebar="trigger"
variant="ghost"
size="icon"
class={cn('h-7 w-7', className)}
{...restProps}
>
<PanelLeft />
<span class="sr-only">Toggle Sidebar</span>
</Button>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
} = $props();
const sidebar = useSidebar();
</script>
{#if collapsible === 'none'}
<div
class={cn(
'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet.Root
controlledOpen
open={sidebar.openMobile}
onOpenChange={sidebar.setOpenMobile}
{...restProps}
>
<Sheet.Content
data-sidebar="sidebar"
data-mobile="true"
class="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<div
bind:this={ref}
class="group peer hidden text-sidebar-foreground md:block"
data-state={sidebar.state}
data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
>
<!-- This is what handles the sidebar gap on desktop -->
<div
class={cn(
'relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
)}
></div>
<div
class={cn(
'fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...restProps}
>
<div
data-sidebar="sidebar"
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{@render children?.()}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from './skeleton.svelte';
export {
Root,
//
Root as Skeleton
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { WithElementRef, WithoutChildren } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
class={cn('animate-pulse rounded-md bg-primary/10', className)}
{...restProps}
></div>

View File

@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Slider as SliderPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value = $bindable([0]),
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
export { className as class };
</script>
<SliderPrimitive.Root
bind:ref
bind:value
class={cn('relative flex w-full touch-none select-none items-center', className)}
{...restProps}
>
{#snippet children({ thumbs })}
<span class="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range class="absolute h-full bg-primary" />
</span>
{#each thumbs as thumb}
<SliderPrimitive.Thumb
index={thumb}
class="block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>

View File

@@ -0,0 +1,18 @@
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import Content from './tooltip-content.svelte';
const Root = TooltipPrimitive.Root;
const Trigger = TooltipPrimitive.Trigger;
const Provider = TooltipPrimitive.Provider;
export {
Root,
Trigger,
Content,
Provider,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
...restProps
}: TooltipPrimitive.ContentProps = $props();
</script>
<TooltipPrimitive.Content
bind:ref
{sideOffset}
class={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...restProps}
/>