mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 19:01:02 +01:00
feat(ui): implement search and dropdown components with actions
This commit is contained in:
141
src/lib/client/stores/search.ts
Normal file
141
src/lib/client/stores/search.ts
Normal 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>;
|
||||
52
src/lib/client/ui/actions/ActionButton.svelte
Normal file
52
src/lib/client/ui/actions/ActionButton.svelte
Normal 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>
|
||||
28
src/lib/client/ui/actions/ActionsBar.svelte
Normal file
28
src/lib/client/ui/actions/ActionsBar.svelte
Normal 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>
|
||||
56
src/lib/client/ui/actions/SearchAction.svelte
Normal file
56
src/lib/client/ui/actions/SearchAction.svelte
Normal 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>
|
||||
23
src/lib/client/ui/dropdown/Dropdown.svelte
Normal file
23
src/lib/client/ui/dropdown/Dropdown.svelte
Normal 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>
|
||||
29
src/lib/client/ui/dropdown/DropdownItem.svelte
Normal file
29
src/lib/client/ui/dropdown/DropdownItem.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user