From cfdbd7771eb6cda57662cfc55a95d3cc32c5c3e6 Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 11 Feb 2026 16:55:32 -0600 Subject: [PATCH 1/2] feature/search-select --- .../components/ui/combobox/combobox.svelte | 123 ++++++++++++++ .../src/lib/components/ui/combobox/index.ts | 1 + .../src/lib/components/ui/select/index.ts | 21 ++- .../ui/select/select-content.svelte | 40 +++++ .../components/ui/select/select-item.svelte | 67 +++++--- .../ui/select/select-searchable.svelte | 157 ++++++++++++++++++ .../pedimentos/edit/[id]/+page.server.ts | 60 ++++--- .../pedimentos/edit/[id]/+page.svelte | 65 +++----- 8 files changed, 442 insertions(+), 92 deletions(-) create mode 100644 frontend/src/lib/components/ui/combobox/combobox.svelte create mode 100644 frontend/src/lib/components/ui/combobox/index.ts create mode 100644 frontend/src/lib/components/ui/select/select-searchable.svelte diff --git a/frontend/src/lib/components/ui/combobox/combobox.svelte b/frontend/src/lib/components/ui/combobox/combobox.svelte new file mode 100644 index 00000000..7ac23e05 --- /dev/null +++ b/frontend/src/lib/components/ui/combobox/combobox.svelte @@ -0,0 +1,123 @@ + + + +
+ + {selectedLabel} +
+ {#if value && !disabled} + + {/if} + +
+
+ + + +
+ +
+ +
+ {#if filteredItems.length === 0} +
+ {emptyMessage} +
+ {:else} + {#each filteredItems as item (item.value)} + 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' + )} + > + + {item.label} + + {/each} + {/if} +
+
+
+
+
diff --git a/frontend/src/lib/components/ui/combobox/index.ts b/frontend/src/lib/components/ui/combobox/index.ts new file mode 100644 index 00000000..a83ddc67 --- /dev/null +++ b/frontend/src/lib/components/ui/combobox/index.ts @@ -0,0 +1 @@ +export { default as Combobox } from './combobox.svelte'; diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts index 9e8d3e90..7e099815 100644 --- a/frontend/src/lib/components/ui/select/index.ts +++ b/frontend/src/lib/components/ui/select/index.ts @@ -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, }; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte index dc16d65d..0a3ef421 100644 --- a/frontend/src/lib/components/ui/select/select-content.svelte +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -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 & { portalProps?: SelectPrimitive.PortalProps; + searchable?: boolean; + searchPlaceholder?: string; + autoFocusSearch?: boolean; } = $props(); + + let searchQuery = $state(''); + let searchInputRef = $state(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(); + } + } @@ -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} > + + {#if searchable} +
+ e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + /> +
+ {/if} + = $props(); + }: WithoutChild & { 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); + }); - - {#snippet children({ selected, highlighted })} - - {#if selected} - +{#if isVisible()} + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} {/if} - - {#if childrenProp} - {@render childrenProp({ selected, highlighted })} - {:else} - {label || value} - {/if} - {/snippet} - + {/snippet} + +{/if} diff --git a/frontend/src/lib/components/ui/select/select-searchable.svelte b/frontend/src/lib/components/ui/select/select-searchable.svelte new file mode 100644 index 00000000..36820144 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-searchable.svelte @@ -0,0 +1,157 @@ + + + +
+
+ +
+ {#if value && !disabled} + + {/if} + + + +
+
+ + + + {#if filteredItems.length === 0} +
+ {emptyMessage} +
+ {:else} + {#each filteredItems as item (item.value)} + + {item.label} + + {/each} + {/if} +
+
+
+
diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts index a1a99d88..2df3627f 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.server.ts @@ -58,30 +58,46 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => { // Si el ID es "new", es una creación if (params.id === 'new') { - const [pedimentoCodesResponse, customsSectionsResponse, customsBrokersResponse, clientsResponse, codePedimentoRegimensResponse] = await Promise.all([ - pedimentoCodesPromise, - customsSectionsPromise, - customsBrokersPromise, - clientsPromise, - codePedimentoRegimensPromise - ]); + try { + const [pedimentoCodesResponse, customsSectionsResponse, customsBrokersResponse, clientsResponse, codePedimentoRegimensResponse] = await Promise.all([ + pedimentoCodesPromise, + customsSectionsPromise, + customsBrokersPromise, + clientsPromise, + codePedimentoRegimensPromise + ]); - const pedimentoCodes = pedimentoCodesResponse.ok ? await pedimentoCodesResponse.json() : { items: [] }; - const customsSections = customsSectionsResponse.ok ? await customsSectionsResponse.json() : { items: [] }; - const customsBrokers = customsBrokersResponse.ok ? await customsBrokersResponse.json() : { items: [] }; - const clients = clientsResponse.ok ? await clientsResponse.json() : { items: [] }; - const codePedimentoRegimens = codePedimentoRegimensResponse.ok ? await codePedimentoRegimensResponse.json() : { items: [] }; + const pedimentoCodes = pedimentoCodesResponse.ok ? await pedimentoCodesResponse.json() : { items: [] }; + const customsSections = customsSectionsResponse.ok ? await customsSectionsResponse.json() : { items: [] }; + const customsBrokers = customsBrokersResponse.ok ? await customsBrokersResponse.json() : { items: [] }; + const clients = clientsResponse.ok ? await clientsResponse.json() : { items: [] }; + const codePedimentoRegimens = codePedimentoRegimensResponse.ok ? await codePedimentoRegimensResponse.json() : { items: [] }; - return { - pedimento: null, - pedimentoId: null, - isCreate: true, - pedimentoCodes: pedimentoCodes.items || [], - customsSections: customsSections.items || [], - customsBrokers: customsBrokers.items || [], - clients: clients.items || [], - codePedimentoRegimens: codePedimentoRegimens.items || [] - }; + return { + pedimento: null, + pedimentoId: null, + isCreate: true, + pedimentoCodes: pedimentoCodes.items || [], + customsSections: customsSections.items || [], + customsBrokers: customsBrokers.items || [], + clients: clients.items || [], + codePedimentoRegimens: codePedimentoRegimens.items || [] + }; + } catch (e) { + console.error('❌ Error loading new pedimento data:', e); + // Graceful degradation: return empty data so the page loads, but show error + return { + pedimento: null, + pedimentoId: null, + isCreate: true, + pedimentoCodes: [], + customsSections: [], + customsBrokers: [], + clients: [], + codePedimentoRegimens: [], + error: 'Error al cargar catálogos. Verifique la conexión con el backend.' + }; + } } const pedimentoId = parseInt(params.id); diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte index 88a57648..a1243603 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte @@ -1,8 +1,8 @@ - - -
- - {selectedLabel} -
- {#if value && !disabled} - - {/if} - -
-
- - - -
- -
- -
- {#if filteredItems.length === 0} -
- {emptyMessage} -
- {:else} - {#each filteredItems as item (item.value)} - 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' - )} - > - - {item.label} - - {/each} - {/if} -
-
-
-
-
diff --git a/frontend/src/lib/components/ui/combobox/index.ts b/frontend/src/lib/components/ui/combobox/index.ts deleted file mode 100644 index a83ddc67..00000000 --- a/frontend/src/lib/components/ui/combobox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as Combobox } from './combobox.svelte'; diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts index 7e099815..8fc34655 100644 --- a/frontend/src/lib/components/ui/select/index.ts +++ b/frontend/src/lib/components/ui/select/index.ts @@ -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; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte index 0a3ef421..8293adf1 100644 --- a/frontend/src/lib/components/ui/select/select-content.svelte +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -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(selectSearchContextKey); let searchQuery = $state(''); + let isOpen = $state(false); let searchInputRef = $state(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 @@ updateQuery((event.currentTarget as HTMLInputElement).value)} onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} /> diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte index f842920b..8295bd90 100644 --- a/frontend/src/lib/components/ui/select/select-item.svelte +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -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 & { searchText?: string } = $props(); // Get search context - const searchContext = getContext<{ query: string } | undefined>('select-search'); + const searchContext = getContext(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); }); diff --git a/frontend/src/lib/components/ui/select/select-root.svelte b/frontend/src/lib/components/ui/select/select-root.svelte new file mode 100644 index 00000000..e6b706ed --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-root.svelte @@ -0,0 +1,33 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/select/select-search-context.ts b/frontend/src/lib/components/ui/select/select-search-context.ts new file mode 100644 index 00000000..d7a7e0b3 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-search-context.ts @@ -0,0 +1,9 @@ +import type { Writable } from "svelte/store"; + +export const selectSearchContextKey = Symbol("select-search"); + +export type SelectSearchContext = { + query: Writable; + open: Writable; + setOpen: (next: boolean) => void; +}; diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte index d405187d..de709160 100644 --- a/frontend/src/lib/components/ui/select/select-trigger.svelte +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -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 & { size?: "sm" | "default"; } = $props(); + + const searchContext = getContext(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(); + } + }