refactor(ui): update shared components and add ControlGroup, SidebarContainer
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
91
src/shared/ui/Button/ButtonGroup.stories.svelte
Normal file
91
src/shared/ui/Button/ButtonGroup.stories.svelte
Normal 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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
src/shared/ui/Button/IconButton.stories.svelte
Normal file
148
src/shared/ui/Button/IconButton.stories.svelte
Normal 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>
|
||||||
@@ -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'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
138
src/shared/ui/Button/ToggleButton.stories.svelte
Normal file
138
src/shared/ui/Button/ToggleButton.stories.svelte
Normal 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>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/shared/ui/ControlGroup/ControlGroup.svelte
Normal file
32
src/shared/ui/ControlGroup/ControlGroup.svelte
Normal 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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Title text
|
||||||
|
*/
|
||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
src/shared/ui/SidebarContainer/SidebarContainer.svelte
Normal file
101
src/shared/ui/SidebarContainer/SidebarContainer.svelte
Normal 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}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user