diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts index 9e8d3e90..8fc34655 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 Root from "./select-root.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..8293adf1 100644 --- a/frontend/src/lib/components/ui/select/select-content.svelte +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -3,6 +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 { getContext } from "svelte"; + import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context"; let { ref = $bindable(null), @@ -10,10 +12,56 @@ sideOffset = 4, portalProps, children, + searchable = true, + searchPlaceholder = "Buscar...", + autoFocusSearch = true, ...restProps }: WithoutChild & { portalProps?: SelectPrimitive.PortalProps; + searchable?: boolean; + searchPlaceholder?: string; + autoFocusSearch?: boolean; } = $props(); + + const searchContext = getContext(selectSearchContextKey); + let searchQuery = $state(''); + let isOpen = $state(false); + let searchInputRef = $state(null); + + $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; + if (event.key.length === 1) { + searchInputRef.focus(); + } + } @@ -21,6 +69,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 +77,23 @@ {...restProps} > + + {#if searchable} +
+ updateQuery((event.currentTarget as HTMLInputElement).value)} + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + /> +
+ {/if} + = $props(); + }: WithoutChild & { searchText?: string } = $props(); + + // Get search context + 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 normalizedQuery = query.toLowerCase(); + if (!normalizedQuery) return true; + + const itemLabel = (searchText || label || value || '').toString().toLowerCase(); + return itemLabel.includes(normalizedQuery); + }); - - {#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-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-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/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(); + } + } { // 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 85fab884..a918ea31 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 @@