@seedgrid/fe-components 2026.6.7 → 2026.6.8

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.
Files changed (40) hide show
  1. package/dist/ai/seedgrid-components.manifest.json +523 -0
  2. package/dist/i18n/en-US.d.ts.map +1 -1
  3. package/dist/i18n/en-US.js +2 -0
  4. package/dist/i18n/es.d.ts.map +1 -1
  5. package/dist/i18n/es.js +2 -0
  6. package/dist/i18n/fr.d.ts.map +1 -1
  7. package/dist/i18n/fr.js +2 -0
  8. package/dist/i18n/pt-BR.d.ts.map +1 -1
  9. package/dist/i18n/pt-BR.js +2 -0
  10. package/dist/i18n/pt-PT.d.ts.map +1 -1
  11. package/dist/i18n/pt-PT.js +2 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -0
  15. package/dist/inputs/SgInputTextSearch.d.ts +11 -0
  16. package/dist/inputs/SgInputTextSearch.d.ts.map +1 -0
  17. package/dist/inputs/SgInputTextSearch.js +47 -0
  18. package/dist/inputs/SgInputTextSearch.meta.d.ts +4 -0
  19. package/dist/inputs/SgInputTextSearch.meta.d.ts.map +1 -0
  20. package/dist/inputs/SgInputTextSearch.meta.js +50 -0
  21. package/dist/inputs/SgMultiSelect.d.ts +23 -0
  22. package/dist/inputs/SgMultiSelect.d.ts.map +1 -0
  23. package/dist/inputs/SgMultiSelect.js +117 -0
  24. package/dist/inputs/SgMultiSelect.meta.d.ts +4 -0
  25. package/dist/inputs/SgMultiSelect.meta.d.ts.map +1 -0
  26. package/dist/inputs/SgMultiSelect.meta.js +56 -0
  27. package/dist/inputs/SgMultiSelectChips.d.ts +27 -0
  28. package/dist/inputs/SgMultiSelectChips.d.ts.map +1 -0
  29. package/dist/inputs/SgMultiSelectChips.js +127 -0
  30. package/dist/inputs/SgMultiSelectChips.meta.d.ts +4 -0
  31. package/dist/inputs/SgMultiSelectChips.meta.d.ts.map +1 -0
  32. package/dist/inputs/SgMultiSelectChips.meta.js +55 -0
  33. package/dist/inputs/SgMultiSelectDropdown.d.ts +28 -0
  34. package/dist/inputs/SgMultiSelectDropdown.d.ts.map +1 -0
  35. package/dist/inputs/SgMultiSelectDropdown.js +41 -0
  36. package/dist/inputs/useSgMultiSelect.d.ts +48 -0
  37. package/dist/inputs/useSgMultiSelect.d.ts.map +1 -0
  38. package/dist/inputs/useSgMultiSelect.js +202 -0
  39. package/dist/sandbox.cjs +53 -53
  40. package/package.json +1 -1
