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

View File

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

View File

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

View File

@@ -5,19 +5,22 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
/** /**
* Visible text (bindable) * Text content
*/ */
text: string; text: string;
/** /**
* Font size in pixels * Font size in pixels
* @default 48
*/ */
fontSize?: number; fontSize?: number;
/** /**
* Line height * Line height
* @default 1.2
*/ */
lineHeight?: number; lineHeight?: number;
/** /**
* Letter spacing in pixels * Letter spacing in pixels
* @default 0
*/ */
letterSpacing?: number; 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'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props { interface Props {
/**
* Divider orientation
* @default 'horizontal'
*/
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
/**
* CSS classes
*/
class?: string; class?: string;
} }

View File

@@ -7,19 +7,43 @@ import type { Filter } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui'; import { Button } from '$shared/ui';
import { Label } 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 { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition'; import {
draw,
fly,
} from 'svelte/transition';
interface Props { interface Props {
/** Label for this filter group (e.g., "Provider", "Tags") */ /**
* Group label
*/
displayedLabel: string; displayedLabel: string;
/** Filter entity */ /**
* Filter entity
*/
filter: Filter; filter: Filter;
/**
* CSS classes
*/
class?: string; class?: string;
} }
const { displayedLabel, filter, class: className }: Props = $props(); 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> </script>
{#snippet icon()} {#snippet icon()}
@@ -55,7 +79,8 @@ const { displayedLabel, filter, class: className }: Props = $props();
</Label> </Label>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#each filter.properties as property (property.id)} {#each displayedProperties as property (property.id)}
<div transition:fly={{ y: 20, duration: 200, easing: cubicOut }}>
<Button <Button
variant="tertiary" variant="tertiary"
active={property.selected} active={property.selected}
@@ -66,6 +91,23 @@ const { displayedLabel, filter, class: className }: Props = $props();
> >
<span>{property.name}</span> <span>{property.name}</span>
</Button> </Button>
</div>
{/each} {/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>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,42 +4,23 @@
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Badge } from '$shared/ui';
interface Props { interface Props {
/**
* CSS classes
*/
class?: string; class?: string;
} }
const { class: className }: Props = $props(); const { class: className }: Props = $props();
const baseClasses = 'barlow font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl';
const title = 'GLYPHDIFF'; const title = 'GLYPHDIFF';
</script> </script>
<!-- Firefox version (hidden in Chrome/Safari) --> <div class={cn('flex items-center gap-2 md:gap-3 select-none', className)}>
<h2 <h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200">
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} {title}
</h2> </h1>
<Badge variant="accent" size="xs">BETA</Badge>
<!-- Chrome/Safari version (hidden in Firefox) --> </div>
<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>

View File

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

View File

