@pagamio/frontend-commons-lib 0.8.332 → 0.8.334

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ import * as React from 'react';
2
+ import type { UsePagamioComboboxReturn } from '../../shared/hooks/usePagamioCombobox';
3
+ export interface PagamioSearchableComboboxProps<T extends {
4
+ id: string;
5
+ }> {
6
+ /** State from usePagamioCombobox(). */
7
+ combobox: UsePagamioComboboxReturn<T>;
8
+ /** Controlled selected id, or null when nothing is selected. */
9
+ value: string | null;
10
+ onChange: (id: string | null) => void;
11
+ /**
12
+ * Optional pre-resolved selected option. When set, the trigger shows its
13
+ * label without needing to fetch via `restoreIds`. If both this and the
14
+ * hook's `restoredOptions` are available, this wins.
15
+ */
16
+ selectedOption?: T | null;
17
+ /** Trigger placeholder when nothing is selected. */
18
+ placeholder?: string;
19
+ /** Search-input placeholder inside the dropdown. */
20
+ searchPlaceholder?: string;
21
+ /** Shown in dropdown when no results match. */
22
+ emptyMessage?: string;
23
+ /** Shown at the bottom of the list when the server returned a full page. */
24
+ cappedMessage?: string;
25
+ /** Shown while loading. */
26
+ loadingMessage?: string;
27
+ disabled?: boolean;
28
+ required?: boolean;
29
+ name?: string;
30
+ /** How to render an option as a string label. Default: `o.name` (must exist). */
31
+ getLabel?: (option: T) => string;
32
+ /** Optional custom row renderer inside the dropdown. */
33
+ renderOption?: (option: T, isSelected: boolean) => React.ReactNode;
34
+ /** Optional custom trigger label renderer. */
35
+ renderSelected?: (option: T) => React.ReactNode;
36
+ className?: string;
37
+ triggerClassName?: string;
38
+ contentClassName?: string;
39
+ }
40
+ export declare function PagamioSearchableCombobox<T extends {
41
+ id: string;
42
+ }>({ combobox, value, onChange, selectedOption, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }: PagamioSearchableComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * PagamioSearchableCombobox — server-driven, debounced, single-select combobox.
4
+ *
5
+ * Visual contract: matches the look & feel of `Select` + `SelectContent` with
6
+ * the embedded search input (the "Authentication Method" picker in the
7
+ * employees form is the reference). Trigger styling, dropdown shadow/border,
8
+ * and selected-item highlight all mirror `Select`.
9
+ *
10
+ * State is owned by `usePagamioCombobox` (created by the caller).
11
+ *
12
+ * See .agent/conventions/lookup-endpoints.md (backend) for the contract.
13
+ */
14
+ import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
15
+ import * as React from 'react';
16
+ import { cn } from '../../helpers';
17
+ import { Popover, PopoverContent, PopoverTrigger } from './Popover';
18
+ function defaultGetLabel(option) {
19
+ const o = option;
20
+ return o.name ?? option.id;
21
+ }
22
+ export function PagamioSearchableCombobox({ combobox, value, onChange, selectedOption, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }) {
23
+ const [open, setOpen] = React.useState(false);
24
+ const inputRef = React.useRef(null);
25
+ // Focus the search input when the popover opens — matches the Select's
26
+ // built-in behavior where the search field is immediately ready.
27
+ React.useEffect(() => {
28
+ if (open) {
29
+ const id = window.setTimeout(() => inputRef.current?.focus(), 0);
30
+ return () => window.clearTimeout(id);
31
+ }
32
+ }, [open]);
33
+ // Resolve the option object for the current value: caller-supplied wins,
34
+ // then the hook's options (top-N), then restored.
35
+ const resolvedSelected = React.useMemo(() => {
36
+ if (selectedOption && selectedOption.id === value)
37
+ return selectedOption;
38
+ if (!value)
39
+ return null;
40
+ return combobox.options.find((o) => o.id === value) ?? combobox.restoredOptions.find((o) => o.id === value) ?? null;
41
+ }, [selectedOption, value, combobox.options, combobox.restoredOptions]);
42
+ const handleSelect = (id) => {
43
+ onChange(id === value ? null : id);
44
+ setOpen(false);
45
+ };
46
+ return (_jsxs("div", { className: cn('w-full', className), children: [_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-required": required, disabled: disabled, name: name, className: cn(
47
+ // Mirrors SelectTrigger styling exactly.
48
+ 'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-background text-foreground px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground', 'focus:outline-none focus:ring-1 focus:ring-ring', 'disabled:cursor-not-allowed disabled:opacity-50', '[&>span]:line-clamp-1', triggerClassName), children: [_jsx("span", { className: cn('line-clamp-1 text-left flex-1', !resolvedSelected && 'text-muted-foreground'), children: resolvedSelected
49
+ ? renderSelected
50
+ ? renderSelected(resolvedSelected)
51
+ : getLabel(resolvedSelected)
52
+ : placeholder }), _jsx(ChevronDownIcon, { className: "ml-2 h-4 w-4 opacity-50 shrink-0" })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 4,
53
+ // Mirrors SelectContent: same shadow + animation classes, popover
54
+ // background, popover-foreground text. Width matches trigger.
55
+ className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }), _jsx("div", { className: "p-1 max-h-[200px] overflow-y-auto", children: combobox.isLoading && combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: loadingMessage })) : combobox.error ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-destructive", children: combobox.error.message || 'Failed to load options.' })) : combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: emptyMessage })) : (_jsxs(_Fragment, { children: [combobox.options.map((option) => {
56
+ const isSelected = option.id === value;
57
+ return (_jsx("button", { type: "button", role: "option", "aria-selected": isSelected, onClick: () => handleSelect(option.id), className: cn(
58
+ // Mirrors SelectItem styling: padding, hover via
59
+ // focus:bg-muted, selected via bg-accent.
60
+ 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left', 'hover:bg-muted focus:bg-muted focus:text-foreground', isSelected && 'bg-accent text-foreground'), children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "line-clamp-1", children: getLabel(option) }), isSelected ? (_jsx("span", { className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(CheckIcon, { className: "h-4 w-4" }) })) : null] })) }, option.id));
61
+ }), combobox.isCapped ? (_jsx("div", { className: "mt-1 border-t px-2 py-1.5 text-xs text-muted-foreground", children: cappedMessage })) : null] })) })] })] }), name ? _jsx("input", { type: "hidden", name: name, value: value ?? '' }) : null] }));
62
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from 'react';
2
+ import type { UsePagamioComboboxReturn } from '../../shared/hooks/usePagamioCombobox';
3
+ export interface PagamioSearchableMultiComboboxProps<T extends {
4
+ id: string;
5
+ }> {
6
+ combobox: UsePagamioComboboxReturn<T>;
7
+ value: string[];
8
+ onChange: (ids: string[]) => void;
9
+ /**
10
+ * Pre-resolved selected options. Used to label chips for ids that aren't
11
+ * in the current top-N. If omitted, the component falls back to
12
+ * `combobox.restoredOptions` (configure `restoreIds` on the hook).
13
+ */
14
+ selectedOptions?: readonly T[];
15
+ placeholder?: string;
16
+ searchPlaceholder?: string;
17
+ emptyMessage?: string;
18
+ cappedMessage?: string;
19
+ loadingMessage?: string;
20
+ disabled?: boolean;
21
+ required?: boolean;
22
+ name?: string;
23
+ getLabel?: (option: T) => string;
24
+ renderOption?: (option: T, isSelected: boolean) => React.ReactNode;
25
+ renderChip?: (option: T, onRemove: () => void) => React.ReactNode;
26
+ className?: string;
27
+ triggerClassName?: string;
28
+ contentClassName?: string;
29
+ }
30
+ export declare function PagamioSearchableMultiCombobox<T extends {
31
+ id: string;
32
+ }>({ combobox, value, onChange, selectedOptions, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderChip, className, triggerClassName, contentClassName, }: PagamioSearchableMultiComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * PagamioSearchableMultiCombobox — server-driven, debounced, multi-select combobox.
4
+ *
5
+ * Visual contract: matches the look & feel of `Select` + `SelectContent`
6
+ * (the "Authentication Method" reference). Trigger styling is identical
7
+ * except for chip rendering. Dropdown shadow/border, embedded search,
8
+ * and item highlight all mirror `Select` / `PagamioSearchableCombobox`.
9
+ *
10
+ * Sibling of PagamioSearchableCombobox. Differences:
11
+ * - `value` is `string[]`, `onChange` toggles ids.
12
+ * - The trigger shows chips for each selected option (resolved via the
13
+ * hook's `restoredOptions` OR `selectedOptions` prop).
14
+ * - The dropdown stays open while toggling — supports multi-pick flow.
15
+ *
16
+ * See .agent/conventions/lookup-endpoints.md (backend) for the contract.
17
+ */
18
+ import { ChevronDownIcon, Cross2Icon } from '@radix-ui/react-icons';
19
+ import * as React from 'react';
20
+ import { cn } from '../../helpers';
21
+ import { Popover, PopoverContent, PopoverTrigger } from './Popover';
22
+ function defaultGetLabel(option) {
23
+ const o = option;
24
+ return o.name ?? option.id;
25
+ }
26
+ export function PagamioSearchableMultiCombobox({ combobox, value, onChange, selectedOptions, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderChip, className, triggerClassName, contentClassName, }) {
27
+ const [open, setOpen] = React.useState(false);
28
+ const inputRef = React.useRef(null);
29
+ React.useEffect(() => {
30
+ if (open) {
31
+ const id = window.setTimeout(() => inputRef.current?.focus(), 0);
32
+ return () => window.clearTimeout(id);
33
+ }
34
+ }, [open]);
35
+ // Resolve labels for currently-selected ids. Caller's `selectedOptions`
36
+ // wins; otherwise look in the hook's options + restoredOptions.
37
+ const selectedById = React.useMemo(() => {
38
+ const map = new Map();
39
+ const sources = [selectedOptions ?? [], combobox.options, combobox.restoredOptions];
40
+ for (const source of sources) {
41
+ for (const option of source) {
42
+ if (!map.has(option.id))
43
+ map.set(option.id, option);
44
+ }
45
+ }
46
+ return map;
47
+ }, [selectedOptions, combobox.options, combobox.restoredOptions]);
48
+ const chips = React.useMemo(() => value.map((id) => selectedById.get(id)).filter((o) => o !== undefined), [value, selectedById]);
49
+ const unresolvedIds = React.useMemo(() => value.filter((id) => !selectedById.has(id)), [value, selectedById]);
50
+ const toggle = (id) => {
51
+ onChange(value.includes(id) ? value.filter((v) => v !== id) : [...value, id]);
52
+ };
53
+ const remove = (id) => onChange(value.filter((v) => v !== id));
54
+ const hasSelection = value.length > 0;
55
+ return (_jsxs("div", { className: cn('w-full', className), children: [_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-required": required, disabled: disabled, className: cn(
56
+ // Mirrors SelectTrigger styling. Slightly taller min-height so
57
+ // chips fit comfortably.
58
+ 'flex min-h-9 w-full items-center justify-between rounded-md border bg-background text-foreground px-3 py-1.5 text-sm shadow-sm ring-offset-background', 'focus:outline-none focus:ring-1 focus:ring-ring', 'disabled:cursor-not-allowed disabled:opacity-50', triggerClassName), children: [_jsx("div", { className: "flex flex-wrap gap-1 items-center flex-1 min-w-0", children: hasSelection ? (_jsxs(_Fragment, { children: [chips.map((option) => renderChip ? (_jsx(React.Fragment, { children: renderChip(option, () => remove(option.id)) }, option.id)) : (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 text-xs text-foreground", children: [_jsx("span", { className: "line-clamp-1 max-w-[160px]", children: getLabel(option) }), _jsx("span", { role: "button", tabIndex: -1, "aria-label": `Remove ${getLabel(option)}`, onClick: (e) => {
59
+ e.stopPropagation();
60
+ remove(option.id);
61
+ }, onMouseDown: (e) => e.preventDefault(), className: "inline-flex h-3 w-3 items-center justify-center opacity-60 hover:opacity-100", children: _jsx(Cross2Icon, { className: "h-3 w-3" }) })] }, option.id))), unresolvedIds.map((id) => (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 text-xs text-muted-foreground italic", children: [_jsx("span", { className: "line-clamp-1 max-w-[140px]", children: id }), _jsx("span", { role: "button", tabIndex: -1, "aria-label": "Remove", onClick: (e) => {
62
+ e.stopPropagation();
63
+ remove(id);
64
+ }, onMouseDown: (e) => e.preventDefault(), className: "inline-flex h-3 w-3 items-center justify-center opacity-60 hover:opacity-100", children: _jsx(Cross2Icon, { className: "h-3 w-3" }) })] }, id)))] })) : (_jsx("span", { className: "text-muted-foreground", children: placeholder })) }), _jsx(ChevronDownIcon, { className: "ml-2 h-4 w-4 opacity-50 shrink-0" })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 4, className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }), _jsx("div", { className: "p-1 max-h-[200px] overflow-y-auto", children: combobox.isLoading && combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: loadingMessage })) : combobox.error ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-destructive", children: combobox.error.message || 'Failed to load options.' })) : combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: emptyMessage })) : (_jsxs(_Fragment, { children: [combobox.options.map((option) => {
65
+ const isSelected = value.includes(option.id);
66
+ return (_jsx("button", { type: "button", role: "option", "aria-selected": isSelected, onClick: () => toggle(option.id), className: cn('relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left', 'hover:bg-muted focus:bg-muted focus:text-foreground', isSelected && 'bg-accent text-foreground'), children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: cn('mr-2 inline-flex h-4 w-4 items-center justify-center rounded-sm border', isSelected ? 'bg-primary text-primary-foreground border-primary' : 'border-input'), children: isSelected ? '✓' : null }), _jsx("span", { className: "line-clamp-1", children: getLabel(option) })] })) }, option.id));
67
+ }), combobox.isCapped ? (_jsx("div", { className: "mt-1 border-t px-2 py-1.5 text-xs text-muted-foreground", children: cappedMessage })) : null] })) })] })] }), name ? value.map((id) => _jsx("input", { type: "hidden", name: name, value: id }, id)) : null] }));
68
+ }
@@ -6,6 +6,8 @@ export * from './Avatar';
6
6
  export * from './Command';
