Prechádzať zdrojové kódy

feat: add filter list component

Thomas Zhang 2 mesiacov pred
rodič
commit
a42605e749

+ 3 - 0
src/lib/components/common/filter-list/index.ts

@@ -0,0 +1,3 @@
+import FilterList from './list.svelte';
+
+export { FilterList };

+ 131 - 0
src/lib/components/common/filter-list/list.svelte

@@ -0,0 +1,131 @@
+<script generics="T" lang="ts">
+	import { Plus, Search } from '@lucide/svelte';
+	import type { Snippet } from 'svelte';
+
+	import { Empty } from '$lib/components/common/empty';
+	import * as Filters from '$lib/components/common/filters';
+	import { List } from '$lib/components/common/list';
+	import { Button } from '$lib/components/controls/button';
+	import * as InputGroup from '$lib/components/controls/input-group';
+
+	let {
+		query = $bindable(),
+		filters = $bindable(),
+		items = [],
+		itemKey,
+		itemRenderer,
+		itemName = 'item',
+		itemNamePlural = 'items',
+		itemCount = items.length,
+		itemIcon = Search,
+		creationLink = '',
+	}: {
+		query: string;
+		filters: Filters.FilterRow[];
+		items: T[];
+		itemKey: (item: T) => string;
+		itemRenderer: Snippet<[T, number]>;
+		itemName?: string;
+		itemNamePlural?: string;
+		itemCount?: number;
+		itemIcon?: typeof Search;
+		creationLink?: string;
+	} = $props();
+	let filtersShown = $state(false);
+	let activeFilterCount = $derived.by(() => {
+		let count = query ? 1 : 0;
+		for (const row of filters) {
+			if (row.countSelected) {
+				count += row.countSelected();
+			} else if (row.type === 'multi') {
+				count += row.selected.size;
+			} else if (row.type === 'single' && row.selected !== row.default) {
+				count += 1;
+			}
+		}
+		return count;
+	});
+
+	const clearFilters = () => {
+		query = '';
+		filters = filters.map((row) => {
+			if (row.type === 'multi') {
+				row.selected.clear();
+				return row;
+			} else if (row.type === 'single') {
+				return { ...row, selected: row.default };
+			}
+			return row;
+		});
+	};
+</script>
+
+<div class="sticky top-0 z-10 bg-background/50 p-6 backdrop-blur-lg">
+	<!-- toolbar -->
+	<div class="mb-4 flex items-center gap-2">
+		<InputGroup.Root class="max-w-64 flex-1">
+			<InputGroup.Addon>
+				<Search size={14} strokeWidth={2} />
+			</InputGroup.Addon>
+			<InputGroup.Input placeholder="Search by name…" bind:value={query} />
+		</InputGroup.Root>
+		<Filters.Toggle
+			count={activeFilterCount}
+			shown={filtersShown}
+			onclick={() => (filtersShown = !filtersShown)}
+		/>
+		<div class="flex-1"></div>
+		<Button href={creationLink} size="sm">
+			<Plus size={14} strokeWidth={2} />
+			New
+		</Button>
+	</div>
+
+	<!-- filter panel -->
+	<Filters.Root class="mb-4" shown={filtersShown} bind:rows={filters} />
+
+	<!-- result count -->
+	{#if itemCount !== undefined}
+		<p class="text-xs text-white/30">
+			{#if items.length === itemCount}
+				{itemCount}
+				{itemCount === 1 ? itemName : itemNamePlural}
+			{:else}
+				{items.length} of {itemCount} {itemNamePlural}
+			{/if}
+		</p>
+	{/if}
+</div>
+
+<div class="p-6">
+	<!-- grid -->
+	{#if items.length > 0}
+		<List
+			class="mb-4 grid max-w-400 grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
+			{items}
+			key={(i) => itemKey(i)}
+		>
+			{#snippet itemComponent(item, idx)}
+				{@render itemRenderer(item, idx)}
+			{/snippet}
+		</List>
+	{:else}
+		<!-- empty states -->
+		<Empty icon={itemIcon}>
+			{#snippet caption()}
+				{#if activeFilterCount > 0}
+					<div class="text-sm text-white/45">No {itemNamePlural} match the filters</div>
+					<Button size="sm" variant="secondary" onclick={() => clearFilters()}>
+						Clear filters
+					</Button>
+				{:else}
+					<div class="text-sm text-white/45">No {itemNamePlural} yet</div>
+					<Button href={creationLink} size="sm">
+						<Plus size={14} strokeWidth={2} />
+						New {itemName}
+					</Button>
+				{/if}
+			{/snippet}
+		</Empty>
+	{/if}
+</div>