feature/search-select-search-in-trigger

This commit is contained in:
hreyes
2026-02-11 16:58:59 -06:00
parent cfdbd7771e
commit bbb4e46d58
8 changed files with 104 additions and 135 deletions

View File

@@ -1,123 +0,0 @@
<script lang="ts">
import { Combobox as ComboboxPrimitive } from 'bits-ui';
import { Check, ChevronsUpDown, X } from 'lucide-svelte';
import { cn } from '$lib/utils';
interface ComboboxItem {
value: string;
label: string;
disabled?: boolean;
}
interface ComboboxProps {
items: ComboboxItem[];
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
searchPlaceholder?: string;
emptyMessage?: string;
class?: string;
}
let {
items = [],
value = $bindable(''),
onValueChange,
placeholder = 'Seleccionar...',
disabled = false,
searchPlaceholder = 'Buscar...',
emptyMessage = 'No se encontraron resultados',
class: className = ''
}: ComboboxProps = $props();
let searchQuery = $state('');
let open = $state(false);
// Filter items based on search query
const filteredItems = $derived(
searchQuery
? items.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase()))
: items
);
// Get selected item label
const selectedLabel = $derived(items.find((item) => item.value === value)?.label || placeholder);
function handleValueChange(newValue: string) {
value = newValue;
onValueChange?.(newValue);
open = false;
searchQuery = '';
}
function handleClear(e: Event) {
e.stopPropagation();
handleValueChange('');
}
</script>
<ComboboxPrimitive.Root bind:open {disabled}>
<div class="relative {className}">
<ComboboxPrimitive.Trigger
class={cn(
'border-input data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 shadow-xs flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
!value && 'text-muted-foreground'
)}
>
<span class="truncate">{selectedLabel}</span>
<div class="flex items-center gap-1">
{#if value && !disabled}
<button
type="button"
onclick={handleClear}
class="hover:bg-accent rounded-sm p-0.5 transition-colors"
>
<X class="size-4" />
</button>
{/if}
<ChevronsUpDown class="size-4 opacity-50" />
</div>
</ComboboxPrimitive.Trigger>
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Content
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-[--bits-combobox-trigger-width] rounded-md border p-1 shadow-md outline-none'
)}
sideOffset={4}
>
<div class="border-input mb-1 flex items-center rounded-md border px-3">
<ComboboxPrimitive.Input
bind:value={searchQuery}
class="placeholder:text-muted-foreground flex h-9 w-full bg-transparent py-2 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={searchPlaceholder}
autofocus
/>
</div>
<div class="max-h-[300px] overflow-y-auto">
{#if filteredItems.length === 0}
<div class="text-muted-foreground py-6 text-center text-sm">
{emptyMessage}
</div>
{:else}
{#each filteredItems as item (item.value)}
<ComboboxPrimitive.Item
value={item.value}
disabled={item.disabled}
onSelect={() => handleValueChange(item.value)}
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
)}
>
<Check class={cn('size-4', value === item.value ? 'opacity-100' : 'opacity-0')} />
<span class="flex-1 truncate">{item.label}</span>
</ComboboxPrimitive.Item>
{/each}
{/if}
</div>
</ComboboxPrimitive.Content>
</ComboboxPrimitive.Portal>
</div>
</ComboboxPrimitive.Root>

View File

@@ -1 +0,0 @@
export { default as Combobox } from './combobox.svelte';

View File

@@ -3,6 +3,7 @@ import { Select as SelectPrimitive } from "bits-ui";
import Content from "./select-content.svelte"; import Content from "./select-content.svelte";
import Item from "./select-item.svelte"; import Item from "./select-item.svelte";
import Label from "./select-label.svelte"; import Label from "./select-label.svelte";
import Root from "./select-root.svelte";
import Trigger from "./select-trigger.svelte"; import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte"; import Separator from "./select-separator.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte"; import ScrollUpButton from "./select-scroll-up-button.svelte";
@@ -10,7 +11,6 @@ import ScrollDownButton from "./select-scroll-down-button.svelte";
import GroupHeading from "./select-group-heading.svelte"; import GroupHeading from "./select-group-heading.svelte";
import SearchableSelect from "./select-searchable.svelte"; import SearchableSelect from "./select-searchable.svelte";
const Root = SelectPrimitive.Root;
const Group = SelectPrimitive.Group; const Group = SelectPrimitive.Group;
const Input = SelectPrimitive.Input; const Input = SelectPrimitive.Input;
const Value = SelectPrimitive.Value; const Value = SelectPrimitive.Value;

View File

