Categorize components/ui into primitives, overlay, media, forms and layout
Splits the flat 38-component ui/ folder into primitives/ (generic controls), overlay/ (menus/modals), media/ (music-domain display components), forms/ (composite entity forms) and layout/ (page scaffolding), and updates every import path accordingly.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
tone?: 'neutral' | 'danger';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { tone = 'neutral', class: klass = '', children }: Props = $props();
|
||||
|
||||
const tones = {
|
||||
neutral: 'border-line bg-surface text-fg-2',
|
||||
danger: 'border-danger/20 bg-danger/10 text-danger-fg'
|
||||
};
|
||||
</script>
|
||||
|
||||
<p class="rounded-control border px-4 py-3 text-sm backdrop-blur-md {tones[tone]} {klass}">
|
||||
{@render children()}
|
||||
</p>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import { previousPage } from '$lib/state/navigation.svelte';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
let { href, label }: Props = $props();
|
||||
|
||||
const previous = $derived(previousPage());
|
||||
const target = $derived(previous ?? { url: href, label });
|
||||
|
||||
function onclick(event: MouseEvent) {
|
||||
if (!previous) return;
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
||||
return;
|
||||
event.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={target.url}
|
||||
{onclick}
|
||||
class="inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
||||
>
|
||||
<span class="grid size-8 place-items-center">
|
||||
<ArrowLeft class="size-icon-sm" />
|
||||
</span>
|
||||
{target.label}
|
||||
</a>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'glass' | 'accent' | 'strong' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit';
|
||||
href?: string;
|
||||
reload?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
type = 'button',
|
||||
href,
|
||||
reload = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
onclick,
|
||||
class: klass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-4 py-2.5 text-xs',
|
||||
md: 'px-5 py-3 text-sm',
|
||||
lg: 'px-6 py-3.5 text-sm'
|
||||
};
|
||||
|
||||
const subtle = 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg';
|
||||
const glass = 'font-normal bg-btn-glass text-fg-2 hover:bg-btn-glass-hover hover:text-fg';
|
||||
|
||||
const variants = {
|
||||
primary: subtle,
|
||||
secondary: subtle,
|
||||
glass,
|
||||
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
|
||||
strong: 'bg-cta-strong text-ink hover:brightness-95',
|
||||
danger: 'bg-danger text-on-art hover:brightness-110'
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
`inline-flex items-center justify-center gap-2 rounded-control transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} disabled={disabled || loading} {onclick} class={classes}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
name?: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
|
||||
<span class="relative inline-grid size-5 shrink-0 place-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{name}
|
||||
bind:checked
|
||||
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
||||
/>
|
||||
<Check
|
||||
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</span>
|
||||
<span class="cursor-pointer">{@render children()}</span>
|
||||
</label>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
selected?: boolean;
|
||||
count?: number;
|
||||
href?: string;
|
||||
onclick?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { selected = false, count, href, onclick, children }: Props = $props();
|
||||
|
||||
const classes = $derived(
|
||||
href
|
||||
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
|
||||
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
|
||||
selected
|
||||
? 'bg-cta-strong text-ink'
|
||||
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'
|
||||
}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
{@render children()}
|
||||
{#if count !== undefined}
|
||||
<span class="tabular-nums {selected ? 'opacity-55' : 'text-muted'}">{count}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a {href} class={classes}>{@render content()}</a>
|
||||
{:else}
|
||||
<button type="button" {onclick} class={classes}>{@render content()}</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
||||
{#if Icon}
|
||||
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
|
||||
{/if}
|
||||
{#if title}
|
||||
<h3 class="mt-6 text-display-3 text-fg">
|
||||
{title}
|
||||
</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-2.5 max-w-sm text-sm leading-relaxed text-fg-2">{description}</p>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
for?: string;
|
||||
class?: string;
|
||||
hint?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { label, for: forId, class: klass = '', hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col {klass}">
|
||||
{#if label}
|
||||
<label for={forId} class="mb-2 block text-sm font-medium text-fg-2">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
||||
{@render hint()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
type?: 'button' | 'submit';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
shape?: 'square' | 'round';
|
||||
tone?: 'muted' | 'subtle' | 'plain';
|
||||
pressed?: boolean;
|
||||
surface?: boolean;
|
||||
revealOnHover?: boolean;
|
||||
href?: string;
|
||||
reload?: boolean;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
type = 'button',
|
||||
size = 'sm',
|
||||
shape = 'square',
|
||||
tone = 'muted',
|
||||
pressed,
|
||||
surface = false,
|
||||
revealOnHover = false,
|
||||
href,
|
||||
reload = false,
|
||||
onclick,
|
||||
class: klass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const SIZE: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
|
||||
xs: 'size-7',
|
||||
sm: 'size-8',
|
||||
md: 'size-9',
|
||||
lg: 'size-10'
|
||||
};
|
||||
|
||||
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
|
||||
muted: 'text-muted hover:text-fg',
|
||||
subtle: 'text-fg-2 hover:text-fg',
|
||||
plain: 'text-fg-2 hover:text-fg'
|
||||
};
|
||||
|
||||
const background = $derived(
|
||||
surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover'
|
||||
);
|
||||
|
||||
const classes = $derived(
|
||||
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : TEXT[tone]} ${background} ${
|
||||
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
|
||||
} ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
data-sveltekit-reload={reload ? '' : undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
class={classes}
|
||||
>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} {onclick} aria-label={label} title={label} aria-pressed={pressed} class={classes}>
|
||||
{@render children()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
|
||||
|
||||
interface Props {
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
let { onLoadMore, hasMore, loading, rootMargin = INFINITE_SCROLL_ROOT_MARGIN }: Props = $props();
|
||||
|
||||
let sentinel = $state<HTMLDivElement>();
|
||||
let visible = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const node = sentinel;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
visible = entries[0].isIntersecting;
|
||||
},
|
||||
{ rootMargin }
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (visible && hasMore && !loading) onLoadMore();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
||||
{#if loading}
|
||||
<LoaderCircle class="size-5 animate-spin text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'text' | 'search' | 'number';
|
||||
min?: number;
|
||||
max?: number;
|
||||
placeholder?: string;
|
||||
maxlength?: number;
|
||||
required?: boolean;
|
||||
autocomplete?: 'on' | 'off';
|
||||
icon?: LucideIcon;
|
||||
oninput?: (event: Event) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
id,
|
||||
name,
|
||||
type = 'text',
|
||||
min,
|
||||
max,
|
||||
placeholder,
|
||||
maxlength,
|
||||
required = false,
|
||||
autocomplete = 'off',
|
||||
icon: Icon,
|
||||
oninput,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full">
|
||||
{#if Icon}
|
||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
|
||||
<Icon class="size-icon-lg" />
|
||||
</span>
|
||||
{/if}
|
||||
<input
|
||||
{id}
|
||||
{name}
|
||||
{type}
|
||||
{min}
|
||||
{max}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
{required}
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
bind:value
|
||||
class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: 'sm' | 'lg';
|
||||
}
|
||||
|
||||
let { size = 'sm' }: Props = $props();
|
||||
|
||||
const MARK: Record<'sm' | 'lg', string> = {
|
||||
sm: 'size-5.5 rounded-thumb',
|
||||
lg: 'size-8 rounded-control'
|
||||
};
|
||||
|
||||
const TEXT: Record<'sm' | 'lg', string> = {
|
||||
sm: 'font-display text-lg font-semibold tracking-tight text-fg',
|
||||
lg: 'font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="{MARK[size]} bg-[image:var(--mf-logo-grad)]"></span>
|
||||
<span class={TEXT[size]}>Musify</span>
|
||||
</span>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" generics="T extends string">
|
||||
interface Option<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: Option<T>[];
|
||||
value: T;
|
||||
}
|
||||
|
||||
let { options, value = $bindable() }: Props = $props();
|
||||
|
||||
const index = $derived(options.findIndex((option) => option.value === value));
|
||||
</script>
|
||||
|
||||
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
|
||||
<div
|
||||
class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-300 ease-out"
|
||||
style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))"
|
||||
></div>
|
||||
{#each options as option (option.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (value = option.value)}
|
||||
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
|
||||
option.value
|
||||
? 'text-ink'
|
||||
: 'text-muted hover:text-fg-2'}"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
oninput: (value: number) => void;
|
||||
variant: 'seek' | 'volume';
|
||||
}
|
||||
|
||||
let { value, max, label, oninput, variant }: Props = $props();
|
||||
|
||||
const percent = $derived(max > 0 ? (value / max) * 100 : 0);
|
||||
|
||||
function handleInput(event: Event) {
|
||||
oninput(Number((event.currentTarget as HTMLInputElement).value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
{max}
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
aria-label={label}
|
||||
class="mf-slider mf-slider-{variant}"
|
||||
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)"
|
||||
/>
|
||||
|
||||
<style>
|
||||
.mf-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: var(--mf-range-h);
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-slider-seek {
|
||||
width: 100%;
|
||||
}
|
||||
.mf-slider-volume {
|
||||
width: var(--mf-volume-w);
|
||||
}
|
||||
.mf-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--mf-range-thumb);
|
||||
height: var(--mf-range-thumb);
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-slider::-moz-range-thumb {
|
||||
width: var(--mf-range-thumb);
|
||||
height: var(--mf-range-thumb);
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
padding?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { padding = 'md', class: klass = '', children }: Props = $props();
|
||||
|
||||
const paddings = { sm: 'p-4', md: 'p-5 sm:p-6', lg: 'p-6 sm:p-12' };
|
||||
</script>
|
||||
|
||||
<div class="rounded-panel bg-surface transition {paddings[padding]} {klass}">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
maxlength?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
maxlength,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
{id}
|
||||
{name}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
bind:value
|
||||
class="min-h-0 field-control resize-none px-4 {klass}"></textarea>
|
||||
Reference in New Issue
Block a user