feature/search-select-search-in-trigger
This commit is contained in:
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Combobox } from './combobox.svelte';
|
||||
@@ -3,6 +3,7 @@ import { Select as SelectPrimitive } from "bits-ui";
|
||||
import Content from "./select-content.svelte";
|
||||
import Item from "./select-item.svelte";
|
||||
import Label from "./select-label.svelte";
|
||||
import Root from "./select-root.svelte";
|
||||
import Trigger from "./select-trigger.svelte";
|
||||
import Separator from "./select-separator.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 SearchableSelect from "./select-searchable.svelte";
|
||||
|
||||
const Root = SelectPrimitive.Root;
|
||||
const Group = SelectPrimitive.Group;
|
||||
const Input = SelectPrimitive.Input;
|
||||
const Value = SelectPrimitive.Value;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
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';
|
||||
import { getContext } from "svelte";
|
||||
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -22,14 +23,38 @@
|
||||
autoFocusSearch?: boolean;
|
||||
} = $props();
|
||||
|
||||
const searchContext = getContext<SelectSearchContext | undefined>(selectSearchContextKey);
|
||||
let searchQuery = $state('');
|
||||
let isOpen = $state(false);
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Provide search context to child items
|
||||
setContext('select-search', {
|
||||
get query() { return searchQuery; }
|
||||
$effect(() => {
|
||||
if (!searchContext) return;
|
||||
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) {
|
||||
if (!searchable || !searchInputRef) return;
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
@@ -58,10 +83,11 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInputRef}
|
||||
bind:value={searchQuery}
|
||||
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"
|
||||
oninput={(event) => updateQuery((event.currentTarget as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
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';
|
||||
import { getContext } from "svelte";
|
||||
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -15,16 +16,25 @@
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> & { searchText?: string } = $props();
|
||||
|
||||
// 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
|
||||
const isVisible = $derived(() => {
|
||||
if (!searchContext) return true;
|
||||
const query = searchContext.query.toLowerCase();
|
||||
if (!query) return true;
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const itemLabel = (searchText || label || value || '').toString().toLowerCase();
|
||||
return itemLabel.includes(query);
|
||||
return itemLabel.includes(normalizedQuery);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
33
frontend/src/lib/components/ui/select/select-root.svelte
Normal file
33
frontend/src/lib/components/ui/select/select-root.svelte
Normal 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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import { getContext } from "svelte";
|
||||
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -12,12 +14,25 @@
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: "sm" | "default";
|
||||
} = $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>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
onkeydown={handleTriggerKeydown}
|
||||
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",
|
||||
className
|
||||
|
||||
Reference in New Issue
Block a user