diff --git a/src/shared/ui/Badge/Badge.svelte b/src/shared/ui/Badge/Badge.svelte index 81de6fe..aa42df9 100644 --- a/src/shared/ui/Badge/Badge.svelte +++ b/src/shared/ui/Badge/Badge.svelte @@ -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 = { info: 'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400', }; -interface Props { +interface Props extends HTMLAttributes { + /** + * 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(); @@ -46,6 +65,7 @@ let { badgeVariantConfig[variant], className, )} + {...rest} > {#if dot} diff --git a/src/shared/ui/Button/Button.svelte b/src/shared/ui/Button/Button.svelte index 04d9c19..339cc37 100644 --- a/src/shared/ui/Button/Button.svelte +++ b/src/shared/ui/Button/Button.svelte @@ -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 = { primary: cn( 'bg-swiss-red text-white', @@ -125,7 +149,6 @@ const variantStyles: Record = { ), }; -// ── Size styles ─────────────────────────────────────────────────────────────── const sizeStyles: Record = { 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 = { xl: 'h-14 w-14 p-3', }; -// ── Active state overrides (per variant) ───────────────────────────────────── const activeStyles: Partial> = { 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', }; diff --git a/src/shared/ui/Button/ButtonGroup.stories.svelte b/src/shared/ui/Button/ButtonGroup.stories.svelte new file mode 100644 index 0000000..2babde6 --- /dev/null +++ b/src/shared/ui/Button/ButtonGroup.stories.svelte @@ -0,0 +1,91 @@ + + + + + + {#snippet template(args)} + + + + + + {/snippet} + + + + {#snippet template(args)} + + + + + + + {/snippet} + + + + {#snippet template(args)} + + + + + + {/snippet} + + + + {#snippet template(args)} + + + + + + {/snippet} + + + + {#snippet template(args)} +
+

Dark Mode

+ + + + + +
+ {/snippet} +
diff --git a/src/shared/ui/Button/ButtonGroup.svelte b/src/shared/ui/Button/ButtonGroup.svelte index c00eb02..fd06816 100644 --- a/src/shared/ui/Button/ButtonGroup.svelte +++ b/src/shared/ui/Button/ButtonGroup.svelte @@ -9,7 +9,13 @@ import type { Snippet } from 'svelte'; import type { HTMLAttributes } from 'svelte/elements'; interface Props extends HTMLAttributes { + /** + * Content snippet + */ children?: Snippet; + /** + * CSS classes + */ class?: string; } diff --git a/src/shared/ui/Button/IconButton.stories.svelte b/src/shared/ui/Button/IconButton.stories.svelte new file mode 100644 index 0000000..b097d6f --- /dev/null +++ b/src/shared/ui/Button/IconButton.stories.svelte @@ -0,0 +1,148 @@ + + + + + + {#snippet template(args)} +
+ + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + +
+ {/snippet} +
+ + + {#snippet template(args)} +
+

Dark Mode

+
+ + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + +
+
+ {/snippet} +
diff --git a/src/shared/ui/Button/IconButton.svelte b/src/shared/ui/Button/IconButton.svelte index 5f60386..49af14f 100644 --- a/src/shared/ui/Button/IconButton.svelte +++ b/src/shared/ui/Button/IconButton.svelte @@ -12,6 +12,10 @@ import type { ButtonVariant } from './types'; type BaseProps = Exclude, 'children' | 'iconPosition'>; interface Props extends BaseProps { + /** + * Visual variant + * @default 'icon' + */ variant?: Extract; } diff --git a/src/shared/ui/Button/ToggleButton.stories.svelte b/src/shared/ui/Button/ToggleButton.stories.svelte new file mode 100644 index 0000000..73b115e --- /dev/null +++ b/src/shared/ui/Button/ToggleButton.stories.svelte @@ -0,0 +1,138 @@ + + + + + + {#snippet template(args)} + Toggle Me + {/snippet} + + + + {#snippet template(args)} +
+ + Unselected + + + Selected + +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ + Primary + + + Secondary + + + Tertiary + + + Outline + + + Ghost + +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ selected = !selected}> + Click to toggle + + Currently: {selected ? 'selected' : 'unselected'} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+

Dark Mode

+
+ + Primary + + + Secondary + + + Tertiary + +
+
+ {/snippet} +
diff --git a/src/shared/ui/Button/ToggleButton.svelte b/src/shared/ui/Button/ToggleButton.svelte index a935319..075a760 100644 --- a/src/shared/ui/Button/ToggleButton.svelte +++ b/src/shared/ui/Button/ToggleButton.svelte @@ -10,12 +10,14 @@ import Button from './Button.svelte'; type BaseProps = ComponentProps; 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', diff --git a/src/shared/ui/ComboControl/ComboControl.svelte b/src/shared/ui/ComboControl/ComboControl.svelte index 62c6f1b..ce3c211 100644 --- a/src/shared/ui/ComboControl/ComboControl.svelte +++ b/src/shared/ui/ComboControl/ComboControl.svelte @@ -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 ?? ''); {#if reduced} @@ -85,12 +105,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
@@ -152,12 +175,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
{/if} diff --git a/src/shared/ui/ContentEditable/ContentEditable.svelte b/src/shared/ui/ContentEditable/ContentEditable.svelte index 8b45acb..63cc760 100644 --- a/src/shared/ui/ContentEditable/ContentEditable.svelte +++ b/src/shared/ui/ContentEditable/ContentEditable.svelte @@ -5,19 +5,22 @@ + +
+
+ {label} +
+ {@render children?.()} +
diff --git a/src/shared/ui/Divider/Divider.svelte b/src/shared/ui/Divider/Divider.svelte index bc3c612..8085069 100644 --- a/src/shared/ui/Divider/Divider.svelte +++ b/src/shared/ui/Divider/Divider.svelte @@ -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; } diff --git a/src/shared/ui/FilterGroup/FilterGroup.svelte b/src/shared/ui/FilterGroup/FilterGroup.svelte index 99c9e8f..3235c1f 100644 --- a/src/shared/ui/FilterGroup/FilterGroup.svelte +++ b/src/shared/ui/FilterGroup/FilterGroup.svelte @@ -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); + } +}); {#snippet icon()} @@ -55,17 +79,35 @@ const { displayedLabel, filter, class: className }: Props = $props();
- {#each filter.properties as property (property.id)} - + {#each displayedProperties as property (property.id)} +
+ +
{/each} + {#if hasMore} + + {/if}
diff --git a/src/shared/ui/Footnote/Footnote.svelte b/src/shared/ui/Footnote/Footnote.svelte index cda14d6..2b72ce9 100644 --- a/src/shared/ui/Footnote/Footnote.svelte +++ b/src/shared/ui/Footnote/Footnote.svelte @@ -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 }]>; } diff --git a/src/shared/ui/Input/Input.svelte b/src/shared/ui/Input/Input.svelte index 9265da3..dd6db7d 100644 --- a/src/shared/ui/Input/Input.svelte +++ b/src/shared/ui/Input/Input.svelte @@ -15,29 +15,53 @@ import type { } from './types'; interface Props extends Omit { + /** + * 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)}{/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 = { 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 = { default: { base: 'bg-paper dark:bg-paper border border-black/5 dark:border-white/10', diff --git a/src/shared/ui/Label/Label.svelte b/src/shared/ui/Label/Label.svelte index a19947f..908af2d 100644 --- a/src/shared/ui/Label/Label.svelte +++ b/src/shared/ui/Label/Label.svelte @@ -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; } diff --git a/src/shared/ui/Label/config.ts b/src/shared/ui/Label/config.ts index bdd4c55..d76f73b 100644 --- a/src/shared/ui/Label/config.ts +++ b/src/shared/ui/Label/config.ts @@ -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 = { 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 = { diff --git a/src/shared/ui/Loader/Loader.svelte b/src/shared/ui/Loader/Loader.svelte index 2b976d6..fe304e3 100644 --- a/src/shared/ui/Loader/Loader.svelte +++ b/src/shared/ui/Loader/Loader.svelte @@ -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; } diff --git a/src/shared/ui/Logo/Logo.svelte b/src/shared/ui/Logo/Logo.svelte index 98505a8..b3c098b 100644 --- a/src/shared/ui/Logo/Logo.svelte +++ b/src/shared/ui/Logo/Logo.svelte @@ -4,42 +4,23 @@ --> - -

- {title} -

- - -

- {#each title.split('') as letter} - {letter} - {/each} -

+
+

+ {title} +

+ BETA +
diff --git a/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte index ceb200e..4cfeae4 100644 --- a/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte +++ b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte @@ -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; } diff --git a/src/shared/ui/Section/Section.svelte b/src/shared/ui/Section/Section.svelte index 3b3f3d3..b4ad655 100644 --- a/src/shared/ui/Section/Section.svelte +++ b/src/shared/ui/Section/Section.svelte @@ -3,66 +3,73 @@ Provides a container for a page widget with snippets for a title --> + +{#if responsive.isMobile} + + {#if isOpen} + + + + +
+ {#if sidebar} + {@render sidebar({ onClose: close })} + {/if} +
+ {/if} +{:else} + +
+ +
+ {#if sidebar} + {@render sidebar({ onClose: close })} + {/if} +
+
+{/if} diff --git a/src/shared/ui/Skeleton/Skeleton.svelte b/src/shared/ui/Skeleton/Skeleton.svelte index 0cf9bd1..95cfd74 100644 --- a/src/shared/ui/Skeleton/Skeleton.svelte +++ b/src/shared/ui/Skeleton/Skeleton.svelte @@ -8,7 +8,8 @@ import type { HTMLAttributes } from 'svelte/elements'; interface Props extends HTMLAttributes { /** - * Whether to show the shimmer animation + * Shimmer animation + * @default true */ animate?: boolean; } diff --git a/src/shared/ui/Slider/Slider.stories.svelte b/src/shared/ui/Slider/Slider.stories.svelte index c0fa835..afdf92d 100644 --- a/src/shared/ui/Slider/Slider.stories.svelte +++ b/src/shared/ui/Slider/Slider.stories.svelte @@ -75,24 +75,6 @@ let valueHigh = $state(75); {/snippet} - - {#snippet template(args)} -
- -

Value: {args.value}

-

Dark mode: track uses neutral-800

-
- {/snippet} -
- {#snippet template(args)}
diff --git a/src/shared/ui/Slider/Slider.svelte b/src/shared/ui/Slider/Slider.svelte index 4fc4a37..1b481bc 100644 --- a/src/shared/ui/Slider/Slider.svelte +++ b/src/shared/ui/Slider/Slider.svelte @@ -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; } diff --git a/src/shared/ui/Stat/Stat.svelte b/src/shared/ui/Stat/Stat.svelte index 7a3fd31..80e6855 100644 --- a/src/shared/ui/Stat/Stat.svelte +++ b/src/shared/ui/Stat/Stat.svelte @@ -8,10 +8,22 @@ import { Label } from '$shared/ui'; import type { ComponentProps } from 'svelte'; interface Props extends Pick, '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; } diff --git a/src/shared/ui/Stat/StatGroup.svelte b/src/shared/ui/Stat/StatGroup.svelte index 8809790..662d45f 100644 --- a/src/shared/ui/Stat/StatGroup.svelte +++ b/src/shared/ui/Stat/StatGroup.svelte @@ -13,7 +13,13 @@ interface StatItem extends Partial, 'variant'>> } interface Props { + /** + * Stats array + */ stats: StatItem[]; + /** + * CSS classes + */ class?: string; } diff --git a/src/shared/ui/TechText/TechText.svelte b/src/shared/ui/TechText/TechText.svelte index 04dacbe..6e3de7a 100644 --- a/src/shared/ui/TechText/TechText.svelte +++ b/src/shared/ui/TechText/TechText.svelte @@ -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; } diff --git a/src/shared/ui/VirtualList/VirtualList.svelte b/src/shared/ui/VirtualList/VirtualList.svelte index def96af..ee13838 100644 --- a/src/shared/ui/VirtualList/VirtualList.svelte +++ b/src/shared/ui/VirtualList/VirtualList.svelte @@ -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, + '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 - * - * ``` + * 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} -
+
{@render content()}
{: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()}
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 5501aa9..b4aa258 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -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';