feature/search-select

This commit is contained in:
hreyes
2026-02-11 16:55:32 -06:00
parent 573dd00056
commit cfdbd7771e
8 changed files with 442 additions and 92 deletions

View File

@@ -0,0 +1,123 @@
<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

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

View File

@@ -1,37 +1,46 @@
import { Select as SelectPrimitive } from "bits-ui";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Item from "./select-item.svelte";
import Label from "./select-label.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import SearchableSelect from "./select-searchable.svelte";
const Root = SelectPrimitive.Root;
const Group = SelectPrimitive.Group;
const Input = SelectPrimitive.Input;
const Value = SelectPrimitive.Value;
export {
Root,
Group,
Input,
Label,
Item,
Value,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
ScrollDownButton,
GroupHeading,
SearchableSelect,
//
Root as Select,
Group as SelectGroup,
Input as SelectInput,
Label as SelectLabel,
Item as SelectItem,
Value as SelectValue,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
ScrollDownButton as SelectScrollDownButton,
GroupHeading as SelectGroupHeading,
SearchableSelect as SelectSearchable,
};

View File

@@ -3,6 +3,7 @@
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import { setContext } from 'svelte';
let {
ref = $bindable(null),
@@ -10,10 +11,32 @@
sideOffset = 4,
portalProps,
children,
searchable = true,
searchPlaceholder = "Buscar...",
autoFocusSearch = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
searchable?: boolean;
searchPlaceholder?: string;
autoFocusSearch?: boolean;
} = $props();
let searchQuery = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null);
// Provide search context to child items
setContext('select-search', {
get query() { return searchQuery; }
});
function handleContentKeydown(event: KeyboardEvent) {
if (!searchable || !searchInputRef) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (event.key.length === 1) {
searchInputRef.focus();
}
}
</script>
<SelectPrimitive.Portal {...portalProps}>
@@ -21,6 +44,7 @@
bind:ref
{sideOffset}
data-slot="select-content"
onkeydown={handleContentKeydown}
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 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -28,6 +52,22 @@
{...restProps}
>
<SelectScrollUpButton />
{#if searchable}
<div class="border-input sticky top-0 z-10 bg-popover px-2 py-1.5">
<input
type="text"
bind:this={searchInputRef}
bind:value={searchQuery}
placeholder={searchPlaceholder}
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"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
/>
</div>
{/if}
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"

View File

@@ -2,39 +2,56 @@
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import { getContext } from 'svelte';
let {
ref = $bindable(null),
class: className,
value,
label,
searchText,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
}: WithoutChild<SelectPrimitive.ItemProps> & { searchText?: string } = $props();
// Get search context
const searchContext = getContext<{ query: string } | undefined>('select-search');
// Determine if item should be visible based on search
const isVisible = $derived(() => {
if (!searchContext) return true;
const query = searchContext.query.toLowerCase();
if (!query) return true;
const itemLabel = (searchText || label || value || '').toString().toLowerCase();
return itemLabel.includes(query);
});
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
tabindex="0"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{#if isVisible()}
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
tabindex="0"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
{/snippet}
</SelectPrimitive.Item>
{/if}

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { Combobox as ComboboxPrimitive } from 'bits-ui';
import { ChevronDown, X } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
interface Item {
value: string;
label: string;
}
let {
items = [],
value = $bindable(''),
onValueChange,
placeholder = 'Seleccionar...',
searchPlaceholder = 'Buscar...',
emptyMessage = 'No se encontraron resultados',
class: className = '',
disabled = false
}: {
items: Item[];
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
class?: string;
disabled?: boolean;
} = $props();
let searchQuery = $state('');
let open = $state(false);
let isTyping = $state(false);
let containerWidth = $state(0);
// Get selected item label
const selectedLabel = $derived((items ?? []).find((item) => item.value === value)?.label || '');
// Filter items based on search query
// Show all items unless the user is explicitly typing
const filteredItems = $derived(
!isTyping || searchQuery.trim() === ''
? (items ?? [])
: (items ?? []).filter(
(item) =>
(item.label ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(item.value ?? '').toLowerCase().includes(searchQuery.toLowerCase())
)
);
function handleValueChange(v: string) {
value = v;
onValueChange?.(v);
open = false;
isTyping = false;
}
function handleClear(e: Event) {
e.stopPropagation();
value = '';
onValueChange?.('');
searchQuery = '';
isTyping = false;
}
function handleInput() {
isTyping = true;
}
function handleFocus() {
if (!open) {
open = true;
isTyping = false; // Show all items on initial focus
}
}
// Sincronizar el input con el valor seleccionado cuando se cierra
$effect(() => {
if (!open) {
searchQuery = selectedLabel;
isTyping = false;
}
});
// Sincronización inicial o cambio externo
$effect(() => {
if (!open && value) {
searchQuery = selectedLabel;
}
});
</script>
<ComboboxPrimitive.Root bind:open {disabled} onValueChange={handleValueChange}>
<div class="relative {className}" bind:clientWidth={containerWidth}>
<div class="relative">
<ComboboxPrimitive.Input
bind:value={searchQuery}
{placeholder}
aria-label={placeholder}
class={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 shadow-xs flex h-9 w-full items-center rounded-md border bg-transparent px-3 py-2 pr-24 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
!value && !searchQuery && 'text-muted-foreground'
)}
oninput={handleInput}
onfocus={handleFocus}
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{#if value && !disabled}
<button
type="button"
tabindex="-1"
onclick={handleClear}
aria-label="Limpiar selección"
class="hover:bg-accent focus-visible:bg-accent rounded-sm p-1 transition-colors outline-none"
>
<X class="size-4" />
</button>
{/if}
<ComboboxPrimitive.Trigger
tabindex="-1"
aria-label="Abrir opciones"
class="hover:bg-accent focus-visible:bg-accent rounded-sm p-1 transition-colors outline-none"
>
<ChevronDown class="size-4 opacity-50" />
</ComboboxPrimitive.Trigger>
</div>
</div>
<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 max-h-[300px] overflow-y-auto rounded-md border p-1 shadow-md outline-none'
)}
style="width: {containerWidth > 0 ? containerWidth + 'px' : '300px'}"
sideOffset={4}
>
{#if filteredItems.length === 0}
<div class="text-muted-foreground py-6 text-center text-sm italic">
{emptyMessage}
</div>
{:else}
{#each filteredItems as item (item.value)}
<ComboboxPrimitive.Item
value={item.value}
label={item.label}
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
)}
>
<span class="flex-1 truncate">{item.label}</span>
</ComboboxPrimitive.Item>
{/each}
{/if}
</ComboboxPrimitive.Content>
</ComboboxPrimitive.Portal>
</div>
</ComboboxPrimitive.Root>