@@ -3,7 +3,8 @@
import SelectScrollUpButton from "./select-scroll-up-button.svelte"; import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte"; import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
import { setContext } from 'svelte'; import { getContext } from "svelte";
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -22,13 +23,37 @@
autoFocusSearch?: boolean; autoFocusSearch?: boolean;
} = $props(); } = $props();
const searchContext = getContext<SelectSearchContext | undefined>(selectSearchContextKey);
let searchQuery = $state(''); let searchQuery = $state('');
let isOpen = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null); let searchInputRef = $state<HTMLInputElement | null>(null);
// Provide search context to child items $effect(() => {
setContext('select-search', { if (!searchContext) return;
get query() { return searchQuery; } const unsubscribe = searchContext.query.subscribe((value) => {
searchQuery = value;
}); });
return unsubscribe;
});
$effect(() => {
if (!searchContext) return;
const unsubscribe = searchContext.open.subscribe((value) => {
isOpen = value;
});
return unsubscribe;
});
$effect(() => {
if (!searchable || !autoFocusSearch || !isOpen || !searchInputRef) return;
searchInputRef.focus();
searchInputRef.select();
});
function updateQuery(next: string) {
searchQuery = next;
searchContext?.query.set(next);
}
function handleContentKeydown(event: KeyboardEvent) { function handleContentKeydown(event: KeyboardEvent) {
if (!searchable || !searchInputRef) return; if (!searchable || !searchInputRef) return;
@@ -58,10 +83,11 @@
<input <input
type="text" type="text"
bind:this={searchInputRef} bind:this={searchInputRef}
bind:value={searchQuery} value={searchQuery}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
autofocus={autoFocusSearch} autofocus={autoFocusSearch}
class="placeholder:text-muted-foreground flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring" class="placeholder:text-muted-foreground flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
oninput={(event) => updateQuery((event.currentTarget as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
/> />

View File

@@ -2,7 +2,8 @@
import CheckIcon from "@lucide/svelte/icons/check"; import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
import { getContext } from 'svelte'; import { getContext } from "svelte";
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -15,16 +16,25 @@
}: WithoutChild<SelectPrimitive.ItemProps> & { searchText?: string } = $props(); }: WithoutChild<SelectPrimitive.ItemProps> & { searchText?: string } = $props();
// Get search context // Get search context
const searchContext = getContext<{ query: string } | undefined>('select-search'); const searchContext = getContext<SelectSearchContext | undefined>(selectSearchContextKey);
let query = $state('');
$effect(() => {
if (!searchContext) return;
const unsubscribe = searchContext.query.subscribe((value) => {
query = value;
});
return unsubscribe;
});
// Determine if item should be visible based on search // Determine if item should be visible based on search
const isVisible = $derived(() => { const isVisible = $derived(() => {
if (!searchContext) return true; if (!searchContext) return true;
const query = searchContext.query.toLowerCase(); const normalizedQuery = query.toLowerCase();
if (!query) return true; if (!normalizedQuery) return true;
const itemLabel = (searchText || label || value || '').toString().toLowerCase(); const itemLabel = (searchText || label || value || '').toString().toLowerCase();
return itemLabel.includes(query); return itemLabel.includes(normalizedQuery);
}); });
</script> </script>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { writable } from "svelte/store";
import { setContext } from "svelte";
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
import { type WithoutChild } from "$lib/utils.js";
let { children, ...restProps }: WithoutChild<SelectPrimitive.RootProps> = $props();
let open = $state(false);
const query = writable("");
const openStore = writable(false);
const context: SelectSearchContext = {
query,
open: openStore,
setOpen: (next) => {
open = next;
}
};
setContext(selectSearchContextKey, context);
$effect(() => {
openStore.set(open);
if (!open) {
query.set("");
}
});
</script>
<SelectPrimitive.Root bind:open {...restProps}>
{@render children?.()}
</SelectPrimitive.Root>

View File

@@ -0,0 +1,9 @@
import type { Writable } from "svelte/store";
export const selectSearchContextKey = Symbol("select-search");
export type SelectSearchContext = {
query: Writable<string>;
open: Writable<boolean>;
setOpen: (next: boolean) => void;
};

View File

@@ -2,6 +2,8 @@
import { Select as SelectPrimitive } from "bits-ui"; import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js"; import { cn, type WithoutChild } from "$lib/utils.js";
import { getContext } from "svelte";
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,12 +14,25 @@
}: WithoutChild<SelectPrimitive.TriggerProps> & { }: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default"; size?: "sm" | "default";
} = $props(); } = $props();
const searchContext = getContext<SelectSearchContext | undefined>(selectSearchContextKey);
function handleTriggerKeydown(event: KeyboardEvent) {
if (!searchContext) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (event.key.length === 1) {
searchContext.setOpen(true);
searchContext.query.update((value) => value + event.key);
event.preventDefault();
}
}
</script> </script>
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
bind:ref bind:ref
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
onkeydown={handleTriggerKeydown}
class={cn( class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className