7
7
  export * from './Dialog';
8
8
  export * from './Form';
9
+ export * from './PagamioSearchableCombobox';
10
+ export * from './PagamioSearchableMultiCombobox';
9
11
  export * from './Popover';
10
12
  export * from './Radio';
11
13
  export * from './Select';
@@ -5,6 +5,8 @@ export * from './Avatar';
5
5
  export * from './Command';
6
6
  export * from './Dialog';
7
7
  export * from './Form';
8
+ export * from './PagamioSearchableCombobox';
9
+ export * from './PagamioSearchableMultiCombobox';
8
10
  export * from './Popover';
9
11
  export * from './Radio';
10
12
  export * from './Select';
@@ -0,0 +1,13 @@
1
+ import type { Field } from '../../../types';
2
+ interface SearchableComboboxInputProps {
3
+ field: Field;
4
+ error?: {
5
+ message?: string;
6
+ };
7
+ value?: string | null;
8
+ onChange?: (value: string | null) => void;
9
+ onBlur?: () => void;
10
+ name?: string;
11
+ }
12
+ declare const SearchableComboboxInput: import("react").ForwardRefExoticComponent<SearchableComboboxInputProps & import("react").RefAttributes<HTMLDivElement>>;
13
+ export default SearchableComboboxInput;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Form-engine input that wires `usePagamioCombobox` + `PagamioSearchableCombobox`
4
+ * into the form-engine's field declaration shape.
5
+ *
6
+ * Field declaration:
7
+ *
8
+ * {
9
+ * name: "storeId",
10
+ * label: "Store",
11
+ * type: "searchable-combobox",
12
+ * source: {
13
+ * fetcher: (params) => getStoresLookup({ businessUnitId, ...params }),
14
+ * queryKey: ["stores", "lookup", businessUnitId],
15
+ * },
16
+ * placeholder: "Search stores…",
17
+ * }
18
+ */
19
+ import { forwardRef, useMemo } from 'react';
20
+ import { PagamioSearchableCombobox } from '../../../../components/ui/PagamioSearchableCombobox';
21
+ import { usePagamioCombobox } from '../../../../shared/hooks/usePagamioCombobox';
22
+ const SearchableComboboxInput = forwardRef(({ field, value, onChange }, ref) => {
23
+ const source = field.source;
24
+ if (!source) {
25
+ // Misconfigured field. Render a stable no-op so the form doesn't crash.
26
+ // The convention requires `source` for searchable-combobox.
27
+ return (_jsxs("div", { ref: ref, className: "rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive", children: ["Field ", _jsx("code", { children: field.name }), " of type ", _jsx("code", { children: "searchable-combobox" }), " is missing the ", _jsx("code", { children: "source" }), ' ', "spec."] }));
28
+ }
29
+ const restoreIds = useMemo(() => (value ? [value] : undefined), [value]);
30
+ const combobox = usePagamioCombobox({
31
+ queryKey: source.queryKey,
32
+ queryFn: source.fetcher,
33
+ pageSize: source.pageSize,
34
+ debounceMs: source.debounceMs,
35
+ restoreIds,
36
+ });
37
+ return (_jsx("div", { ref: ref, children: _jsx(PagamioSearchableCombobox, { combobox: combobox, value: value ?? null, onChange: (id) => onChange?.(id), placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel }) }));
38
+ });
39
+ SearchableComboboxInput.displayName = 'SearchableComboboxInput';
40
+ export default SearchableComboboxInput;
@@ -0,0 +1,13 @@
1
+ import type { Field } from '../../../types';
2
+ interface SearchableMultiComboboxInputProps {
3
+ field: Field;
4
+ error?: {
5
+ message?: string;
6
+ };
7
+ value?: string[];
8
+ onChange?: (value: string[]) => void;
9
+ onBlur?: () => void;
10
+ name?: string;
11
+ }
12
+ declare const SearchableMultiComboboxInput: import("react").ForwardRefExoticComponent<SearchableMultiComboboxInputProps & import("react").RefAttributes<HTMLDivElement>>;
13
+ export default SearchableMultiComboboxInput;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Form-engine input that wires `usePagamioCombobox` +
4
+ * `PagamioSearchableMultiCombobox` into the form-engine's field declaration shape.
5
+ *
6
+ * Field declaration:
7
+ *
8
+ * {
9
+ * name: "productIds",
10
+ * label: "Products",
11
+ * type: "searchable-multi-combobox",
12
+ * source: {
13
+ * fetcher: (params) => getProductsLookup({ organizationId, ...params }),
14
+ * queryKey: ["products", "lookup", organizationId],
15
+ * },
16
+ * placeholder: "Add products…",
17
+ * }
18
+ */
19
+ import { forwardRef, useMemo } from 'react';
20
+ import { PagamioSearchableMultiCombobox } from '../../../../components/ui/PagamioSearchableMultiCombobox';
21
+ import { usePagamioCombobox } from '../../../../shared/hooks/usePagamioCombobox';
22
+ const SearchableMultiComboboxInput = forwardRef(({ field, value, onChange }, ref) => {
23
+ const source = field.source;
24
+ if (!source) {
25
+ return (_jsxs("div", { ref: ref, className: "rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive", children: ["Field ", _jsx("code", { children: field.name }), " of type ", _jsx("code", { children: "searchable-multi-combobox" }), " is missing the", ' ', _jsx("code", { children: "source" }), " spec."] }));
26
+ }
27
+ const restoreIds = useMemo(() => (value && value.length > 0 ? value : undefined), [value]);
28
+ const combobox = usePagamioCombobox({
29
+ queryKey: source.queryKey,
30
+ queryFn: source.fetcher,
31
+ pageSize: source.pageSize,
32
+ debounceMs: source.debounceMs,
33
+ restoreIds,
34
+ });
35
+ return (_jsx("div", { ref: ref, children: _jsx(PagamioSearchableMultiCombobox, { combobox: combobox, value: value ?? [], onChange: (ids) => onChange?.(ids), placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel }) }));
36
+ });
37
+ SearchableMultiComboboxInput.displayName = 'SearchableMultiComboboxInput';
38
+ export default SearchableMultiComboboxInput;
@@ -31,7 +31,7 @@ export const setupInputRegistry = async () => {
31
31
  return getRegistryPromise();
32
32
  };
