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 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;
|
||||||
|
|||||||
@@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
Reference in New Issue
Block a user