refactor(ui): update shared components and add ControlGroup, SidebarContainer

This commit is contained in:
Ilia Mashkov
2026-03-02 22:19:35 +03:00
parent 13818d5844
commit 0dd08874bc
33 changed files with 927 additions and 203 deletions

View File

@@ -9,6 +9,7 @@ import {
labelSizeConfig,
} from '$shared/ui/Label/config';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
@@ -20,12 +21,29 @@ const badgeVariantConfig: Record<BadgeVariant, string> = {
info: 'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400',
};
interface Props {
interface Props extends HTMLAttributes<HTMLSpanElement> {
/**
* Visual variant
* @default 'default'
*/
variant?: BadgeVariant;
/**
* Badge size
* @default 'xs'
*/
size?: LabelSize;
/** Renders a small filled circle before the text. */
/**
* Show status dot
* @default false
*/
dot?: boolean;
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}
@@ -35,6 +53,7 @@ let {
dot = false,
children,
class: className,
...rest
}: Props = $props();
</script>
@@ -46,6 +65,7 @@ let {
badgeVariantConfig[variant],
className,
)}
{...rest}
>
{#if dot}
<span class="w-1 h-1 rounded-full bg-current"></span>

View File

@@ -13,18 +13,43 @@ import type {
} from './types';
interface Props extends HTMLButtonAttributes {
/**
* Visual style variant
* @default 'secondary'
*/
variant?: ButtonVariant;
/**
* Button size
* @default 'md'
*/
size?: ButtonSize;
/** Svelte snippet rendered as the icon. */
/**
* Icon snippet
*/
icon?: Snippet;
/**
* Icon placement
* @default 'left'
*/
iconPosition?: IconPosition;
/**
* Active toggle state
* @default false
*/
active?: boolean;
/**
* When true (default), adds `active:scale-[0.97]` on tap via CSS.
* Primary variant is excluded from scale — it shifts via translate instead.
* Tap animation
* Primary uses translate, others use scale
* @default true
*/
animate?: boolean;
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}
@@ -45,7 +70,6 @@ let {
// Square sizing when icon is present but there is no text label
const isIconOnly = $derived(!!icon && !children);
// ── Variant base styles ──────────────────────────────────────────────────────
const variantStyles: Record<ButtonVariant, string> = {
primary: cn(
'bg-swiss-red text-white',
@@ -125,7 +149,6 @@ const variantStyles: Record<ButtonVariant, string> = {
),
};
// ── Size styles ───────────────────────────────────────────────────────────────
const sizeStyles: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-[9px] gap-1',
sm: 'h-8 px-3 text-[10px] gap-1.5',
@@ -143,12 +166,11 @@ const iconSizeStyles: Record<ButtonSize, string> = {
xl: 'h-14 w-14 p-3',
};
// ── Active state overrides (per variant) ─────────────────────────────────────
const activeStyles: Partial<Record<ButtonVariant, string>> = {
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
tertiary:
'bg-paper dark:bg-[#1e1e1e] border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
ghost: 'bg-transparent dark:bg-transparent text-brnad dark:text-brand',
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
outline: 'bg-surface dark:bg-paper border-brand',
icon: 'bg-paper dark:bg-paper text-brand border-black/5 dark:border-white/10',
};

View File

@@ -0,0 +1,91 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ButtonGroup from './ButtonGroup.svelte';
const { Story } = defineMeta({
title: 'Shared/ButtonGroup',
component: ButtonGroup,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Wraps buttons in a warm-surface pill with a 1px gap and subtle border. Use for segmented controls, view toggles, or any mutually exclusive button set.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
class: {
control: 'text',
description: 'Additional CSS classes',
},
},
});
</script>
<script lang="ts">
import { Button } from '$shared/ui/Button';
</script>
<Story name="Default">
{#snippet template(args)}
<ButtonGroup {...args}>
<Button variant="tertiary">Option 1</Button>
<Button variant="tertiary">Option 2</Button>
<Button variant="tertiary">Option 3</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story name="Horizontal">
{#snippet template(args)}
<ButtonGroup {...args}>
<Button variant="tertiary">Day</Button>
<Button variant="tertiary" active>Week</Button>
<Button variant="tertiary">Month</Button>
<Button variant="tertiary">Year</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story name="Vertical">
{#snippet template(args)}
<ButtonGroup {...args} class="flex-col">
<Button variant="tertiary">Top</Button>
<Button variant="tertiary" active>Middle</Button>
<Button variant="tertiary">Bottom</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story name="With Icons">
{#snippet template(args)}
<ButtonGroup {...args}>
<Button variant="tertiary">Grid</Button>
<Button variant="tertiary" active>List</Button>
<Button variant="tertiary">Map</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story
name="Dark Mode"
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<ButtonGroup {...args}>
<Button variant="tertiary">Option A</Button>
<Button variant="tertiary" active>Option B</Button>
<Button variant="tertiary">Option C</Button>
</ButtonGroup>
</div>
{/snippet}
</Story>

View File

@@ -9,7 +9,13 @@ import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}

View File

@@ -0,0 +1,148 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import IconButton from './IconButton.svelte';
const { Story } = defineMeta({
title: 'Shared/IconButton',
component: IconButton,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Icon-only button variant. Convenience wrapper that defaults variant to "icon" and enforces icon-only usage.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['icon', 'ghost', 'secondary'],
defaultValue: 'icon',
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
defaultValue: 'md',
},
active: {
control: 'boolean',
defaultValue: false,
},
animate: {
control: 'boolean',
defaultValue: true,
},
},
});
</script>
<script lang="ts">
import MoonIcon from '@lucide/svelte/icons/moon';
import SearchIcon from '@lucide/svelte/icons/search';
import TrashIcon from '@lucide/svelte/icons/trash-2';
</script>
<Story
name="Default"
args={{ variant: 'icon', size: 'md', active: false, animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<IconButton {...args}>
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} size="sm">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} size="lg">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
</div>
{/snippet}
</Story>
<Story
name="Variants"
args={{ size: 'md', active: false, animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<IconButton {...args} variant="icon">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="ghost">
{#snippet icon()}
<MoonIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="secondary">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
</div>
{/snippet}
</Story>
<Story
name="Active State"
args={{ size: 'md', animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<IconButton {...args} active={false} variant="icon">
{#snippet icon()}
<TrashIcon />
{/snippet}
</IconButton>
<IconButton {...args} active={true} variant="icon">
{#snippet icon()}
<TrashIcon />
{/snippet}
</IconButton>
</div>
{/snippet}
</Story>
<Story
name="Dark Mode"
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<div class="flex items-center gap-4">
<IconButton {...args} variant="icon">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="ghost">
{#snippet icon()}
<MoonIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="secondary">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
</div>
</div>
{/snippet}
</Story>

View File

@@ -12,6 +12,10 @@ import type { ButtonVariant } from './types';
type BaseProps = Exclude<ComponentProps<typeof Button>, 'children' | 'iconPosition'>;
interface Props extends BaseProps {
/**
* Visual variant
* @default 'icon'
*/
variant?: Extract<ButtonVariant, 'icon' | 'ghost' | 'secondary'>;
}

View File

@@ -0,0 +1,138 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ToggleButton from './ToggleButton.svelte';
const { Story } = defineMeta({
title: 'Shared/ToggleButton',
component: ToggleButton,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Toggle button with selected/active states. Accepts `selected` prop as alias for `active`, matching common toggle patterns.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'tertiary', 'outline', 'ghost'],
defaultValue: 'tertiary',
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
defaultValue: 'md',
},
selected: {
control: 'boolean',
description: 'Selected state (alias for active)',
},
active: {
control: 'boolean',
defaultValue: false,
},
animate: {
control: 'boolean',
defaultValue: true,
},
},
});
</script>
<script lang="ts">
let selected = $state(false);
</script>
<Story
name="Default"
args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }}
>
{#snippet template(args)}
<ToggleButton {...args}>Toggle Me</ToggleButton>
{/snippet}
</Story>
<Story
name="Selected/Unselected"
args={{ variant: 'tertiary', size: 'md', animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<ToggleButton {...args} selected={false}>
Unselected
</ToggleButton>
<ToggleButton {...args} selected={true}>
Selected
</ToggleButton>
</div>
{/snippet}
</Story>
<Story
name="Variants"
args={{ size: 'md', selected: true, animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<ToggleButton {...args} variant="primary">
Primary
</ToggleButton>
<ToggleButton {...args} variant="secondary">
Secondary
</ToggleButton>
<ToggleButton {...args} variant="tertiary">
Tertiary
</ToggleButton>
<ToggleButton {...args} variant="outline">
Outline
</ToggleButton>
<ToggleButton {...args} variant="ghost">
Ghost
</ToggleButton>
</div>
{/snippet}
</Story>
<Story
name="Interactive"
args={{ variant: 'tertiary', size: 'md', animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<ToggleButton {...args} selected={selected} onclick={() => selected = !selected}>
Click to toggle
</ToggleButton>
<span class="text-sm text-muted-foreground">Currently: {selected ? 'selected' : 'unselected'}</span>
</div>
{/snippet}
</Story>
<Story
name="Dark Mode"
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<div class="flex items-center gap-4">
<ToggleButton {...args} variant="primary" selected={true}>
Primary
</ToggleButton>
<ToggleButton {...args} variant="secondary" selected={false}>
Secondary
</ToggleButton>
<ToggleButton {...args} variant="tertiary" selected={false}>
Tertiary
</ToggleButton>
</div>
</div>
{/snippet}
</Story>

View File

@@ -10,12 +10,14 @@ import Button from './Button.svelte';
type BaseProps = ComponentProps<typeof Button>;
interface Props extends BaseProps {
/** Alias for `active`. Takes precedence if both are provided. */
/**
* Selected state alias for active
*/
selected?: boolean;
}
let {
variant = 'secondary',
variant = 'tertiary',
size = 'md',
icon,
iconPosition = 'left',

View File

@@ -18,12 +18,36 @@ import PlusIcon from '@lucide/svelte/icons/plus';
import TechText from '../TechText/TechText.svelte';
interface Props {
/**
* Typography control
*/
control: TypographyControl;
/**
* Control label
*/
label?: string;
/**
* CSS classes
*/
class?: string;
/**
* Reduced layout
* @default false
*/
reduced?: boolean;
/**
* Increase button label
* @default 'Increase'
*/
increaseLabel?: string;
/**
* Decrease button label
* @default 'Decrease'
*/
decreaseLabel?: string;
/**
* Control aria label
*/
controlLabel?: string;
}
@@ -39,10 +63,6 @@ let {
let open = $state(false);
function toggleOpen() {
open = !open;
}
// Smart value formatting matching the Figma design
const formattedValue = $derived(() => {
const v = control.value;
@@ -55,7 +75,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
</script>
<!--
── REDUCED MODE ────────────────────────────────────────────────────────────
REDUCED MODE
Inline slider + value. No buttons, no popover.
-->
{#if reduced}
@@ -85,12 +105,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<div class={cn('flex items-center px-1 relative', className)}>
<!-- Decrease button -->
<Button
variant="secondary"
variant="icon"
size="sm"
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
>
<MinusIcon class="size-3.5 stroke-2" />
{#snippet icon()}
<MinusIcon class="size-3.5 stroke-2" />
{/snippet}
</Button>
<!-- Trigger -->
@@ -152,12 +175,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<!-- Increase button -->
<Button
variant="secondary"
variant="icon"
size="sm"
onclick={control.increase}
disabled={control.isAtMax}
aria-label={increaseLabel}
>
<PlusIcon class="size-3.5 stroke-2" />
{#snippet icon()}
<PlusIcon class="size-3.5 stroke-2" />
{/snippet}
</Button>
</div>
{/if}

View File

@@ -5,19 +5,22 @@
<script lang="ts">
interface Props {
/**
* Visible text (bindable)
* Text content
*/
text: string;
/**
* Font size in pixels
* @default 48
*/
fontSize?: number;
/**
* Line height
* @default 1.2
*/
lineHeight?: number;
/**
* Letter spacing in pixels
* @default 0
*/
letterSpacing?: number;
}

View File

@@ -0,0 +1,32 @@
<!--
Component: ControlGroup
Labeled container for form controls
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
/**
* Group label
*/
label: string;
/**
* Content snippet
*/
children: Snippet;
/**
* CSS classes
*/
class?: string;
}
const { label, children, class: className }: Props = $props();
</script>
<div class={cn('flex flex-col gap-3 py-6 border-b border-black/5 dark:border-white/10 last:border-0', className)}>
<div class="flex justify-between items-center text-[0.6875rem] font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
{label}
</div>
{@render children?.()}
</div>

View File

@@ -6,7 +6,14 @@
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
/**
* Divider orientation
* @default 'horizontal'
*/
orientation?: 'horizontal' | 'vertical';
/**
* CSS classes
*/
class?: string;
}

View File

@@ -7,19 +7,43 @@ import type { Filter } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition';
import {
draw,
fly,
} from 'svelte/transition';
interface Props {
/** Label for this filter group (e.g., "Provider", "Tags") */
/**
* Group label
*/
displayedLabel: string;
/** Filter entity */
/**
* Filter entity
*/
filter: Filter;
/**
* CSS classes
*/
class?: string;
}
const { displayedLabel, filter, class: className }: Props = $props();
const MAX_DISPLAYED_OPTIONS = 10;
const hasMore = $derived(filter.properties.length > MAX_DISPLAYED_OPTIONS);
let showMore = $state(false);
let displayedProperties = $state(filter.properties.slice(0, MAX_DISPLAYED_OPTIONS));
$effect(() => {
if (showMore) {
displayedProperties = filter.properties;
} else {
displayedProperties = filter.properties.slice(0, MAX_DISPLAYED_OPTIONS);
}
});
</script>
{#snippet icon()}
@@ -55,17 +79,35 @@ const { displayedLabel, filter, class: className }: Props = $props();
</Label>
<div class="flex flex-col gap-1">
{#each filter.properties as property (property.id)}
<Button
variant="tertiary"
active={property.selected}
onclick={() => (property.selected = !property.selected)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right"
icon={property.selected ? icon : undefined}
>
<span>{property.name}</span>
</Button>
{#each displayedProperties as property (property.id)}
<div transition:fly={{ y: 20, duration: 200, easing: cubicOut }}>
<Button
variant="tertiary"
active={property.selected}
onclick={() => (property.selected = !property.selected)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right"
icon={property.selected ? icon : undefined}
>
<span>{property.name}</span>
</Button>
</div>
{/each}
{#if hasMore}
<Button
variant="icon"
onclick={() => (showMore = !showMore)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="left"
>
{#snippet icon()}
{#if showMore}
<ChevronUpIcon class="size-4" />
{:else}
<EllipsisIcon class="size-4" />
{/if}
{/snippet}
</Button>
{/if}
</div>
</div>

View File

@@ -7,10 +7,16 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
/**
* Custom render function for full control
* Custom render snippet
*/
render?: Snippet<[{ class: string }]>;
}

View File

@@ -15,29 +15,53 @@ import type {
} from './types';
interface Props extends Omit<HTMLInputAttributes, 'size'> {
/**
* Visual style variant
* @default 'default'
*/
variant?: InputVariant;
/**
* Input size
* @default 'md'
*/
size?: InputSize;
/** Marks the input as invalid — red border + ring, red helper text. */
/**
* Invalid state
*/
error?: boolean;
/** Helper / error message rendered below the input. */
/**
* Helper text
*/
helperText?: string;
/** Show an animated × button when the input has a value. */
/**
* Show clear button
* @default false
*/
showClearButton?: boolean;
/** Called when the clear button is clicked. */
/**
* Clear button callback
*/
onclear?: () => void;
/**
* Snippet for the left icon slot.
* Receives `size` as an argument for convenient icon sizing.
* @example {#snippet leftIcon(size)}<SearchIcon size={inputIconSize[size]} />{/snippet}
* Left icon snippet
*/
leftIcon?: Snippet<[InputSize]>;
/**
* Snippet for the right icon slot (rendered after the clear button).
* Receives `size` as an argument.
* Right icon snippet
*/
rightIcon?: Snippet<[InputSize]>;
/**
* Full width
* @default false
*/
fullWidth?: boolean;
/**
* Input value
*/
value?: string | number | readonly string[];
/**
* CSS classes
*/
class?: string;
}
@@ -56,15 +80,13 @@ let {
...rest
}: Props = $props();
// ── Size config ──────────────────────────────────────────────────────────────
const sizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
lg: { input: 'px-4 py-3', text: 'text-lg', height: 'h-12', clearIcon: 16 },
xl: { input: 'px-4 py-3', text: 'text-xl', height: 'h-14', clearIcon: 18 },
lg: { input: 'px-4 py-3', text: 'text-lg md:text-xl', height: 'h-12', clearIcon: 16 },
xl: { input: 'px-4 py-3', text: 'text-xl md:text-2xl', height: 'h-14', clearIcon: 18 },
};
// ── Variant config ───────────────────────────────────────────────────────────
const variantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
default: {
base: 'bg-paper dark:bg-paper border border-black/5 dark:border-white/10',

View File

@@ -13,13 +13,42 @@ import {
} from './config';
interface Props {
/**
* Visual variant
* @default 'default'
*/
variant?: LabelVariant;
/**
* Label size
* @default 'sm'
*/
size?: LabelSize;
/**
* Uppercase text
* @default true
*/
uppercase?: boolean;
/**
* Bold text
* @default false
*/
bold?: boolean;
/**
* Icon snippet
*/
icon?: Snippet;
/**
* Icon placement
* @default 'left'
*/
iconPosition?: 'left' | 'right';
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}

View File

@@ -11,12 +11,13 @@ export type LabelVariant =
| 'warning'
| 'error';
export type LabelSize = 'xs' | 'sm' | 'md';
export type LabelSize = 'xs' | 'sm' | 'md' | 'lg';
export const labelSizeConfig: Record<LabelSize, string> = {
xs: 'text-[0.5rem]',
sm: 'text-[0.5625rem] md:text-[0.625rem]',
md: 'text-[0.625rem] md:text-[0.6875rem]',
lg: 'text-[0.8rem] md:text-[0.875rem]',
};
export const labelVariantConfig: Record<LabelVariant, string> = {

View File

@@ -7,17 +7,17 @@ import { fade } from 'svelte/transition';
interface Props {
/**
* Icon size (in pixels)
* Icon size in pixels
* @default 20
*/
size?: number;
/**
* Additional classes for container
* CSS classes
*/
class?: string;
/**
* Message text
* @default analyzing_data
* Loading message
* @default 'analyzing_data'
*/
message?: string;
}

View File

@@ -4,42 +4,23 @@
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Badge } from '$shared/ui';
interface Props {
/**
* CSS classes
*/
class?: string;
}
const { class: className }: Props = $props();
const baseClasses = 'barlow font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl';
const title = 'GLYPHDIFF';
</script>
<!-- Firefox version (hidden in Chrome/Safari) -->
<h2
class={cn(
baseClasses,
'text-justify [text-align-last:justify] [text-justify:inter-character]',
// Hide in non-Firefox
'hidden [@supports(text-justify:inter-character)]:block',
className,
)}
>
{title}
</h2>
<!-- Chrome/Safari version (hidden in Firefox) -->
<h2
class={cn(
'flex justify-between w-full',
baseClasses,
// Hide in Firefox
'[@supports(text-justify:inter-character)]:hidden',
className,
)}
>
{#each title.split('') as letter}
<span>{letter}</span>
{/each}
</h2>
<div class={cn('flex items-center gap-2 md:gap-3 select-none', className)}>
<h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200">
{title}
</h1>
<Badge variant="accent" size="xs">BETA</Badge>
</div>

View File

@@ -14,20 +14,21 @@ interface Props {
*/
manager: PerspectiveManager;
/**
* Additional classes
* CSS classes
*/
class?: string;
/**
* Children
* Content snippet
*/
children: Snippet<[{ className?: string }]>;
/**
* Constrain plan to a horizontal region
* 'left' | 'right' | 'full' (default)
* Constrain region
* @default 'full'
*/
region?: 'left' | 'right' | 'full';
/**
* Width percentage when using left/right region (default 50)
* Region width percentage
* @default 50
*/
regionWidth?: number;
}

View File

@@ -3,66 +3,73 @@
Provides a container for a page widget with snippets for a title
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { type Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { HTMLAttributes } from 'svelte/elements';
import {
type FlyParams,
fly,
} from 'svelte/transition';
import SectionHeader from './SectionHeader/SectionHeader.svelte';
import SectionTitle from './SectionTitle/SectionTitle.svelte';
import type { TitleStatusChangeHandler } from './types';
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/**
* ID of the section
* Section ID
*/
id?: string;
/**
* Additional CSS classes to apply to the section container.
* CSS classes
*/
class?: string;
/**
* Title of the section
* Section title
*/
title: string;
/**
* Snippet for a title description
* Breadcrumb title
*/
breadcrumbTitle?: string;
/**
* Description snippet
*/
description?: Snippet<[{ className?: string }]>;
/**
* Header title
*/
headerTitle?: string;
/**
* Header subtitle
*/
headerSubtitle?: string;
/**
* Index of the section
* Header action callback
*/
headerAction?: (node: HTMLElement) => void;
/**
* Section index
*/
index?: number;
/**
* Callback function to notify when the title visibility status changes
*/
onTitleStatusChange?: TitleStatusChangeHandler;
/**
* Snippet for the section content
* Content snippet
*/
content?: Snippet<[{ className?: string }]>;
/**
* Snippet for the section header content
* Header content snippet
*/
headerContent?: Snippet<[{ className?: string }]>;
}
const {
let {
class: className,
title,
headerTitle,
headerSubtitle,
headerContent,
headerAction = () => {},
index = 0,
onTitleStatusChange,
id,
content,
headerContent,
}: Props = $props();
const flyParams: FlyParams = {
@@ -76,11 +83,14 @@ const flyParams: FlyParams = {
<section
{id}
class="w-full max-w-7xl mx-auto px-4 md:px-6 pb-32 md:pb-48"
class="w-full max-w-7xl mx-auto px-4 md:px-6 py-8 md:py-16 {className}"
in:fly={flyParams}
out:fly={flyParams}
>
<div class="flex flex-col md:flex-row md:items-end justify-between mb-8 md:mb-12 gap-4 md:gap-6">
<div
use:headerAction
class="flex flex-col md:flex-row md:items-end justify-between mb-8 md:mb-12 gap-4 md:gap-6"
>
<div>
{#if headerTitle}
<SectionHeader title={headerTitle} subtitle={headerSubtitle} index={index} />

View File

@@ -7,10 +7,26 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Label } from '$shared/ui';
interface Props {
/**
* Section index
*/
index: string | number;
/**
* Section title
*/
title: string;
/**
* Section subtitle
*/
subtitle?: string;
/**
* Pulse animation
* @default false
*/
pulse?: boolean;
/**
* CSS classes
*/
class?: string;
}

View File

@@ -6,6 +6,10 @@
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
/**
* CSS classes
* @default ''
*/
class?: string;
}
const { class: className = '' }: Props = $props();

View File

@@ -4,6 +4,9 @@
-->
<script lang="ts">
interface Props {
/**
* Title text
*/
text?: string;
}

View File

@@ -0,0 +1,101 @@
<!--
Component: SidebarContainer
Wraps <Sidebar> and handles show/hide behaviour for both breakpoints.
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import {
fade,
fly,
} from 'svelte/transition';
interface Props {
/**
* Sidebar open state
*/
isOpen: boolean;
/**
* Sidebar snippet
*/
sidebar?: Snippet<[{ onClose: () => void }]>;
/**
* CSS classes
*/
class?: string;
}
let {
isOpen = $bindable(false),
sidebar,
class: className,
}: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
function close() {
isOpen = false;
}
</script>
{#if responsive.isMobile}
<!--
── MOBILE: fixed overlay ─────────────────────────────────────────────
Only rendered when open. Both backdrop and panel use Svelte transitions
so they animate in and out independently.
-->
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/40 backdrop-blur-sm z-40"
transition:fade={{ duration: 200 }}
onclick={close}
aria-hidden="true"
>
</div>
<!-- Panel -->
<div
class="fixed left-0 top-0 bottom-0 w-80 z-50 shadow-2xl"
in:fly={{ x: -320, duration: 300, easing: cubicOut }}
out:fly={{ x: -320, duration: 250, easing: cubicOut }}
>
{#if sidebar}
{@render sidebar({ onClose: close })}
{/if}
</div>
{/if}
{:else}
<!--
── DESKTOP: collapsible column ───────────────────────────────────────
Always in the DOM — width transitions between 320px and 0.
overflow-hidden clips the w-80 inner div during the collapse.
transition-[width] is on the outer shell.
duration-300 + ease-out approximates the spring(300, 30) feel.
The inner div stays w-80 so Sidebar layout never reflows mid-animation.
-->
<div
class={cn(
'shrink-0 z-30 h-full relative',
'overflow-hidden',
'will-change-[width]',
'transition-[width] duration-300 ease-out',
'border-r border-black/5 dark:border-white/10',
'bg-[#f3f0e9] dark:bg-[#121212]',
isOpen ? 'w-80 opacity-100' : 'w-0 opacity-0',
'transition-[width,opacity] duration-300 ease-out',
className,
)}
>
<!-- Fixed-width inner so content never reflows during width animation -->
<div class="w-80 h-full">
{#if sidebar}
{@render sidebar({ onClose: close })}
{/if}
</div>
</div>
{/if}

View File

@@ -8,7 +8,8 @@ import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* Whether to show the shimmer animation
* Shimmer animation
* @default true
*/
animate?: boolean;
}

View File

@@ -75,24 +75,6 @@ let valueHigh = $state(75);
{/snippet}
</Story>
<Story
name="Dark Mode"
args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background">
<Slider {...args} />
<p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p>
<p class="mt-2 text-xs text-muted-foreground">Dark mode: track uses neutral-800</p>
</div>
{/snippet}
</Story>
<Story name="Interactive States" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value: 50 }}>
{#snippet template(args)}
<div class="p-8 space-y-8">

View File

@@ -10,18 +10,48 @@ import {
} from 'bits-ui';
interface Props {
/**
* Slider value
* @default 0
*/
value?: number;
/**
* Minimum value
* @default 0
*/
min?: number;
/**
* Maximum value
* @default 100
*/
max?: number;
/**
* Step increment
* @default 1
*/
step?: number;
/**
* Disabled state
* @default false
*/
disabled?: boolean;
/**
* Slider orientation
* @default 'horizontal'
*/
orientation?: Orientation;
/**
* Format the displayed value label.
* Value formatter
* @default (v) => v
*/
format?: (v: number) => string | number;
/**
* Value change callback
*/
onValueChange?: (v: number) => void;
/**
* CSS classes
*/
class?: string;
}

View File

@@ -8,10 +8,22 @@ import { Label } from '$shared/ui';
import type { ComponentProps } from 'svelte';
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
/**
* Stat label
*/
label: string;
/**
* Stat value
*/
value: string | number;
/** Renders a 1px vertical divider after the stat. */
/**
* Show separator
* @default false
*/
separator?: boolean;
/**
* CSS classes
*/
class?: string;
}

View File

@@ -13,7 +13,13 @@ interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>>
}
interface Props {
/**
* Stats array
*/
stats: StatItem[];
/**
* CSS classes
*/
class?: string;
}

View File

@@ -13,9 +13,23 @@ import {
import type { Snippet } from 'svelte';
interface Props {
/**
* Visual variant
* @default 'muted'
*/
variant?: LabelVariant;
/**
* Text size
* @default 'sm'
*/
size?: LabelSize;
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}

View File

@@ -13,100 +13,57 @@ import { createVirtualizer } from '$shared/lib';
import { throttle } from '$shared/lib/utils';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Props {
interface Props extends
Omit<
HTMLAttributes<HTMLDivElement>,
'children'
>
{
/**
* Array of items to render in the virtual list.
*
* @template T - The type of items in the list
* Items array
*/
items: T[];
/**
* Total number of items (including not-yet-loaded items for pagination).
* If not provided, defaults to items.length.
*
* Use this when implementing pagination to ensure the scrollbar
* reflects the total count of items, not just the loaded ones.
*
* @example
* ```ts
* // Pagination scenario: 1920 total fonts, but only 50 loaded
* <VirtualList items={loadedFonts} total={1920}>
* ```
* Total item count
* @default items.length
*/
total?: number;
/**
* Height for each item, either as a fixed number
* or a function that returns height per index.
* Item height
* @default 80
*/
itemHeight?: number | ((index: number) => number);
/**
* Optional overscan value for the virtual list.
* Overscan items
* @default 5
*/
overscan?: number;
/**
* Optional CSS class string for styling the container
* (follows shadcn convention for className prop)
* CSS classes
*/
class?: string;
/**
* Number of columns for grid layout.
* Grid columns
* @default 1
*/
columns?: number;
/**
* Gap between items in pixels.
* Item gap in pixels
* @default 0
*/
gap?: number;
/**
* An optional callback that will be called for each new set of loaded items
* @param items - Loaded items
* Visible items change callback
*/
onVisibleItemsChange?: (items: T[]) => void;
/**
* An optional callback that will be called when user scrolls near the end of the list.
* Useful for triggering auto-pagination.
*
* The callback receives the index of the last visible item. You can use this
* to determine if you should load more data.
*
* @example
* ```ts
* onNearBottom={(lastVisibleIndex) => {
* const itemsRemaining = total - lastVisibleIndex;
* if (itemsRemaining < 5 && hasMore && !isFetching) {
* loadMore();
* }
* }}
* ```
* Near bottom callback
*/
onNearBottom?: (lastVisibleIndex: number) => void;
/**
* Snippet for rendering individual list items.
*
* The snippet receives an object containing:
* - `item`: The item from the items array (type T)
* - `index`: The current item's index in the array
*
* This pattern provides type safety and flexibility for
* rendering different item types without prop drilling.
*
* @template T - The type of items in the list
*/
/**
* Snippet for rendering individual list items.
*
* The snippet receives an object containing:
* - `item`: The item from the items array (type T)
* - `index`: The current item's index in the array
*
* This pattern provides type safety and flexibility for
* rendering different item types without prop drilling.
*
* @template T - The type of items in the list
* Item render snippet
*/
children: Snippet<
[
@@ -120,12 +77,12 @@ interface Props {
]
>;
/**
* Whether to use the window as the scroll container.
* Use window scroll
* @default false
*/
useWindowScroll?: boolean;
/**
* Flag to show loading state
* Loading state
*/
isLoading?: boolean;
}
@@ -143,6 +100,7 @@ let {
isLoading = false,
columns = 1,
gap = 0,
...rest
}: Props = $props();
// Reference to the scroll container element for attaching the virtualizer
@@ -208,7 +166,7 @@ const throttledVisibleChange = throttle((visibleItems: T[]) => {
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms debounce
}, 200); // 200ms throttle
// Calculate top/bottom padding for spacer elements
// In CSS Grid, gap creates space BETWEEN elements.
@@ -245,8 +203,11 @@ $effect(() => {
$effect(() => {
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
// Only trigger if container has sufficient height to avoid false positives
if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) {
if (
virtualizer.items.length > 0
&& onNearBottom
&& virtualizer.containerHeight > 100
) {
const lastVisibleRow = virtualizer.items[virtualizer.items.length - 1];
// Convert row index to last item index in that row
const lastVisibleItemIndex = Math.min(
@@ -256,7 +217,10 @@ $effect(() => {
// Compare against loaded items length, not total
const itemsRemaining = items.length - lastVisibleItemIndex;
if (itemsRemaining <= 5) {
// Only trigger if user has scrolled (prevents loading on mount)
const hasScrolled = virtualizer.scrollOffset > 0;
if (itemsRemaining <= 5 && hasScrolled) {
throttledNearBottom(lastVisibleItemIndex);
}
}
@@ -329,7 +293,7 @@ $effect(() => {
{/snippet}
{#if useWindowScroll}
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
<div class={cn('relative w-full', className)} bind:this={viewportRef} {...rest}>
{@render content()}
</div>
{:else}
@@ -338,9 +302,10 @@ $effect(() => {
class={cn(
'relative overflow-y-auto overflow-x-hidden',
'rounded-md bg-background',
'w-full min-h-[200px]',
'w-full',
className,
)}
{...rest}
>
{@render content()}
</div>

View File

@@ -7,12 +7,10 @@ export {
} from './Button';
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte';
export { default as ControlGroup } from './ControlGroup/ControlGroup.svelte';
export { default as Divider } from './Divider/Divider.svelte';
export { default as Drawer } from './Drawer/Drawer.svelte';
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
export { default as FilterGroup } from './FilterGroup/FilterGroup.svelte';
export { default as Footnote } from './Footnote/Footnote.svelte';
export { default as GridBackground } from './GridBackground/GridBackground.svelte';
export { default as Input } from './Input/Input.svelte';
export { default as Label } from './Label/Label.svelte';
export { default as Loader } from './Loader/Loader.svelte';
@@ -21,9 +19,10 @@ export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.sv
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte';
export type { TitleStatusChangeHandler } from './Section/types';
export { default as SidebarMenu } from './SidebarMenu/SidebarMenu.svelte';
export { default as SidebarContainer } from './SidebarContainer/SidebarContainer.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte';
export { default as Stat } from './Stat/Stat.svelte';
export { default as StatGroup } from './Stat/StatGroup.svelte';
export { default as TechText } from './TechText/TechText.svelte';
export { default as VirtualList } from './VirtualList/VirtualList.svelte';