feature/project-redesign #28
@@ -9,6 +9,7 @@ import {
|
||||
labelSizeConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
|
||||
|
||||
@@ -20,12 +21,29 @@ const badgeVariantConfig: Record<BadgeVariant, string> = {
|
||||
info: 'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
interface Props extends HTMLAttributes<HTMLSpanElement> {
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: BadgeVariant;
|
||||
/**
|
||||
* Badge size
|
||||
* @default 'xs'
|
||||
*/
|
||||
size?: LabelSize;
|
||||
/** Renders a small filled circle before the text. */
|
||||
/**
|
||||
* Show status dot
|
||||
* @default false
|
||||
*/
|
||||
dot?: boolean;
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +53,7 @@ let {
|
||||
dot = false,
|
||||
children,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -46,6 +65,7 @@ let {
|
||||
badgeVariantConfig[variant],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{#if dot}
|
||||
<span class="w-1 h-1 rounded-full bg-current"></span>
|
||||
|
||||
@@ -13,18 +13,43 @@ import type {
|
||||
} from './types';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
/**
|
||||
* Visual style variant
|
||||
* @default 'secondary'
|
||||
*/
|
||||
variant?: ButtonVariant;
|
||||
/**
|
||||
* Button size
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: ButtonSize;
|
||||
/** Svelte snippet rendered as the icon. */
|
||||
/**
|
||||
* Icon snippet
|
||||
*/
|
||||
icon?: Snippet;
|
||||
/**
|
||||
* Icon placement
|
||||
* @default 'left'
|
||||
*/
|
||||
iconPosition?: IconPosition;
|
||||
/**
|
||||
* Active toggle state
|
||||
* @default false
|
||||
*/
|
||||
active?: boolean;
|
||||
/**
|
||||
* When true (default), adds `active:scale-[0.97]` on tap via CSS.
|
||||
* Primary variant is excluded from scale — it shifts via translate instead.
|
||||
* Tap animation
|
||||
* Primary uses translate, others use scale
|
||||
* @default true
|
||||
*/
|
||||
animate?: boolean;
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -45,7 +70,6 @@ let {
|
||||
// Square sizing when icon is present but there is no text label
|
||||
const isIconOnly = $derived(!!icon && !children);
|
||||
|
||||
// ── Variant base styles ──────────────────────────────────────────────────────
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: cn(
|
||||
'bg-swiss-red text-white',
|
||||
@@ -125,7 +149,6 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
),
|
||||
};
|
||||
|
||||
// ── Size styles ───────────────────────────────────────────────────────────────
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
xs: 'h-6 px-2 text-[9px] gap-1',
|
||||
sm: 'h-8 px-3 text-[10px] gap-1.5',
|
||||
@@ -143,12 +166,11 @@ const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
xl: 'h-14 w-14 p-3',
|
||||
};
|
||||
|
||||
// ── Active state overrides (per variant) ─────────────────────────────────────
|
||||
const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
||||
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
|
||||
tertiary:
|
||||
'bg-paper dark:bg-[#1e1e1e] border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
|
||||
ghost: 'bg-transparent dark:bg-transparent text-brnad dark:text-brand',
|
||||
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
|
||||
outline: 'bg-surface dark:bg-paper border-brand',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-black/5 dark:border-white/10',
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
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'>;
|
||||
|
||||
interface Props extends BaseProps {
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'icon'
|
||||
*/
|
||||
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>;
|
||||
|
||||
interface Props extends BaseProps {
|
||||
/** Alias for `active`. Takes precedence if both are provided. */
|
||||
/**
|
||||
* Selected state alias for active
|
||||
*/
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'secondary',
|
||||
variant = 'tertiary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
|
||||
@@ -18,12 +18,36 @@ import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import TechText from '../TechText/TechText.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Typography control
|
||||
*/
|
||||
control: TypographyControl;
|
||||
/**
|
||||
* Control label
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Reduced layout
|
||||
* @default false
|
||||
*/
|
||||
reduced?: boolean;
|
||||
/**
|
||||
* Increase button label
|
||||
* @default 'Increase'
|
||||
*/
|
||||
increaseLabel?: string;
|
||||
/**
|
||||
* Decrease button label
|
||||
* @default 'Decrease'
|
||||
*/
|
||||
decreaseLabel?: string;
|
||||
/**
|
||||
* Control aria label
|
||||
*/
|
||||
controlLabel?: string;
|
||||
}
|
||||
|
||||
@@ -39,10 +63,6 @@ let {
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
function toggleOpen() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
// Smart value formatting matching the Figma design
|
||||
const formattedValue = $derived(() => {
|
||||
const v = control.value;
|
||||
@@ -55,7 +75,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
</script>
|
||||
|
||||
<!--
|
||||
── REDUCED MODE ────────────────────────────────────────────────────────────
|
||||
REDUCED MODE
|
||||
Inline slider + value. No buttons, no popover.
|
||||
-->
|
||||
{#if reduced}
|
||||
@@ -85,12 +105,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
<div class={cn('flex items-center px-1 relative', className)}>
|
||||
<!-- Decrease button -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
onclick={control.decrease}
|
||||
disabled={control.isAtMin}
|
||||
aria-label={decreaseLabel}
|
||||
>
|
||||
<MinusIcon class="size-3.5 stroke-2" />
|
||||
{#snippet icon()}
|
||||
<MinusIcon class="size-3.5 stroke-2" />
|
||||
{/snippet}
|
||||
</Button>
|
||||
|
||||
<!-- Trigger -->
|
||||
@@ -152,12 +175,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- Increase button -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
onclick={control.increase}
|
||||
disabled={control.isAtMax}
|
||||
aria-label={increaseLabel}
|
||||
>
|
||||
<PlusIcon class="size-3.5 stroke-2" />
|
||||
{#snippet icon()}
|
||||
<PlusIcon class="size-3.5 stroke-2" />
|
||||
{/snippet}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,19 +5,22 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/**
|
||||
* Visible text (bindable)
|
||||
* Text content
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Font size in pixels
|
||||
* @default 48
|
||||
*/
|
||||
fontSize?: number;
|
||||
/**
|
||||
* Line height
|
||||
* @default 1.2
|
||||
*/
|
||||
lineHeight?: number;
|
||||
/**
|
||||
* Letter spacing in pixels
|
||||
* @default 0
|
||||
*/
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Divider orientation
|
||||
* @default 'horizontal'
|
||||
*/
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,19 +7,43 @@ import type { Filter } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import DotIcon from '@lucide/svelte/icons/dot';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { draw } from 'svelte/transition';
|
||||
import {
|
||||
draw,
|
||||
fly,
|
||||
} from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/** Label for this filter group (e.g., "Provider", "Tags") */
|
||||
/**
|
||||
* Group label
|
||||
*/
|
||||
displayedLabel: string;
|
||||
/** Filter entity */
|
||||
/**
|
||||
* Filter entity
|
||||
*/
|
||||
filter: Filter;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { displayedLabel, filter, class: className }: Props = $props();
|
||||
|
||||
const MAX_DISPLAYED_OPTIONS = 10;
|
||||
const hasMore = $derived(filter.properties.length > MAX_DISPLAYED_OPTIONS);
|
||||
let showMore = $state(false);
|
||||
let displayedProperties = $state(filter.properties.slice(0, MAX_DISPLAYED_OPTIONS));
|
||||
|
||||
$effect(() => {
|
||||
if (showMore) {
|
||||
displayedProperties = filter.properties;
|
||||
} else {
|
||||
displayedProperties = filter.properties.slice(0, MAX_DISPLAYED_OPTIONS);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet icon()}
|
||||
@@ -55,17 +79,35 @@ const { displayedLabel, filter, class: className }: Props = $props();
|
||||
</Label>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each filter.properties as property (property.id)}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
active={property.selected}
|
||||
onclick={() => (property.selected = !property.selected)}
|
||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
||||
iconPosition="right"
|
||||
icon={property.selected ? icon : undefined}
|
||||
>
|
||||
<span>{property.name}</span>
|
||||
</Button>
|
||||
{#each displayedProperties as property (property.id)}
|
||||
<div transition:fly={{ y: 20, duration: 200, easing: cubicOut }}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
active={property.selected}
|
||||
onclick={() => (property.selected = !property.selected)}
|
||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
||||
iconPosition="right"
|
||||
icon={property.selected ? icon : undefined}
|
||||
>
|
||||
<span>{property.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<Button
|
||||
variant="icon"
|
||||
onclick={() => (showMore = !showMore)}
|
||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
||||
iconPosition="left"
|
||||
>
|
||||
{#snippet icon()}
|
||||
{#if showMore}
|
||||
<ChevronUpIcon class="size-4" />
|
||||
{:else}
|
||||
<EllipsisIcon class="size-4" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,16 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Custom render function for full control
|
||||
* Custom render snippet
|
||||
*/
|
||||
render?: Snippet<[{ class: string }]>;
|
||||
}
|
||||
|
||||
@@ -15,29 +15,53 @@ import type {
|
||||
} from './types';
|
||||
|
||||
interface Props extends Omit<HTMLInputAttributes, 'size'> {
|
||||
/**
|
||||
* Visual style variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: InputVariant;
|
||||
/**
|
||||
* Input size
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: InputSize;
|
||||
/** Marks the input as invalid — red border + ring, red helper text. */
|
||||
/**
|
||||
* Invalid state
|
||||
*/
|
||||
error?: boolean;
|
||||
/** Helper / error message rendered below the input. */
|
||||
/**
|
||||
* Helper text
|
||||
*/
|
||||
helperText?: string;
|
||||
/** Show an animated × button when the input has a value. */
|
||||
/**
|
||||
* Show clear button
|
||||
* @default false
|
||||
*/
|
||||
showClearButton?: boolean;
|
||||
/** Called when the clear button is clicked. */
|
||||
/**
|
||||
* Clear button callback
|
||||
*/
|
||||
onclear?: () => void;
|
||||
/**
|
||||
* Snippet for the left icon slot.
|
||||
* Receives `size` as an argument for convenient icon sizing.
|
||||
* @example {#snippet leftIcon(size)}<SearchIcon size={inputIconSize[size]} />{/snippet}
|
||||
* Left icon snippet
|
||||
*/
|
||||
leftIcon?: Snippet<[InputSize]>;
|
||||
/**
|
||||
* Snippet for the right icon slot (rendered after the clear button).
|
||||
* Receives `size` as an argument.
|
||||
* Right icon snippet
|
||||
*/
|
||||
rightIcon?: Snippet<[InputSize]>;
|
||||
/**
|
||||
* Full width
|
||||
* @default false
|
||||
*/
|
||||
fullWidth?: boolean;
|
||||
/**
|
||||
* Input value
|
||||
*/
|
||||
value?: string | number | readonly string[];
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -56,15 +80,13 @@ let {
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
// ── Size config ──────────────────────────────────────────────────────────────
|
||||
const sizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
|
||||
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
|
||||
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
|
||||
lg: { input: 'px-4 py-3', text: 'text-lg', height: 'h-12', clearIcon: 16 },
|
||||
xl: { input: 'px-4 py-3', text: 'text-xl', height: 'h-14', clearIcon: 18 },
|
||||
lg: { input: 'px-4 py-3', text: 'text-lg md:text-xl', height: 'h-12', clearIcon: 16 },
|
||||
xl: { input: 'px-4 py-3', text: 'text-xl md:text-2xl', height: 'h-14', clearIcon: 18 },
|
||||
};
|
||||
|
||||
// ── Variant config ───────────────────────────────────────────────────────────
|
||||
const variantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
|
||||
default: {
|
||||
base: 'bg-paper dark:bg-paper border border-black/5 dark:border-white/10',
|
||||
|
||||
@@ -13,13 +13,42 @@ import {
|
||||
} from './config';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: LabelVariant;
|
||||
/**
|
||||
* Label size
|
||||
* @default 'sm'
|
||||
*/
|
||||
size?: LabelSize;
|
||||
/**
|
||||
* Uppercase text
|
||||
* @default true
|
||||
*/
|
||||
uppercase?: boolean;
|
||||
/**
|
||||
* Bold text
|
||||
* @default false
|
||||
*/
|
||||
bold?: boolean;
|
||||
/**
|
||||
* Icon snippet
|
||||
*/
|
||||
icon?: Snippet;
|
||||
/**
|
||||
* Icon placement
|
||||
* @default 'left'
|
||||
*/
|
||||
iconPosition?: 'left' | 'right';
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@ export type LabelVariant =
|
||||
| 'warning'
|
||||
| 'error';
|
||||
|
||||
export type LabelSize = 'xs' | 'sm' | 'md';
|
||||
export type LabelSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export const labelSizeConfig: Record<LabelSize, string> = {
|
||||
xs: 'text-[0.5rem]',
|
||||
sm: 'text-[0.5625rem] md:text-[0.625rem]',
|
||||
md: 'text-[0.625rem] md:text-[0.6875rem]',
|
||||
lg: 'text-[0.8rem] md:text-[0.875rem]',
|
||||
};
|
||||
|
||||
export const labelVariantConfig: Record<LabelVariant, string> = {
|
||||
|
||||
@@ -7,17 +7,17 @@ import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Icon size (in pixels)
|
||||
* Icon size in pixels
|
||||
* @default 20
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* Additional classes for container
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Message text
|
||||
* @default analyzing_data
|
||||
* Loading message
|
||||
* @default 'analyzing_data'
|
||||
*/
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@@ -4,42 +4,23 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Badge } from '$shared/ui';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className }: Props = $props();
|
||||
|
||||
const baseClasses = 'barlow font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl';
|
||||
|
||||
const title = 'GLYPHDIFF';
|
||||
</script>
|
||||
|
||||
<!-- Firefox version (hidden in Chrome/Safari) -->
|
||||
<h2
|
||||
class={cn(
|
||||
baseClasses,
|
||||
'text-justify [text-align-last:justify] [text-justify:inter-character]',
|
||||
// Hide in non-Firefox
|
||||
'hidden [@supports(text-justify:inter-character)]:block',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<!-- Chrome/Safari version (hidden in Firefox) -->
|
||||
<h2
|
||||
class={cn(
|
||||
'flex justify-between w-full',
|
||||
baseClasses,
|
||||
// Hide in Firefox
|
||||
'[@supports(text-justify:inter-character)]:hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{#each title.split('') as letter}
|
||||
<span>{letter}</span>
|
||||
{/each}
|
||||
</h2>
|
||||
<div class={cn('flex items-center gap-2 md:gap-3 select-none', className)}>
|
||||
<h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200">
|
||||
{title}
|
||||
</h1>
|
||||
<Badge variant="accent" size="xs">BETA</Badge>
|
||||
</div>
|
||||
|
||||
@@ -14,20 +14,21 @@ interface Props {
|
||||
*/
|
||||
manager: PerspectiveManager;
|
||||
/**
|
||||
* Additional classes
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Children
|
||||
* Content snippet
|
||||
*/
|
||||
children: Snippet<[{ className?: string }]>;
|
||||
/**
|
||||
* Constrain plan to a horizontal region
|
||||
* 'left' | 'right' | 'full' (default)
|
||||
* Constrain region
|
||||
* @default 'full'
|
||||
*/
|
||||
region?: 'left' | 'right' | 'full';
|
||||
/**
|
||||
* Width percentage when using left/right region (default 50)
|
||||
* Region width percentage
|
||||
* @default 50
|
||||
*/
|
||||
regionWidth?: number;
|
||||
}
|
||||
|
||||
@@ -3,66 +3,73 @@
|
||||
Provides a container for a page widget with snippets for a title
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import {
|
||||
type FlyParams,
|
||||
fly,
|
||||
} from 'svelte/transition';
|
||||
|
||||
import SectionHeader from './SectionHeader/SectionHeader.svelte';
|
||||
import SectionTitle from './SectionTitle/SectionTitle.svelte';
|
||||
import type { TitleStatusChangeHandler } from './types';
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
/**
|
||||
* ID of the section
|
||||
* Section ID
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Additional CSS classes to apply to the section container.
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Title of the section
|
||||
* Section title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Snippet for a title description
|
||||
* Breadcrumb title
|
||||
*/
|
||||
breadcrumbTitle?: string;
|
||||
/**
|
||||
* Description snippet
|
||||
*/
|
||||
description?: Snippet<[{ className?: string }]>;
|
||||
/**
|
||||
* Header title
|
||||
*/
|
||||
headerTitle?: string;
|
||||
/**
|
||||
* Header subtitle
|
||||
*/
|
||||
headerSubtitle?: string;
|
||||
/**
|
||||
* Index of the section
|
||||
* Header action callback
|
||||
*/
|
||||
headerAction?: (node: HTMLElement) => void;
|
||||
/**
|
||||
* Section index
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Callback function to notify when the title visibility status changes
|
||||
*/
|
||||
onTitleStatusChange?: TitleStatusChangeHandler;
|
||||
/**
|
||||
* Snippet for the section content
|
||||
* Content snippet
|
||||
*/
|
||||
content?: Snippet<[{ className?: string }]>;
|
||||
/**
|
||||
* Snippet for the section header content
|
||||
* Header content snippet
|
||||
*/
|
||||
headerContent?: Snippet<[{ className?: string }]>;
|
||||
}
|
||||
|
||||
const {
|
||||
let {
|
||||
class: className,
|
||||
title,
|
||||
headerTitle,
|
||||
headerSubtitle,
|
||||
headerContent,
|
||||
headerAction = () => {},
|
||||
index = 0,
|
||||
onTitleStatusChange,
|
||||
id,
|
||||
content,
|
||||
headerContent,
|
||||
}: Props = $props();
|
||||
|
||||
const flyParams: FlyParams = {
|
||||
@@ -76,11 +83,14 @@ const flyParams: FlyParams = {
|
||||
|
||||
<section
|
||||
{id}
|
||||
class="w-full max-w-7xl mx-auto px-4 md:px-6 pb-32 md:pb-48"
|
||||
class="w-full max-w-7xl mx-auto px-4 md:px-6 py-8 md:py-16 {className}"
|
||||
in:fly={flyParams}
|
||||
out:fly={flyParams}
|
||||
>
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between mb-8 md:mb-12 gap-4 md:gap-6">
|
||||
<div
|
||||
use:headerAction
|
||||
class="flex flex-col md:flex-row md:items-end justify-between mb-8 md:mb-12 gap-4 md:gap-6"
|
||||
>
|
||||
<div>
|
||||
{#if headerTitle}
|
||||
<SectionHeader title={headerTitle} subtitle={headerSubtitle} index={index} />
|
||||
|
||||
@@ -7,10 +7,26 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Label } from '$shared/ui';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Section index
|
||||
*/
|
||||
index: string | number;
|
||||
/**
|
||||
* Section title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Section subtitle
|
||||
*/
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Pulse animation
|
||||
* @default false
|
||||
*/
|
||||
pulse?: boolean;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* CSS classes
|
||||
* @default ''
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
const { class: className = '' }: Props = $props();
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/**
|
||||
* Title text
|
||||
*/
|
||||
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> {
|
||||
/**
|
||||
* Whether to show the shimmer animation
|
||||
* Shimmer animation
|
||||
* @default true
|
||||
*/
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
@@ -75,24 +75,6 @@ let valueHigh = $state(75);
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Dark Mode"
|
||||
args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}
|
||||
parameters={{
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<div class="p-8 bg-background">
|
||||
<Slider {...args} />
|
||||
<p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p>
|
||||
<p class="mt-2 text-xs text-muted-foreground">Dark mode: track uses neutral-800</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Interactive States" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value: 50 }}>
|
||||
{#snippet template(args)}
|
||||
<div class="p-8 space-y-8">
|
||||
|
||||
@@ -10,18 +10,48 @@ import {
|
||||
} from 'bits-ui';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Slider value
|
||||
* @default 0
|
||||
*/
|
||||
value?: number;
|
||||
/**
|
||||
* Minimum value
|
||||
* @default 0
|
||||
*/
|
||||
min?: number;
|
||||
/**
|
||||
* Maximum value
|
||||
* @default 100
|
||||
*/
|
||||
max?: number;
|
||||
/**
|
||||
* Step increment
|
||||
* @default 1
|
||||
*/
|
||||
step?: number;
|
||||
/**
|
||||
* Disabled state
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Slider orientation
|
||||
* @default 'horizontal'
|
||||
*/
|
||||
orientation?: Orientation;
|
||||
/**
|
||||
* Format the displayed value label.
|
||||
* Value formatter
|
||||
* @default (v) => v
|
||||
*/
|
||||
format?: (v: number) => string | number;
|
||||
/**
|
||||
* Value change callback
|
||||
*/
|
||||
onValueChange?: (v: number) => void;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,22 @@ import { Label } from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
|
||||
/**
|
||||
* Stat label
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Stat value
|
||||
*/
|
||||
value: string | number;
|
||||
/** Renders a 1px vertical divider after the stat. */
|
||||
/**
|
||||
* Show separator
|
||||
* @default false
|
||||
*/
|
||||
separator?: boolean;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@ interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Stats array
|
||||
*/
|
||||
stats: StatItem[];
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,23 @@ import {
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'muted'
|
||||
*/
|
||||
variant?: LabelVariant;
|
||||
/**
|
||||
* Text size
|
||||
* @default 'sm'
|
||||
*/
|
||||
size?: LabelSize;
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,100 +13,57 @@ import { createVirtualizer } from '$shared/lib';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
interface Props extends
|
||||
Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
>
|
||||
{
|
||||
/**
|
||||
* Array of items to render in the virtual list.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
* Items array
|
||||
*/
|
||||
items: T[];
|
||||
/**
|
||||
* Total number of items (including not-yet-loaded items for pagination).
|
||||
* If not provided, defaults to items.length.
|
||||
*
|
||||
* Use this when implementing pagination to ensure the scrollbar
|
||||
* reflects the total count of items, not just the loaded ones.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Pagination scenario: 1920 total fonts, but only 50 loaded
|
||||
* <VirtualList items={loadedFonts} total={1920}>
|
||||
* ```
|
||||
* Total item count
|
||||
* @default items.length
|
||||
*/
|
||||
total?: number;
|
||||
/**
|
||||
* Height for each item, either as a fixed number
|
||||
* or a function that returns height per index.
|
||||
* Item height
|
||||
* @default 80
|
||||
*/
|
||||
itemHeight?: number | ((index: number) => number);
|
||||
/**
|
||||
* Optional overscan value for the virtual list.
|
||||
* Overscan items
|
||||
* @default 5
|
||||
*/
|
||||
overscan?: number;
|
||||
/**
|
||||
* Optional CSS class string for styling the container
|
||||
* (follows shadcn convention for className prop)
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Number of columns for grid layout.
|
||||
* Grid columns
|
||||
* @default 1
|
||||
*/
|
||||
columns?: number;
|
||||
/**
|
||||
* Gap between items in pixels.
|
||||
* Item gap in pixels
|
||||
* @default 0
|
||||
*/
|
||||
gap?: number;
|
||||
/**
|
||||
* An optional callback that will be called for each new set of loaded items
|
||||
* @param items - Loaded items
|
||||
* Visible items change callback
|
||||
*/
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
/**
|
||||
* An optional callback that will be called when user scrolls near the end of the list.
|
||||
* Useful for triggering auto-pagination.
|
||||
*
|
||||
* The callback receives the index of the last visible item. You can use this
|
||||
* to determine if you should load more data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* onNearBottom={(lastVisibleIndex) => {
|
||||
* const itemsRemaining = total - lastVisibleIndex;
|
||||
* if (itemsRemaining < 5 && hasMore && !isFetching) {
|
||||
* loadMore();
|
||||
* }
|
||||
* }}
|
||||
* ```
|
||||
* Near bottom callback
|
||||
*/
|
||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
* The snippet receives an object containing:
|
||||
* - `item`: The item from the items array (type T)
|
||||
* - `index`: The current item's index in the array
|
||||
*
|
||||
* This pattern provides type safety and flexibility for
|
||||
* rendering different item types without prop drilling.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
* The snippet receives an object containing:
|
||||
* - `item`: The item from the items array (type T)
|
||||
* - `index`: The current item's index in the array
|
||||
*
|
||||
* This pattern provides type safety and flexibility for
|
||||
* rendering different item types without prop drilling.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
* Item render snippet
|
||||
*/
|
||||
children: Snippet<
|
||||
[
|
||||
@@ -120,12 +77,12 @@ interface Props {
|
||||
]
|
||||
>;
|
||||
/**
|
||||
* Whether to use the window as the scroll container.
|
||||
* Use window scroll
|
||||
* @default false
|
||||
*/
|
||||
useWindowScroll?: boolean;
|
||||
/**
|
||||
* Flag to show loading state
|
||||
* Loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -143,6 +100,7 @@ let {
|
||||
isLoading = false,
|
||||
columns = 1,
|
||||
gap = 0,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
// Reference to the scroll container element for attaching the virtualizer
|
||||
@@ -208,7 +166,7 @@ const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
||||
|
||||
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
}, 200); // 200ms debounce
|
||||
}, 200); // 200ms throttle
|
||||
|
||||
// Calculate top/bottom padding for spacer elements
|
||||
// In CSS Grid, gap creates space BETWEEN elements.
|
||||
@@ -245,8 +203,11 @@ $effect(() => {
|
||||
|
||||
$effect(() => {
|
||||
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
||||
// Only trigger if container has sufficient height to avoid false positives
|
||||
if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) {
|
||||
if (
|
||||
virtualizer.items.length > 0
|
||||
&& onNearBottom
|
||||
&& virtualizer.containerHeight > 100
|
||||
) {
|
||||
const lastVisibleRow = virtualizer.items[virtualizer.items.length - 1];
|
||||
// Convert row index to last item index in that row
|
||||
const lastVisibleItemIndex = Math.min(
|
||||
@@ -256,7 +217,10 @@ $effect(() => {
|
||||
// Compare against loaded items length, not total
|
||||
const itemsRemaining = items.length - lastVisibleItemIndex;
|
||||
|
||||
if (itemsRemaining <= 5) {
|
||||
// Only trigger if user has scrolled (prevents loading on mount)
|
||||
const hasScrolled = virtualizer.scrollOffset > 0;
|
||||
|
||||
if (itemsRemaining <= 5 && hasScrolled) {
|
||||
throttledNearBottom(lastVisibleItemIndex);
|
||||
}
|
||||
}
|
||||
@@ -329,7 +293,7 @@ $effect(() => {
|
||||
{/snippet}
|
||||
|
||||
{#if useWindowScroll}
|
||||
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
|
||||
<div class={cn('relative w-full', className)} bind:this={viewportRef} {...rest}>
|
||||
{@render content()}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -338,9 +302,10 @@ $effect(() => {
|
||||
class={cn(
|
||||
'relative overflow-y-auto overflow-x-hidden',
|
||||
'rounded-md bg-background',
|
||||
'w-full min-h-[200px]',
|
||||
'w-full',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{@render content()}
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,10 @@ export {
|
||||
} from './Button';
|
||||
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
|
||||
export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte';
|
||||
export { default as ControlGroup } from './ControlGroup/ControlGroup.svelte';
|
||||
export { default as Divider } from './Divider/Divider.svelte';
|
||||
export { default as Drawer } from './Drawer/Drawer.svelte';
|
||||
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
|
||||
export { default as FilterGroup } from './FilterGroup/FilterGroup.svelte';
|
||||
export { default as Footnote } from './Footnote/Footnote.svelte';
|
||||
export { default as GridBackground } from './GridBackground/GridBackground.svelte';
|
||||
export { default as Input } from './Input/Input.svelte';
|
||||
export { default as Label } from './Label/Label.svelte';
|
||||
export { default as Loader } from './Loader/Loader.svelte';
|
||||
@@ -21,9 +19,10 @@ export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.sv
|
||||
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
|
||||
export { default as Section } from './Section/Section.svelte';
|
||||
export type { TitleStatusChangeHandler } from './Section/types';
|
||||
export { default as SidebarMenu } from './SidebarMenu/SidebarMenu.svelte';
|
||||
export { default as SidebarContainer } from './SidebarContainer/SidebarContainer.svelte';
|
||||
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
|
||||
export { default as Slider } from './Slider/Slider.svelte';
|
||||
export { default as Stat } from './Stat/Stat.svelte';
|
||||
export { default as StatGroup } from './Stat/StatGroup.svelte';
|
||||
export { default as TechText } from './TechText/TechText.svelte';
|
||||
export { default as VirtualList } from './VirtualList/VirtualList.svelte';
|
||||
|
||||
Reference in New Issue
Block a user