feature/search-select
This commit is contained in:
123
frontend/src/lib/components/ui/combobox/combobox.svelte
Normal file
123
frontend/src/lib/components/ui/combobox/combobox.svelte
Normal 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>
|
||||
1
frontend/src/lib/components/ui/combobox/index.ts
Normal file
1
frontend/src/lib/components/ui/combobox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Combobox } from './combobox.svelte';
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
157
frontend/src/lib/components/ui/select/select-searchable.svelte
Normal file
157
frontend/src/lib/components/ui/select/select-searchable.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user