@@ -0,0 +1,55 @@
1
+ export const sgMeta = {
2
+ version: "0.1",
3
+ componentId: "form.input.multiselectchips",
4
+ package: "@seedgrid/fe-components",
5
+ exportName: "SgMultiSelectChips",
6
+ slug: "sg-multi-select-chips",
7
+ displayName: "SgMultiSelectChips",
8
+ category: "input",
9
+ subcategory: "multiselect",
10
+ description: "Variante do multi-select onde os itens selecionados viram chips removiveis no trigger; o dropdown com checkboxes e a busca sao iguais ao SgMultiSelect.",
11
+ tags: ["form", "multiselect", "chips", "tags", "checkbox", "rhf"],
12
+ capabilities: ["multi-selection", "chips", "search", "clearable", "max-selected", "keyboard"],
13
+ fieldSemantics: ["structuredChoice", "multiChoice", "tags", "categories"],
14
+ props: [
15
+ { name: "id", type: "string", required: true, description: "Identificador unico do campo.", semanticRole: "data", bindable: false },
16
+ { name: "label", type: "string", description: "Rotulo exibido ao usuario.", semanticRole: "label", bindable: true },
17
+ { name: "options", type: "{ label: string; value: string | number; disabled?: boolean }[]", description: "Lista de opcoes disponiveis.", semanticRole: "data", bindable: false },
18
+ { name: "value", type: "(string | number)[]", description: "Array de valores selecionados.", semanticRole: "value", bindable: true },
19
+ { name: "onChange", type: "(value: (string | number)[]) => void", description: "Callback com o array atualizado de valores.", semanticRole: "event", bindable: false },
20
+ { name: "searchable", type: "boolean", default: false, description: "Exibe campo de busca no topo do dropdown.", semanticRole: "behavior", bindable: true },
21
+ { name: "clearable", type: "boolean", default: false, description: "Exibe botao para limpar toda a selecao.", semanticRole: "behavior", bindable: true },
22
+ { name: "maxSelected", type: "number", description: "Limite maximo de itens selecionaveis.", semanticRole: "validation", bindable: true },
23
+ { name: "placeholder", type: "string", description: "Texto exibido quando nada esta selecionado.", semanticRole: "label", bindable: true },
24
+ { name: "required", type: "boolean", default: false, description: "Marca o campo como obrigatorio.", semanticRole: "validation", bindable: true },
25
+ { name: "onEnter", type: "() => void", description: "Disparado ao focar o campo.", semanticRole: "event", bindable: false },
26
+ { name: "onExit", type: "() => void", description: "Disparado ao sair do campo.", semanticRole: "event", bindable: false }
27
+ ],
28
+ states: ["default", "focused", "open", "disabled", "error"],
29
+ showcase: { route: "/components/sg-multi-select-chips", hasPlayground: false, hasPropsTable: true },
30
+ sdui: {
31
+ rendererType: "field.multiselectchips",
32
+ acceptsDataBinding: true,
33
+ defaultProps: { searchable: false, clearable: false }
34
+ }
35
+ };
36
+ export const aiHints = {
37
+ version: "0.1",
38
+ preferredUseCases: [
39
+ "Selecao de varias opcoes onde e util ver e remover cada item escolhido individualmente (ex.: tags, destinatarios, empresas).",
40
+ "Filtros multi-valor em que o usuario precisa enxergar tudo o que ja selecionou.",
41
+ "Campos relacionais N:N em formularios CRUD com poucos itens visiveis por vez."
42
+ ],
43
+ avoidUseCases: [
44
+ "Escolha unica; nesses casos prefira SgCombobox ou SgRadioGroup.",
45
+ "Selecoes muito numerosas onde os chips ocupariam espaco demais; nesses casos prefira SgMultiSelect (resumo 'N selecionadas').",
46
+ "Listas com busca remota assincrona; nesses casos prefira SgAutocomplete."
47
+ ],
48
+ synonyms: ["multi select chips", "multiselect tags", "selecao multipla com chips", "tag input select", "chips dropdown"],
49
+ relatedEntityFields: ["empresas", "categorias", "tags", "permissoes", "roles", "destinatarios"],
50
+ compositionHints: [
51
+ "Usar dentro de SgPanel ou SgGroupBox em formularios.",
52
+ "Preferir a SgMultiSelect quando o numero de selecoes for grande e os chips poluirem o layout."
53
+ ],
54
+ rankingSignals: { freeText: 0.1, structuredChoice: 0.95, date: 0, number: 0, denseLayout: 0.6 }
55
+ };
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import type { SgMultiSelectOption, SgMultiSelectOptionValue } from "./useSgMultiSelect";
3
+ export type SgMultiSelectDropdownProps = {
4
+ dropdownRef: React.RefObject<HTMLDivElement | null>;
5
+ searchRef: React.RefObject<HTMLInputElement | null>;
6
+ ignoreBlurRef: React.MutableRefObject<boolean>;
7
+ dropdownStyle: React.CSSProperties;
8
+ searchable: boolean;
9
+ searchPlaceholder?: string;
10
+ query: string;
11
+ setQuery: (value: string) => void;
12
+ setActiveIndex: (index: number) => void;
13
+ activeIndex: number;
14
+ visibleOptions: SgMultiSelectOption[];
15
+ emptyText: string;
16
+ isSelected: (value: SgMultiSelectOptionValue) => boolean;
17
+ resolvedValueLength: number;
18
+ maxSelected?: number;
19
+ toggleOption: (option: SgMultiSelectOption) => void;
20
+ onKeyDown: (event: React.KeyboardEvent) => void;
21
+ };
22
+ /**
23
+ * Dropdown compartilhado por SgMultiSelect e SgMultiSelectChips: lista de
24
+ * opcoes com checkbox, busca opcional, scroll e marcacao. Mantem a lista
25
+ * aberta enquanto o usuario marca/desmarca.
26
+ */
27
+ export declare function SgMultiSelectDropdown(props: Readonly<SgMultiSelectDropdownProps>): React.ReactPortal | null;
28
+ //# sourceMappingURL=SgMultiSelectDropdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SgMultiSelectDropdown.d.ts","sourceRoot":"","sources":["../../src/inputs/SgMultiSelectDropdown.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,OAAO,KAAK,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAExF,MAAM,MAAM,0BAA0B,GAAG;IACvC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACpD,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACpD,aAAa,EAAE,KAAK,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC;IAC/C,aAAa,EAAE,KAAK,CAAC,aAAa,CAAC;IACnC,UAAU,EAAE,OAAO,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,mBAAmB,EAAE,CAAC;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO,CAAC;IACzD,mBAAmB,EAAE,MAAM,CAAC;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACpD,SAAS,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;CACjD,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,QAAQ,CAAC,0BAA0B,CAAC,4BAiFhF"}
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createPortal } from "react-dom";
4
+ import { Check } from "lucide-react";
5
+ import { t, useComponentsI18n } from "../i18n";
6
+ /**
7
+ * Dropdown compartilhado por SgMultiSelect e SgMultiSelectChips: lista de
8
+ * opcoes com checkbox, busca opcional, scroll e marcacao. Mantem a lista
9
+ * aberta enquanto o usuario marca/desmarca.
10
+ */
11
+ export function SgMultiSelectDropdown(props) {
12
+ const i18n = useComponentsI18n();
13
+ if (typeof document === "undefined")
14
+ return null;
15
+ return createPortal(_jsxs("div", { ref: props.dropdownRef, className: "z-[1100] overflow-hidden rounded-md border border-border bg-[rgb(var(--sg-surface,var(--sg-bg)))] text-[rgb(var(--sg-text,var(--sg-fg)))] shadow-lg", style: props.dropdownStyle, role: "listbox", "aria-multiselectable": "true", onKeyDown: props.onKeyDown, children: [props.searchable ? (_jsx("div", { className: "border-b border-border p-2", children: _jsx("input", { ref: props.searchRef, type: "text", value: props.query, placeholder: props.searchPlaceholder ?? t(i18n, "components.actions.search"), className: "h-8 w-full rounded border border-border bg-[var(--sg-input-bg,hsl(var(--background)))] px-2 text-sm text-[var(--sg-input-fg,hsl(var(--foreground)))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary)/0.25)]", onChange: (event) => {
16
+ props.setQuery(event.currentTarget.value);
17
+ props.setActiveIndex(0);
18
+ }, onMouseDown: () => {
19
+ props.ignoreBlurRef.current = true;
20
+ } }) })) : null, _jsx("div", { className: "max-h-64 overflow-auto py-1", children: props.visibleOptions.length === 0 ? (_jsx("div", { className: "px-3 py-2 text-sm text-muted-foreground", children: props.emptyText })) : (props.visibleOptions.map((option, index) => {
21
+ const checked = props.isSelected(option.value);
22
+ const isActive = props.activeIndex === index;
23
+ const reachedMax = !checked &&
24
+ props.maxSelected !== undefined &&
25
+ props.resolvedValueLength >= props.maxSelected;
26
+ const optionDisabled = option.disabled || reachedMax;
27
+ return (_jsxs("div", { role: "option", "aria-selected": checked, className: [
28
+ "flex cursor-pointer items-center gap-2 px-3 py-2 text-sm",
29
+ isActive ? "bg-muted/60" : "",
30
+ optionDisabled ? "cursor-not-allowed opacity-50" : "hover:bg-muted/40"
31
+ ].join(" "), onMouseEnter: () => props.setActiveIndex(index), onMouseDown: (event) => {
32
+ event.preventDefault();
33
+ props.ignoreBlurRef.current = true;
34
+ }, onClick: () => props.toggleOption(option), children: [_jsx("span", { "aria-hidden": "true", className: [
35
+ "flex h-4 w-4 shrink-0 items-center justify-center rounded border",
36
+ checked
37
+ ? "border-[hsl(var(--primary))] bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]"
38
+ : "border-border bg-[var(--sg-input-bg,hsl(var(--background)))]"
39
+ ].join(" "), children: checked ? _jsx(Check, { size: 12, strokeWidth: 3 }) : null }), _jsx("span", { className: "truncate", children: option.label })] }, String(option.value)));
40
+ })) })] }), document.body);
41
+ }
@@ -0,0 +1,48 @@
1
+ import React from "react";
2
+ export type SgMultiSelectOptionValue = string | number;
3
+ export type SgMultiSelectOption = {
4
+ label: string;
5
+ value: SgMultiSelectOptionValue;
6
+ disabled?: boolean;
7
+ };
8
+ export declare function valueEquals(left: SgMultiSelectOptionValue, right: SgMultiSelectOptionValue): boolean;
9
+ export type UseSgMultiSelectParams = {
10
+ options: SgMultiSelectOption[];
11
+ value?: SgMultiSelectOptionValue[];
12
+ onChange?: (value: SgMultiSelectOptionValue[]) => void;
13
+ searchable?: boolean;
14
+ maxSelected?: number;
15
+ isDisabled: boolean;
16
+ borderRadius?: number | string;
17
+ };
18
+ /**
19
+ * Logica compartilhada entre SgMultiSelect e SgMultiSelectChips: estado de
20
+ * abertura, selecao, busca, posicionamento do dropdown via portal e navegacao
21
+ * por teclado. Os componentes apenas renderizam o trigger e reaproveitam o
22
+ * dropdown a partir do que este hook expoe.
23
+ */
24
+ export declare function useSgMultiSelect(params: UseSgMultiSelectParams): {
25
+ readonly wrapperRef: React.RefObject<HTMLDivElement | null>;
26
+ readonly inputRef: React.RefObject<HTMLInputElement | null>;
27
+ readonly dropdownRef: React.RefObject<HTMLDivElement | null>;
28
+ readonly searchRef: React.RefObject<HTMLInputElement | null>;
29
+ readonly ignoreBlurRef: React.RefObject<boolean>;
30
+ readonly open: boolean;
31
+ readonly activeIndex: number;
32
+ readonly query: string;
33
+ readonly dropdownStyle: React.CSSProperties;
34
+ readonly resolvedValue: SgMultiSelectOptionValue[];
35
+ readonly selectedLabels: string[];
36
+ readonly selectedOptions: SgMultiSelectOption[];
37
+ readonly visibleOptions: SgMultiSelectOption[];
38
+ readonly setQuery: React.Dispatch<React.SetStateAction<string>>;
39
+ readonly setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
40
+ readonly isSelected: (optionValue: SgMultiSelectOptionValue) => boolean;
41
+ readonly toggleOption: (option: SgMultiSelectOption) => void;
42
+ readonly removeValue: (optionValue: SgMultiSelectOptionValue) => void;
43
+ readonly clearSelection: () => void;
44
+ readonly closeDropdown: () => void;
45
+ readonly openDropdown: () => void;
46
+ readonly handleListKeyDown: (event: React.KeyboardEvent) => void;
47
+ };
48
+ //# sourceMappingURL=useSgMultiSelect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSgMultiSelect.d.ts","sourceRoot":"","sources":["../../src/inputs/useSgMultiSelect.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvD,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,wBAAwB,CAAC;IAChC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,wBAAgB,WAAW,CAAC,IAAI,EAAE,wBAAwB,EAAE,KAAK,EAAE,wBAAwB,WAE1F;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,mBAAmB,EAAE,CAAC;IAC/B,KAAK,CAAC,EAAE,wBAAwB,EAAE,CAAC;IACnC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,wBAAwB,EAAE,KAAK,IAAI,CAAC;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,OAAO,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAChC,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,sBAAsB;;;;;;;;;;;;;;;;uCAyB7C,wBAAwB;oCA6C7B,mBAAmB;wCAgBd,wBAAwB;;;;wCA4E9B,KAAK,CAAC,aAAa;EAiE9B"}
@@ -0,0 +1,202 @@
1
+ "use client";
2
+ import React from "react";
3
+ export function valueEquals(left, right) {
4
+ return String(left) === String(right);
5
+ }
6
+ /**
7
+ * Logica compartilhada entre SgMultiSelect e SgMultiSelectChips: estado de
8
+ * abertura, selecao, busca, posicionamento do dropdown via portal e navegacao
9
+ * por teclado. Os componentes apenas renderizam o trigger e reaproveitam o
10
+ * dropdown a partir do que este hook expoe.
11
+ */
12
+ export function useSgMultiSelect(params) {
13
+ const { options, value, onChange, searchable = false, maxSelected, isDisabled, borderRadius } = params;
14
+ const isControlled = value !== undefined;
15
+ const wrapperRef = React.useRef(null);
16
+ const inputRef = React.useRef(null);
17
+ const dropdownRef = React.useRef(null);
18
+ const searchRef = React.useRef(null);
19
+ const ignoreBlurRef = React.useRef(false);
20
+ const [open, setOpen] = React.useState(false);
21
+ const [internalValue, setInternalValue] = React.useState([]);
22
+ const [activeIndex, setActiveIndex] = React.useState(-1);
23
+ const [query, setQuery] = React.useState("");
24
+ const [dropdownStyle, setDropdownStyle] = React.useState({});
25
+ const resolvedBorderRadius = React.useMemo(() => {
26
+ if (borderRadius === undefined)
27
+ return undefined;
28
+ return typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius;
29
+ }, [borderRadius]);
30
+ const resolvedValue = isControlled ? (value ?? []) : internalValue;
31
+ const isSelected = React.useCallback((optionValue) => resolvedValue.some((entry) => valueEquals(entry, optionValue)), [resolvedValue]);
32
+ const visibleOptions = React.useMemo(() => {
33
+ if (!searchable || !query.trim())
34
+ return options;
35
+ const normalized = query.trim().toLowerCase();
36
+ return options.filter((option) => option.label.toLowerCase().includes(normalized));
37
+ }, [options, query, searchable]);
38
+ const selectedLabels = React.useMemo(() => {
39
+ return resolvedValue
40
+ .map((entry) => options.find((option) => valueEquals(option.value, entry))?.label)
41
+ .filter((label) => Boolean(label));
42
+ }, [options, resolvedValue]);
43
+ const selectedOptions = React.useMemo(() => {
44
+ return resolvedValue
45
+ .map((entry) => options.find((option) => valueEquals(option.value, entry)))
46
+ .filter((option) => Boolean(option));
47
+ }, [options, resolvedValue]);
48
+ const setSelectedValue = React.useCallback((nextValue) => {
49
+ if (!isControlled) {
50
+ setInternalValue(nextValue);
51
+ }
52
+ onChange?.(nextValue);
53
+ }, [isControlled, onChange]);
54
+ const closeDropdown = React.useCallback(() => {
55
+ setOpen(false);
56
+ setActiveIndex(-1);
57
+ setQuery("");
58
+ }, []);
59
+ const openDropdown = React.useCallback(() => {
60
+ if (isDisabled)
61
+ return;
62
+ setOpen(true);
63
+ }, [isDisabled]);
64
+ const toggleOption = React.useCallback((option) => {
65
+ if (option.disabled)
66
+ return;
67
+ const already = resolvedValue.some((entry) => valueEquals(entry, option.value));
68
+ if (already) {
69
+ setSelectedValue(resolvedValue.filter((entry) => !valueEquals(entry, option.value)));
70
+ return;
71
+ }
72
+ if (maxSelected !== undefined && resolvedValue.length >= maxSelected) {
73
+ return;
74
+ }
75
+ setSelectedValue([...resolvedValue, option.value]);
76
+ }, [maxSelected, resolvedValue, setSelectedValue]);
77
+ const removeValue = React.useCallback((optionValue) => {
78
+ setSelectedValue(resolvedValue.filter((entry) => !valueEquals(entry, optionValue)));
79
+ }, [resolvedValue, setSelectedValue]);
80
+ const clearSelection = React.useCallback(() => {
81
+ setSelectedValue([]);
82
+ }, [setSelectedValue]);
83
+ const syncDropdownPosition = React.useCallback(() => {
84
+ const anchor = wrapperRef.current?.querySelector("input") ?? inputRef.current ?? wrapperRef.current;
85
+ if (!anchor)
86
+ return;
87
+ const rect = anchor.getBoundingClientRect();
88
+ setDropdownStyle({
89
+ position: "fixed",
90
+ top: rect.bottom + 4,
91
+ left: rect.left,
92
+ minWidth: rect.width,
93
+ width: rect.width,
94
+ maxWidth: "min(32rem, calc(100vw - 24px))",
95
+ borderRadius: resolvedBorderRadius
96
+ });
97
+ }, [resolvedBorderRadius]);
98
+ React.useEffect(() => {
99
+ if (!open)
100
+ return;
101
+ const handleOutside = (event) => {
102
+ if (wrapperRef.current?.contains(event.target))
103
+ return;
104
+ if (dropdownRef.current?.contains(event.target))
105
+ return;
106
+ closeDropdown();
107
+ };
108
+ document.addEventListener("mousedown", handleOutside);
109
+ return () => document.removeEventListener("mousedown", handleOutside);
110
+ }, [closeDropdown, open]);
111
+ React.useEffect(() => {
112
+ if (!open)
113
+ return;
114
+ syncDropdownPosition();
115
+ const handleLayoutChange = () => {
116
+ syncDropdownPosition();
117
+ };
118
+ window.addEventListener("resize", handleLayoutChange);
119
+ window.addEventListener("scroll", handleLayoutChange, true);
120
+ return () => {
121
+ window.removeEventListener("resize", handleLayoutChange);
122
+ window.removeEventListener("scroll", handleLayoutChange, true);
123
+ };
124
+ }, [open, syncDropdownPosition]);
125
+ React.useEffect(() => {
126
+ if (!open) {
127
+ return;
128
+ }
129
+ if (searchable && searchRef.current) {
130
+ searchRef.current.focus();
131
+ }
132
+ setActiveIndex(visibleOptions.length > 0 ? 0 : -1);
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [open, searchable]);
135
+ React.useEffect(() => {
136
+ if (!open)
137
+ return;
138
+ if (activeIndex >= visibleOptions.length) {
139
+ setActiveIndex(visibleOptions.length - 1);
140
+ }
141
+ }, [activeIndex, open, visibleOptions.length]);
142
+ const handleListKeyDown = React.useCallback((event) => {
143
+ if (isDisabled)
144
+ return;
145
+ if (event.key === "Escape") {
146
+ event.preventDefault();
147
+ closeDropdown();
148
+ inputRef.current?.focus();
149
+ return;
150
+ }
151
+ if (event.key === "Tab") {
152
+ closeDropdown();
153
+ return;
154
+ }
155
+ if (event.key === "ArrowDown") {
156
+ event.preventDefault();
157
+ setActiveIndex((prev) => Math.min(prev + 1, visibleOptions.length - 1));
158
+ return;
159
+ }
160
+ if (event.key === "ArrowUp") {
161
+ event.preventDefault();
162
+ setActiveIndex((prev) => Math.max(prev - 1, 0));
163
+ return;
164
+ }
165
+ if (event.key === "Enter" || event.key === " ") {
166
+ if (activeIndex < 0)
167
+ return;
168
+ const option = visibleOptions[activeIndex];
169
+ if (!option)
170
+ return;
171
+ event.preventDefault();
172
+ toggleOption(option);
173
+ }
174
+ }, [activeIndex, closeDropdown, isDisabled, toggleOption, visibleOptions]);
175
+ return {
176
+ // refs
177
+ wrapperRef,
178
+ inputRef,
179
+ dropdownRef,
180
+ searchRef,
181
+ ignoreBlurRef,
182
+ // state
183
+ open,
184
+ activeIndex,
185
+ query,
186
+ dropdownStyle,
187
+ resolvedValue,
188
+ selectedLabels,
189
+ selectedOptions,
190
+ visibleOptions,
191
+ // setters / actions
192
+ setQuery,
193
+ setActiveIndex,
194
+ isSelected,
195
+ toggleOption,
196
+ removeValue,
197
+ clearSelection,
198
+ closeDropdown,
199
+ openDropdown,
200
+ handleListKeyDown
201
+ };
202
+ }