feat(ui): implement search and dropdown components with actions

This commit is contained in:
Sam Chau
2025-11-05 07:29:38 +10:30
parent 9b6b746ed6
commit 4bcbdd77c8
6 changed files with 329 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
/**
* Search store for managing search and filter state
*/
import { writable, derived, get } from 'svelte/store';
export interface SearchState {
query: string;
filters: Record<string, any>;
isActive: boolean;
}
export interface SearchStoreConfig {
debounceMs?: number;
caseSensitive?: boolean;
}
export function createSearchStore(config: SearchStoreConfig = {}) {
const { debounceMs = 300, caseSensitive = false } = config;
const state = writable<SearchState>({
query: '',
filters: {},
isActive: false
});
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Derived store for the debounced query
const debouncedQuery = writable('');
function setQuery(query: string) {
state.update((s) => ({ ...s, query, isActive: query.length > 0 }));
// Debounce the query update
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
debouncedQuery.set(query);
}, debounceMs);
}
function setFilter(key: string, value: any) {
state.update((s) => ({
...s,
filters: { ...s.filters, [key]: value },
isActive: true
}));
}
function removeFilter(key: string) {
state.update((s) => {
const { [key]: _, ...rest } = s.filters;
return {
...s,
filters: rest,
isActive: s.query.length > 0 || Object.keys(rest).length > 0
};
});
}
function clearFilters() {
state.update((s) => ({
...s,
filters: {},
isActive: s.query.length > 0
}));
}
function clear() {
state.set({
query: '',
filters: {},
isActive: false
});
debouncedQuery.set('');
if (debounceTimer) {
clearTimeout(debounceTimer);
}
}
// Helper function to filter an array of items
function filterItems<T>(
items: T[],
searchFields: (keyof T)[],
additionalFilter?: (item: T, filters: Record<string, any>) => boolean
): T[] {
const currentState = get(state);
const query = get(debouncedQuery);
if (!currentState.isActive) {
return items;
}
return items.filter((item) => {
// Search query filter
if (query) {
const matchesQuery = searchFields.some((field) => {
const value = String(item[field] ?? '');
return caseSensitive
? value.includes(query)
: value.toLowerCase().includes(query.toLowerCase());
});
if (!matchesQuery) {
return false;
}
}
// Additional custom filters
if (additionalFilter && Object.keys(currentState.filters).length > 0) {
return additionalFilter(item, currentState.filters);
}
return true;
});
}
// Derived store for easy access to whether search is active
const isActive = derived(state, ($state) => $state.isActive);
// Derived store for filter count
const filterCount = derived(state, ($state) => Object.keys($state.filters).length);
return {
subscribe: state.subscribe,
debouncedQuery: { subscribe: debouncedQuery.subscribe },
isActive: { subscribe: isActive.subscribe },
filterCount: { subscribe: filterCount.subscribe },
setQuery,
setFilter,
removeFilter,
clearFilters,
clear,
filterItems
};
}
export type SearchStore = ReturnType<typeof createSearchStore>;

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import type { ComponentType } from 'svelte';
export let icon: ComponentType | undefined = undefined;
export let square: boolean = true; // Fixed size square button
export let hasDropdown: boolean = false;
export let dropdownPosition: 'left' | 'right' | 'middle' = 'left';
let isHovered = false;
let leaveTimer: ReturnType<typeof setTimeout> | null = null;
function handleMouseEnter() {
if (leaveTimer) {
clearTimeout(leaveTimer);
leaveTimer = null;
}
isHovered = true;
}
function handleMouseLeave() {
// Small delay before closing to allow mouse to move to dropdown
leaveTimer = setTimeout(() => {
isHovered = false;
}, 100);
}
</script>
<div
class="relative flex"
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
role="group"
>
<button
class="flex items-center justify-center border border-neutral-200 bg-white transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700 {square
? 'h-10 w-10'
: 'h-10 px-4'}"
on:click
>
{#if icon}
<svelte:component this={icon} size={20} class="text-neutral-700 dark:text-neutral-300" />
{/if}
<slot />
</button>
{#if hasDropdown && isHovered}
<div transition:fly={{ y: -8, duration: 150 }}>
<slot name="dropdown" {dropdownPosition} open={isHovered} />
</div>
{/if}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
export let className: string = '';
</script>
<div class="actions-bar flex {className}">
<slot />
</div>
<style>
.actions-bar :global(> *:not(:first-child)) {
margin-left: -1px;
}
/* Apply rounding to the actual bordered elements inside first/last children */
.actions-bar :global(> *:first-child > *) {
border-top-left-radius: 0.5rem !important;
border-bottom-left-radius: 0.5rem !important;
}
.actions-bar :global(> *:last-child > *) {
border-top-right-radius: 0.5rem !important;
border-bottom-right-radius: 0.5rem !important;
}
.actions-bar :global(> *:only-child > *) {
border-radius: 0.5rem !important;
}
</style>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { Search, X } from 'lucide-svelte';
import type { SearchStore } from '$lib/client/stores/search';
export let searchStore: SearchStore;
export let placeholder: string = 'Search...';
let inputRef: HTMLInputElement;
let isFocused = false;
// Reactive query binding
$: query = $searchStore.query;
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
searchStore.setQuery(target.value);
}
function handleClear() {
searchStore.clear();
inputRef?.focus();
}
</script>
<div class="relative flex flex-1">
<div
class="relative flex h-10 w-full items-center border border-neutral-200 bg-white transition-all dark:border-neutral-700 dark:bg-neutral-800"
>
<!-- Search icon -->
<div class="pointer-events-none absolute left-3 flex items-center">
<Search size={18} class="text-neutral-500 dark:text-neutral-400" />
</div>
<!-- Input -->
<input
bind:this={inputRef}
type="text"
value={query}
on:input={handleInput}
on:focus={() => (isFocused = true)}
on:blur={() => (isFocused = false)}
{placeholder}
class="h-full w-full bg-transparent pl-10 pr-10 text-sm text-neutral-900 placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
/>
<!-- Clear button -->
{#if query}
<button
on:click={handleClear}
class="absolute right-2 flex h-6 w-6 items-center justify-center rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
<X size={14} class="text-neutral-500 dark:text-neutral-400" />
</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
export let position: 'left' | 'right' | 'middle' = 'left';
export let open: boolean = false;
export let minWidth: string = '12rem'; // Allow customization
// Compute position classes
$: positionClass = {
left: 'left-0',
right: 'right-0',
middle: 'left-1/2 -translate-x-1/2'
}[position];
</script>
<!-- Dropdown content - can be used standalone or within a trigger wrapper -->
<!-- Invisible hover bridge to keep dropdown open when moving mouse down -->
<div class="absolute top-full z-40 h-3 w-full" />
<div
class="absolute top-full z-50 mt-3 rounded-lg border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800 {positionClass}"
style="min-width: {minWidth}"
>
<slot />
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
import { Check } from 'lucide-svelte';
export let icon: ComponentType | undefined = undefined;
export let label: string;
export let disabled: boolean = false;
export let danger: boolean = false;
export let selected: boolean = false;
</script>
<button
class="flex w-full items-center gap-3 border-b border-neutral-200 px-4 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0 dark:border-neutral-700
{disabled
? 'cursor-not-allowed text-neutral-400 dark:text-neutral-600'
: danger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30'
: 'text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700'}"
{disabled}
on:click
>
{#if icon}
<svelte:component this={icon} size={16} />
{/if}
<span class="flex-1">{label}</span>
{#if selected}
<Check size={16} class="text-blue-600 dark:text-blue-400" />
{/if}
</button>