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

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