From cfdbd7771eb6cda57662cfc55a95d3cc32c5c3e6 Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 11 Feb 2026 16:55:32 -0600 Subject: [PATCH] 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 @@