33
33
  const doSetup = async () => {
34
- const [{ default: CardExpiryInput }, { default: CheckboxInput }, { default: ColorInput }, { default: CreditCardInput }, { default: DateInput }, { default: EmailInput }, { default: LabelInput }, { default: MultiSelectInputComponent }, { default: NumberInput }, { default: PasswordInput }, { default: RadioInput }, { default: SelectInput }, { default: TextareaInputFW }, { default: TimeInput }, { default: ToggleSwitchInput }, { default: UploadFieldForm }, { default: PhoneInput }, { default: SearchableMultiSelectInput },] = await Promise.all([
34
+ const [{ default: CardExpiryInput }, { default: CheckboxInput }, { default: ColorInput }, { default: CreditCardInput }, { default: DateInput }, { default: EmailInput }, { default: LabelInput }, { default: MultiSelectInputComponent }, { default: NumberInput }, { default: PasswordInput }, { default: RadioInput }, { default: SelectInput }, { default: TextareaInputFW }, { default: TimeInput }, { default: ToggleSwitchInput }, { default: UploadFieldForm }, { default: PhoneInput }, { default: SearchableMultiSelectInput }, { default: SearchableComboboxInput }, { default: SearchableMultiComboboxInput },] = await Promise.all([
35
35
  import('../components/inputs/card-expiry-input/CardExpiryInput'),
36
36
  import('../components/inputs/checkbox-input/CheckboxInput'),
37
37
  import('../components/inputs/color-input/ColorInput'),
@@ -50,6 +50,8 @@ const doSetup = async () => {
50
50
  import('../components/inputs/upload-field/UploadFieldForm'),
51
51
  import('../../components/ui/PhoneInput'),
52
52
  import('../components/inputs/searchable-multi-select/SearchableMultiSelectInput'),
53
+ import('../components/inputs/searchable-combobox/SearchableComboboxInput'),
54
+ import('../components/inputs/searchable-multi-combobox/SearchableMultiComboboxInput'),
53
55
  ]);
54
56
  registerInput('card-expiry-input', CardExpiryInput);
55
57
  registerInput('checkbox', CheckboxInput);
@@ -69,5 +71,7 @@ const doSetup = async () => {
69
71
  registerInput('textarea', TextareaInputFW);
70
72
  registerInput('time', TimeInput);
71
73
  registerInput('searchable-multi-select', SearchableMultiSelectInput);
74
+ registerInput('searchable-combobox', SearchableComboboxInput);
75
+ registerInput('searchable-multi-combobox', SearchableMultiComboboxInput);
72
76
  registryDone = true;
73
77
  };
@@ -130,7 +130,45 @@ export interface DependencyValidation {
130
130
  /**
131
131
  * Available field types supported by the form engine
132
132
  */
133
- export type FieldType = 'card-expiry-input' | 'checkbox' | 'color' | 'credit-card' | 'date' | 'email' | 'file' | 'multi-select' | 'number' | 'password' | 'radio' | 'searchable-multi-select' | 'select' | 'switch' | 'tel' | 'text' | 'textarea' | 'time';
133
+ export type FieldType = 'card-expiry-input' | 'checkbox' | 'color' | 'credit-card' | 'date' | 'email' | 'file' | 'multi-select' | 'number' | 'password' | 'radio' | 'searchable-combobox' | 'searchable-multi-combobox' | 'searchable-multi-select' | 'select' | 'switch' | 'tel' | 'text' | 'textarea' | 'time';
134
+ /**
135
+ * Source contract for server-driven combobox field types
136
+ * (`searchable-combobox`, `searchable-multi-combobox`).
137
+ *
138
+ * The form engine builds a `queryFn` from this spec and feeds it to
139
+ * `usePagamioCombobox`. See `.agent/conventions/lookup-endpoints.md`
140
+ * (backend) for the endpoint contract.
141
+ */
142
+ export interface ComboboxOption {
143
+ id: string;
144
+ name: string;
145
+ [key: string]: unknown;
146
+ }
147
+ export interface ComboboxSourceSpec {
148
+ /**
149
+ * Function that fetches lookup results. The form engine calls this with
150
+ * `{ search, limit, ids }` per the lookup contract.
151
+ * Returns `{ data: T[] }` or `null`.
152
+ */
153
+ fetcher: (params: {
154
+ search?: string;
155
+ limit?: number;
156
+ ids?: string[];
157
+ }) => Promise<{
158
+ data: ComboboxOption[];
159
+ } | null | undefined>;
160
+ /**
161
+ * Query key prefix. Combined with the fetched-search and ids inside
162
+ * `usePagamioCombobox`. Should include scope (organizationId, etc.).
163
+ */
164
+ queryKey: readonly unknown[];
165
+ /** Optional: how to render the option label. Default: `option.name`. */
166
+ getLabel?: (option: ComboboxOption) => string;
167
+ /** Optional: per-field page size override. Default 20. */
168
+ pageSize?: number;
169
+ /** Optional: per-field debounce override (ms). Default 300. */
170
+ debounceMs?: number;
171
+ }
134
172
  /**
135
173
  * Defines the structure of a form field
136
174
  */
@@ -172,6 +210,11 @@ export interface Field {
172
210
  /** callback for onChange event */
173
211
  onChange?: (value: string) => void;
174
212
  isHidden?: boolean;
213
+ /**
214
+ * Source spec for `searchable-combobox` / `searchable-multi-combobox` types.
215
+ * Tells the form engine how to fetch lookup results.
216
+ */
217
+ source?: ComboboxSourceSpec;
175
218
  }
176
219
  export interface DependentFieldUpdate {
177
220
  field: string;
@@ -2,3 +2,4 @@ export * from './useContainerWidth';
2
2
  export * from './useMediaQueries';
3
3
  export * from './useSessionTimer';
4
4
  export * from './usePagamioTable';
5
+ export * from './usePagamioCombobox';
@@ -2,3 +2,4 @@ export * from './useContainerWidth';
2
2
  export * from './useMediaQueries';
3
3
  export * from './useSessionTimer';
4
4
  export * from './usePagamioTable';
5
+ export * from './usePagamioCombobox';
@@ -0,0 +1,83 @@
1
+ /**
2
+ * usePagamioCombobox — state hook for server-driven searchable combobox UIs.
3
+ *
4
+ * Mirrors the shape of `usePagamioTable`. The hook owns:
5
+ * - The raw + debounced search string
6
+ * - Top-N options fetched from the server (one query)
7
+ * - Optional restoration of full records for a known set of ids (second query)
8
+ * - `isCapped` (true when the server returned a full page; user should refine)
9
+ *
10
+ * Rendering is the caller's concern (see PagamioSearchableCombobox).
11
+ *
12
+ * Frontend contract (`/<resource>/lookup` endpoint):
13
+ * GET /api/v1/<resource>/lookup?search=&limit=&ids=&<scope>
14
+ * → { success, data: T[], timestamp }
15
+ * Slim payload (`{ id, name, ...minimal extras }`), no `meta`, no pagination.
16
+ * See `.agent/conventions/lookup-endpoints.md` (backend repo).
17
+ */
18
+ import { type QueryKey } from '@tanstack/react-query';
19
+ export interface ComboboxQueryParams {
20
+ search?: string;
21
+ limit?: number;
22
+ ids?: string[];
23
+ }
24
+ export interface ComboboxFetchResult<T> {
25
+ data: T[];
26
+ }
27
+ export interface UsePagamioComboboxConfig<T extends {
28
+ id: string;
29
+ }> {
30
+ /** TanStack Query key. Should include scope params (orgId, buId). */
31
+ queryKey: QueryKey;
32
+ /**
33
+ * The fetcher. Called with `{ search, limit }` for top-N lookups and
34
+ * separately with `{ ids }` for restoring selected-state labels.
35
+ * Should return `{ data: T[] }` or `null`. The hook unwraps `data`.
36
+ */
37
+ queryFn: (params: ComboboxQueryParams) => Promise<ComboboxFetchResult<T> | null | undefined>;
38
+ /**
39
+ * Whether the underlying queries are allowed to run. Same semantics as
40
+ * TanStack Query's `enabled`. Use for dependent pickers (e.g. store
41
+ * picker needs business unit selected first).
42
+ */
43
+ enabled?: boolean;
44
+ /** Debounce ms for the search input. Default 300. */
45
+ debounceMs?: number;
46
+ /**
47
+ * How many rows to request per top-N fetch. Default 20. Should be ≤ the
48
+ * backend's hard cap (e.g. 50). When the server returns exactly this
49
+ * many rows, `isCapped` flips true to encourage the user to refine.
50
+ */
51
+ pageSize?: number;
52
+ /** TanStack Query stale time (ms). Default 60_000. */
53
+ staleTime?: number;
54
+ /**
55
+ * Ids whose full records to fetch even if they aren't in the current
56
+ * search results. Used to render chips/labels for pre-selected values.
57
+ */
58
+ restoreIds?: readonly string[];
59
+ }
60
+ export interface UsePagamioComboboxReturn<T extends {
61
+ id: string;
62
+ }> {
63
+ /** Raw input value (updates on every keystroke). */
64
+ search: string;
65
+ setSearch: (value: string) => void;
66
+ /** Debounced input — drives the actual server query. */
67
+ debouncedSearch: string;
68
+ /** Top-N options for the current debounced search. */
69
+ options: T[];
70
+ /** Records resolved via `restoreIds`. Empty array when no ids passed. */
71
+ restoredOptions: T[];
72
+ /** First-fetch loading. */
73
+ isLoading: boolean;
74
+ /** Any background refetch in flight. */
75
+ isFetching: boolean;
76
+ /** True iff the server returned `pageSize` rows — there may be more. */
77
+ isCapped: boolean;
78
+ /** Underlying error, if any. */
79
+ error: Error | null;
80
+ }
81
+ export declare function usePagamioCombobox<T extends {
82
+ id: string;
83
+ }>({ queryKey, queryFn, enabled, debounceMs, pageSize, staleTime, restoreIds, }: UsePagamioComboboxConfig<T>): UsePagamioComboboxReturn<T>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * usePagamioCombobox — state hook for server-driven searchable combobox UIs.
3
+ *
4
+ * Mirrors the shape of `usePagamioTable`. The hook owns:
5
+ * - The raw + debounced search string
6
+ * - Top-N options fetched from the server (one query)
7
+ * - Optional restoration of full records for a known set of ids (second query)
8
+ * - `isCapped` (true when the server returned a full page; user should refine)
9
+ *
10
+ * Rendering is the caller's concern (see PagamioSearchableCombobox).
11
+ *
12
+ * Frontend contract (`/<resource>/lookup` endpoint):
13
+ * GET /api/v1/<resource>/lookup?search=&limit=&ids=&<scope>
14
+ * → { success, data: T[], timestamp }
15
+ * Slim payload (`{ id, name, ...minimal extras }`), no `meta`, no pagination.
16
+ * See `.agent/conventions/lookup-endpoints.md` (backend repo).
17
+ */
18
+ import { keepPreviousData, useQuery } from '@tanstack/react-query';
19
+ import { useEffect, useMemo, useState } from 'react';
20
+ // ─── Hook ───────────────────────────────────────────────────────────────
21
+ const DEFAULT_DEBOUNCE_MS = 300;
22
+ const DEFAULT_PAGE_SIZE = 20;
23
+ const DEFAULT_STALE_TIME = 60_000;
24
+ export function usePagamioCombobox({ queryKey, queryFn, enabled = true, debounceMs = DEFAULT_DEBOUNCE_MS, pageSize = DEFAULT_PAGE_SIZE, staleTime = DEFAULT_STALE_TIME, restoreIds, }) {
25
+ const [search, setSearch] = useState('');
26
+ const [debouncedSearch, setDebouncedSearch] = useState('');
27
+ // Debounce raw input -> debounced value.
28
+ useEffect(() => {
29
+ if (search === debouncedSearch)
30
+ return;
31
+ const timer = setTimeout(() => setDebouncedSearch(search), debounceMs);
32
+ return () => clearTimeout(timer);
33
+ }, [search, debouncedSearch, debounceMs]);
34
+ // ── Primary query: top-N for current debounced search ────────────────
35
+ const topNQuery = useQuery({
36
+ queryKey: [...queryKey, 'lookup', { search: debouncedSearch, limit: pageSize }],
37
+ queryFn: () => queryFn({
38
+ search: debouncedSearch || undefined,
39
+ limit: pageSize,
40
+ }),
41
+ enabled,
42
+ staleTime,
43
+ placeholderData: keepPreviousData,
44
+ });
45
+ const options = useMemo(() => topNQuery.data?.data ?? [], [topNQuery.data]);
46
+ const isCapped = options.length >= pageSize;
47
+ // ── Secondary query: resolve labels for known ids ────────────────────
48
+ // Sorted to keep the cache key stable.
49
+ const stableRestoreIds = useMemo(() => {
50
+ if (!restoreIds || restoreIds.length === 0)
51
+ return undefined;
52
+ return [...restoreIds].sort();
53
+ }, [restoreIds]);
54
+ const restoreQuery = useQuery({
55
+ queryKey: [...queryKey, 'lookup', 'restore', stableRestoreIds],
56
+ queryFn: () => queryFn({ ids: stableRestoreIds }),
57
+ enabled: enabled && !!stableRestoreIds && stableRestoreIds.length > 0,
58
+ staleTime,
59
+ });
60
+ const restoredOptions = useMemo(() => restoreQuery.data?.data ?? [], [restoreQuery.data]);
61
+ return {
62
+ search,
63
+ setSearch,
64
+ debouncedSearch,
65
+ options,
66
+ restoredOptions,
67
+ isLoading: topNQuery.isLoading || restoreQuery.isLoading,
68
+ isFetching: topNQuery.isFetching || restoreQuery.isFetching,
69
+ isCapped,
70
+ error: topNQuery.error ?? restoreQuery.error ?? null,
71
+ };
72
+ }
package/lib/styles.css CHANGED
@@ -1450,6 +1450,9 @@ video {
1450
1450
  .w-\[52px\] {
1451
1451
  width: 52px;
1452
1452
  }
1453
+ .w-\[var\(--radix-popover-trigger-width\)\] {
1454
+ width: var(--radix-popover-trigger-width);
1455
+ }
1453
1456
  .w-auto {
1454
1457
  width: auto;
1455
1458
  }
@@ -1518,6 +1521,9 @@ video {
1518
1521
  .max-w-\[100vw\] {
1519
1522
  max-width: 100vw;
1520
1523
  }
1524
+ .max-w-\[140px\] {
1525
+ max-width: 140px;
1526
+ }
1521
1527
  .max-w-\[160px\] {
1522
1528
  max-width: 160px;
1523
1529
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pagamio/frontend-commons-lib",
3
3
  "description": "Pagamio library for Frontend reusable components like the form engine and table container",
4
- "version": "0.8.332",
4
+ "version": "0.8.334",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "provenance": false