@@ -3,66 +3,73 @@
Provides a container for a page widget with snippets for a title Provides a container for a page widget with snippets for a title
--> -->
<script lang="ts"> <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 { cubicOut } from 'svelte/easing';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { import {
type FlyParams, type FlyParams,
fly, fly,
} from 'svelte/transition'; } from 'svelte/transition';
import SectionHeader from './SectionHeader/SectionHeader.svelte'; import SectionHeader from './SectionHeader/SectionHeader.svelte';
import SectionTitle from './SectionTitle/SectionTitle.svelte'; import SectionTitle from './SectionTitle/SectionTitle.svelte';
import type { TitleStatusChangeHandler } from './types';
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> { interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/** /**
* ID of the section * Section ID
*/ */
id?: string; id?: string;
/** /**
* Additional CSS classes to apply to the section container. * CSS classes
*/ */
class?: string; class?: string;
/** /**
* Title of the section * Section title
*/ */
title: string; title: string;
/** /**
* Snippet for a title description * Breadcrumb title
*/
breadcrumbTitle?: string;
/**
* Description snippet
*/ */
description?: Snippet<[{ className?: string }]>; description?: Snippet<[{ className?: string }]>;
/**
* Header title
*/
headerTitle?: string; headerTitle?: string;
/**
* Header subtitle
*/
headerSubtitle?: string; headerSubtitle?: string;
/** /**
* Index of the section * Header action callback
*/
headerAction?: (node: HTMLElement) => void;
/**
* Section index
*/ */
index?: number; index?: number;
/** /**
* Callback function to notify when the title visibility status changes * Content snippet
*/
onTitleStatusChange?: TitleStatusChangeHandler;
/**
* Snippet for the section content
*/ */
content?: Snippet<[{ className?: string }]>; content?: Snippet<[{ className?: string }]>;
/** /**
* Snippet for the section header content * Header content snippet
*/ */
headerContent?: Snippet<[{ className?: string }]>; headerContent?: Snippet<[{ className?: string }]>;
} }
const { let {
class: className, class: className,
title, title,
headerTitle, headerTitle,
headerSubtitle, headerSubtitle,
headerContent,
headerAction = () => {},
index = 0, index = 0,
onTitleStatusChange,
id, id,
content, content,
headerContent,
}: Props = $props(); }: Props = $props();
const flyParams: FlyParams = { const flyParams: FlyParams = {
@@ -76,11 +83,14 @@ const flyParams: FlyParams = {
<section <section
{id} {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} in:fly={flyParams}
out: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> <div>
{#if headerTitle} {#if headerTitle}
<SectionHeader title={headerTitle} subtitle={headerSubtitle} index={index} /> <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'; import { Label } from '$shared/ui';
interface Props { interface Props {
/**
* Section index
*/
index: string | number; index: string | number;
/**
* Section title
*/
title: string; title: string;
/**
* Section subtitle
*/
subtitle?: string; subtitle?: string;
/**
* Pulse animation
* @default false
*/
pulse?: boolean; pulse?: boolean;
/**
* CSS classes
*/
class?: string; class?: string;
} }

View File

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

View File

@@ -4,6 +4,9 @@
--> -->
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
/**
* Title text
*/
text?: string; 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> { interface Props extends HTMLAttributes<HTMLDivElement> {
/** /**
* Whether to show the shimmer animation * Shimmer animation
* @default true
*/ */
animate?: boolean; animate?: boolean;
} }

View File

@@ -75,24 +75,6 @@ let valueHigh = $state(75);
{/snippet} {/snippet}
</Story> </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 }}> <Story name="Interactive States" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value: 50 }}>
{#snippet template(args)} {#snippet template(args)}
<div class="p-8 space-y-8"> <div class="p-8 space-y-8">

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,100 +13,57 @@ import { createVirtualizer } from '$shared/lib';
import { throttle } from '$shared/lib/utils'; import { throttle } from '$shared/lib/utils';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; 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. * Items array
*
* @template T - The type of items in the list
*/ */
items: T[]; items: T[];
/** /**
* Total number of items (including not-yet-loaded items for pagination). * Total item count
* If not provided, defaults to items.length. * @default 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?: number; total?: number;
/** /**
* Height for each item, either as a fixed number * Item height
* or a function that returns height per index.
* @default 80 * @default 80
*/ */
itemHeight?: number | ((index: number) => number); itemHeight?: number | ((index: number) => number);
/** /**
* Optional overscan value for the virtual list. * Overscan items
* @default 5 * @default 5
*/ */
overscan?: number; overscan?: number;
/** /**
* Optional CSS class string for styling the container * CSS classes
* (follows shadcn convention for className prop)
*/ */
class?: string; class?: string;
/** /**
* Number of columns for grid layout. * Grid columns
* @default 1 * @default 1
*/ */
columns?: number; columns?: number;
/** /**
* Gap between items in pixels. * Item gap in pixels
* @default 0 * @default 0
*/ */
gap?: number; gap?: number;
/** /**
* An optional callback that will be called for each new set of loaded items * Visible items change callback
* @param items - Loaded items
*/ */
onVisibleItemsChange?: (items: T[]) => void; onVisibleItemsChange?: (items: T[]) => void;
/** /**
* An optional callback that will be called when user scrolls near the end of the list. * Near bottom callback
* 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();
* }
* }}
* ```
*/ */
onNearBottom?: (lastVisibleIndex: number) => void; onNearBottom?: (lastVisibleIndex: number) => void;
/** /**
* Snippet for rendering individual list items. * Item render snippet
*
* 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
*/ */
children: Snippet< children: Snippet<
[ [
@@ -120,12 +77,12 @@ interface Props {
] ]
>; >;
/** /**
* Whether to use the window as the scroll container. * Use window scroll
* @default false * @default false
*/ */
useWindowScroll?: boolean; useWindowScroll?: boolean;
/** /**
* Flag to show loading state * Loading state
*/ */
isLoading?: boolean; isLoading?: boolean;
} }
@@ -143,6 +100,7 @@ let {
isLoading = false, isLoading = false,
columns = 1, columns = 1,
gap = 0, gap = 0,
...rest
}: Props = $props(); }: Props = $props();
// Reference to the scroll container element for attaching the virtualizer // Reference to the scroll container element for attaching the virtualizer
@@ -208,7 +166,7 @@ const throttledVisibleChange = throttle((visibleItems: T[]) => {
const throttledNearBottom = throttle((lastVisibleIndex: number) => { const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex); onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms debounce }, 200); // 200ms throttle
// Calculate top/bottom padding for spacer elements // Calculate top/bottom padding for spacer elements
// In CSS Grid, gap creates space BETWEEN elements. // In CSS Grid, gap creates space BETWEEN elements.
@@ -245,8 +203,11 @@ $effect(() => {
$effect(() => { $effect(() => {
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items) // 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 (
if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) { virtualizer.items.length > 0
&& onNearBottom
&& virtualizer.containerHeight > 100
) {
const lastVisibleRow = virtualizer.items[virtualizer.items.length - 1]; const lastVisibleRow = virtualizer.items[virtualizer.items.length - 1];
// Convert row index to last item index in that row // Convert row index to last item index in that row
const lastVisibleItemIndex = Math.min( const lastVisibleItemIndex = Math.min(
@@ -256,7 +217,10 @@ $effect(() => {
// Compare against loaded items length, not total // Compare against loaded items length, not total
const itemsRemaining = items.length - lastVisibleItemIndex; 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); throttledNearBottom(lastVisibleItemIndex);
} }
} }
@@ -329,7 +293,7 @@ $effect(() => {
{/snippet} {/snippet}
{#if useWindowScroll} {#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()} {@render content()}
</div> </div>
{:else} {:else}
@@ -338,9 +302,10 @@ $effect(() => {
class={cn( class={cn(
'relative overflow-y-auto overflow-x-hidden', 'relative overflow-y-auto overflow-x-hidden',
'rounded-md bg-background', 'rounded-md bg-background',
'w-full min-h-[200px]', 'w-full',
className, className,
)} )}
{...rest}
> >
{@render content()} {@render content()}
</div> </div>

View File

@@ -7,12 +7,10 @@ export {
} from './Button'; } from './Button';
export { default as ComboControl } from './ComboControl/ComboControl.svelte'; export { default as ComboControl } from './ComboControl/ComboControl.svelte';
export { default as ContentEditable } from './ContentEditable/ContentEditable.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 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 FilterGroup } from './FilterGroup/FilterGroup.svelte';
export { default as Footnote } from './Footnote/Footnote.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 Input } from './Input/Input.svelte';
export { default as Label } from './Label/Label.svelte'; export { default as Label } from './Label/Label.svelte';
export { default as Loader } from './Loader/Loader.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 SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte'; export { default as Section } from './Section/Section.svelte';
export type { TitleStatusChangeHandler } from './Section/types'; 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 Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte'; export { default as Slider } from './Slider/Slider.svelte';
export { default as Stat } from './Stat/Stat.svelte'; export { default as Stat } from './Stat/Stat.svelte';
export { default as StatGroup } from './Stat/StatGroup.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'; export { default as VirtualList } from './VirtualList/VirtualList.svelte';