@addev-be/ui 3.3.3 → 3.3.7

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.
@@ -9,6 +9,7 @@ import "lodash";
9
9
  import "../forms/Form/index.js";
10
10
  import "../forms/Form/styles.js";
11
11
  import "../forms/NumberInput.js";
12
+ import "../forms/SqlPrefixFilterCombobox/SqlPrefixFilterCombobox.js";
12
13
  import { useParams as x, Link as R } from "react-router-dom";
13
14
  import "../common/Accordion/Accordion.js";
14
15
  import "../common/Avatar/styles.js";
@@ -26,7 +27,7 @@ import "../common/TreeView/TreeContext.js";
26
27
  import { FormContainer as j } from "./styles.js";
27
28
  import { StackedLabel as w } from "../forms/VerticalLabel.js";
28
29
  import { useAuthentication as E } from "../../hooks/providers.js";
29
- const ne = () => {
30
+ const ae = () => {
30
31
  const { key: t } = x(), [s, y] = o(""), [i, C] = o(""), [n, v] = o(-1), [p, k] = o(-1), [c, u] = o(""), { resetPassword: d, checkRecoveryKey: l } = E();
31
32
  S(() => {
32
33
  t && l(t).then((r) => {
@@ -75,5 +76,5 @@ const ne = () => {
75
76
  ] }) });
76
77
  };
77
78
  export {
78
- ne as PasswordResetForm
79
+ ae as PasswordResetForm
79
80
  };
@@ -0,0 +1,9 @@
1
+ import { UseSqlPrefixFilterComboboxStateProps } from './useSqlPrefixFilterComboboxState';
2
+ export type SqlPrefixFilterComboboxProps<T> = UseSqlPrefixFilterComboboxStateProps<T> & {
3
+ label?: string;
4
+ itemKey: (item: T, index: number) => string;
5
+ noResultsText?: string;
6
+ placeholder?: string;
7
+ readOnly?: boolean;
8
+ };
9
+ export declare const SqlPrefixFilterCombobox: <T>({ label, value, itemKey, itemLabel, readOnly, noResultsText, placeholder, ...props }: SqlPrefixFilterComboboxProps<T>) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,140 @@
1
+ import { jsx as o, jsxs as a, Fragment as _ } from "react/jsx-runtime";
2
+ import { CardForm as h } from "../Form/index.js";
3
+ import r from "styled-components";
4
+ import { useSqlPrefixFilterComboboxState as $ } from "./useSqlPrefixFilterComboboxState.js";
5
+ const A = 20, B = 40, O = r.div`
6
+ position: relative;
7
+ width: 100%;
8
+ `, j = r.input`
9
+ font-family: var(--font-input);
10
+ font-size: var(--text-base);
11
+ font-weight: 500;
12
+ border: 1px solid var(--color-base-300);
13
+ color: var(--color-text-800);
14
+ background: var(--color-base-0);
15
+ box-sizing: border-box;
16
+ width: 100%;
17
+ padding: var(--space-2);
18
+ border-radius: var(--rounded-md);
19
+
20
+ &:focus {
21
+ outline: none;
22
+ border-color: var(--color-sky-500);
23
+ box-shadow: 0 0 0 1px var(--color-sky-500);
24
+ }
25
+ `, q = r.div`
26
+ position: absolute;
27
+ top: calc(100% + var(--space-1));
28
+ left: 0;
29
+ right: 0;
30
+ z-index: 20;
31
+ max-height: ${A}rem;
32
+ background: var(--color-base-0);
33
+ border: 1px solid var(--color-base-300);
34
+ border-radius: var(--rounded-md);
35
+ box-shadow: var(--shadow-lg);
36
+ overflow-y: auto;
37
+ overflow-x: hidden;
38
+ `, K = r.button`
39
+ width: 100%;
40
+ border: 0;
41
+ height: ${B}px;
42
+ padding: 0 var(--space-3);
43
+ text-align: left;
44
+ cursor: pointer;
45
+ background: ${({ $highlighted: t }) => t ? "var(--color-base-100)" : "var(--color-base-0)"};
46
+ color: var(--color-text-800);
47
+ display: flex;
48
+ align-items: center;
49
+
50
+ &:hover {
51
+ background: var(--color-base-100);
52
+ }
53
+ `, l = r.div`
54
+ padding: var(--space-2) var(--space-3);
55
+ color: var(--color-text-500);
56
+ font-size: var(--text-sm);
57
+ `, V = ({
58
+ label: t,
59
+ value: g,
60
+ itemKey: v,
61
+ itemLabel: i,
62
+ readOnly: p = !1,
63
+ noResultsText: b = "Aucun resultat",
64
+ placeholder: x,
65
+ ...f
66
+ }) => {
67
+ const {
68
+ bottomSpacerHeight: m,
69
+ filteredRows: s,
70
+ handleBlur: w,
71
+ handleChange: S,
72
+ handleFocus: C,
73
+ handleKeyDown: y,
74
+ handleOpenAllItems: d,
75
+ handleSuggestionsScroll: I,
76
+ inputValue: k,
77
+ isLoading: c,
78
+ opened: u,
79
+ highlightedIndex: F,
80
+ selectedLabel: H,
81
+ selectItem: D,
82
+ suggestionsContainerRef: E,
83
+ topSpacerHeight: M,
84
+ visibleRows: R,
85
+ visibleStartIndex: z
86
+ } = $({
87
+ ...f,
88
+ itemLabel: i,
89
+ value: g
90
+ });
91
+ return p ? /* @__PURE__ */ o(h.Input, { label: t, value: H, readOnly: !0 }) : /* @__PURE__ */ o(h.Row, { label: t, children: /* @__PURE__ */ a(O, { children: [
92
+ /* @__PURE__ */ o(
93
+ j,
94
+ {
95
+ type: "text",
96
+ value: k,
97
+ placeholder: u ? void 0 : x,
98
+ autoComplete: "off",
99
+ onMouseDown: d,
100
+ onClick: d,
101
+ onFocus: C,
102
+ onBlur: w,
103
+ onKeyDown: y,
104
+ onChange: (e) => S(e.target.value)
105
+ }
106
+ ),
107
+ u && /* @__PURE__ */ a(
108
+ q,
109
+ {
110
+ ref: E,
111
+ onScroll: I,
112
+ children: [
113
+ s.length > 0 ? /* @__PURE__ */ a(_, { children: [
114
+ /* @__PURE__ */ o("div", { style: { height: M } }),
115
+ R.map((e, G) => {
116
+ const n = z + G;
117
+ return /* @__PURE__ */ o(
118
+ K,
119
+ {
120
+ type: "button",
121
+ $highlighted: n === F,
122
+ onMouseDown: (T) => {
123
+ T.preventDefault(), D(e);
124
+ },
125
+ children: i(e, n)
126
+ },
127
+ v(e, n)
128
+ );
129
+ }),
130
+ /* @__PURE__ */ o("div", { style: { height: m } })
131
+ ] }) : c ? /* @__PURE__ */ o(l, { children: "Chargement..." }) : /* @__PURE__ */ o(l, { children: b }),
132
+ c && s.length > 0 && /* @__PURE__ */ o(l, { children: "Chargement..." })
133
+ ]
134
+ }
135
+ )
136
+ ] }) });
137
+ };
138
+ export {
139
+ V as SqlPrefixFilterCombobox
140
+ };
@@ -0,0 +1,36 @@
1
+ import { ConditionDTO, OrderByDTO, SqlRequestRow } from '../../../services/sqlRequests';
2
+ export type UseSqlPrefixFilterComboboxStateProps<T> = {
3
+ requestName: string;
4
+ columns: string[];
5
+ returnColumns?: string[];
6
+ parser?: (row: SqlRequestRow<T>) => T;
7
+ value: T | null;
8
+ itemLabel: (item: T, index: number) => string;
9
+ filterField: string;
10
+ conditions?: ConditionDTO[];
11
+ orderBy?: OrderByDTO[];
12
+ onChange?: (value: T | null) => void;
13
+ initialPageSize?: number;
14
+ pageSize?: number;
15
+ };
16
+ export declare const useSqlPrefixFilterComboboxState: <T>({ requestName, columns, returnColumns, parser, value, itemLabel, filterField, conditions, orderBy, onChange, initialPageSize, pageSize, }: UseSqlPrefixFilterComboboxStateProps<T>) => {
17
+ bottomSpacerHeight: number;
18
+ filteredRows: T[];
19
+ handleBlur: () => void;
20
+ handleChange: (nextValue: string) => void;
21
+ handleFocus: () => void;
22
+ handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
23
+ handleOpenAllItems: () => void;
24
+ handleSuggestionsScroll: (event: React.UIEvent<HTMLDivElement>) => void;
25
+ inputValue: string;
26
+ isLoading: boolean;
27
+ opened: boolean;
28
+ highlightedIndex: number;
29
+ resetToSelectedValue: () => void;
30
+ selectedLabel: string;
31
+ selectItem: (item: T | null) => void;
32
+ suggestionsContainerRef: import('react').RefObject<HTMLDivElement | null>;
33
+ topSpacerHeight: number;
34
+ visibleRows: T[];
35
+ visibleStartIndex: number;
36
+ };
@@ -0,0 +1,275 @@
1
+ import { useSqlRequestHandler as De } from "../../../services/sqlRequests.js";
2
+ import { useRef as b, useMemo as g, useState as i, useCallback as o, useEffect as R } from "react";
3
+ const Fe = 25, ye = 50, N = 40, qe = 80, Ce = 8, Te = 6, pe = 2, Ue = 250, v = (r) => (r ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase(), se = (r) => v(r).replace(/[^a-z0-9]+/g, ""), be = (r) => {
4
+ const u = se(r);
5
+ if (!u) return "";
6
+ const d = v(r).match(/^[a-z0-9]+/)?.[0] ?? "";
7
+ return /[^a-z0-9]/i.test(r ?? "") && d || u.slice(0, pe);
8
+ }, Ge = ({
9
+ requestName: r,
10
+ columns: u,
11
+ returnColumns: M = u,
12
+ parser: d,
13
+ value: z,
14
+ itemLabel: S,
15
+ filterField: k,
16
+ conditions: G = [],
17
+ orderBy: B = [],
18
+ onChange: oe,
19
+ initialPageSize: O = Fe,
20
+ pageSize: V = ye
21
+ }) => {
22
+ const K = b(null), P = b(null), Y = b(0), T = b(!1), re = b(
23
+ /* @__PURE__ */ new Map()
24
+ ), [ce] = De(r), f = g(
25
+ () => z ? S(z, -1) : "",
26
+ [S, z]
27
+ ), [p, W] = i(f), [n, x] = i(!1), [c, m] = i(!1), [h, _] = i(-1), [a, X] = i([]), [Z, $] = i(!1), [D, le] = i(!1), [Ie, ue] = i(0), [I, J] = i(null), F = g(() => !n || c ? "" : se(p), [p, n, c]), Q = g(
28
+ () => v(p).length === 0,
29
+ [p]
30
+ ), y = g(
31
+ () => !n || c ? "" : be(p),
32
+ [p, n, c]
33
+ ), ae = g(
34
+ () => JSON.stringify({
35
+ requestName: r,
36
+ columns: u,
37
+ returnColumns: M,
38
+ filterField: k,
39
+ conditions: G,
40
+ orderBy: B,
41
+ initialPageSize: O,
42
+ pageSize: V,
43
+ hasParser: !!d
44
+ }),
45
+ [
46
+ u,
47
+ G,
48
+ k,
49
+ O,
50
+ B,
51
+ V,
52
+ d,
53
+ r,
54
+ M
55
+ ]
56
+ ), w = g(
57
+ () => `${ae}::${y || "__all__"}`,
58
+ [ae, y]
59
+ ), s = g(() => c || !F ? a : a.filter(
60
+ (e, t) => se(S(e, t)).startsWith(
61
+ F
62
+ )
63
+ ), [S, F, a, c]), q = Math.max(
64
+ 0,
65
+ Math.floor(Ie / N) - Te
66
+ ), we = Ce + Te * 2, ee = Math.min(
67
+ s.length,
68
+ q + we
69
+ ), Ee = g(
70
+ () => s.slice(q, ee),
71
+ [s, ee, q]
72
+ ), Re = q * N, Se = (s.length - ee) * N, l = o(() => {
73
+ K.current !== null && (window.clearTimeout(K.current), K.current = null);
74
+ }, []);
75
+ R(
76
+ () => () => {
77
+ l();
78
+ },
79
+ [l]
80
+ ), R(() => {
81
+ n || (T.current = !1);
82
+ }, [n]), R(() => {
83
+ n || W(I ?? f);
84
+ }, [n, I, f]), R(() => {
85
+ I !== null && I === f && J(null);
86
+ }, [I, f]);
87
+ const C = o(() => {
88
+ P.current && (P.current.scrollTop = 0), ue(0);
89
+ }, []), A = o(() => {
90
+ x(!1), m(!1), _(-1), X([]), $(!1), C();
91
+ }, [C]), H = o(() => {
92
+ T.current = !1, J(null), W(f), A();
93
+ }, [A, f]), L = o(
94
+ async (e, t, U) => {
95
+ const ie = ++Y.current;
96
+ le(!0);
97
+ const fe = [...G];
98
+ y && fe.push({
99
+ field: k,
100
+ operator: "startsWith",
101
+ value: y
102
+ });
103
+ const Oe = (t ? O : V) + 1, Ve = {
104
+ columns: u,
105
+ returnColumns: M,
106
+ conditions: fe,
107
+ orderBy: B,
108
+ start: e,
109
+ length: Oe,
110
+ getCount: !1
111
+ };
112
+ try {
113
+ const xe = await ce(Ve);
114
+ if (Y.current !== ie) return;
115
+ const te = xe.data ?? [], he = t ? O : V, ne = te.length > he, ge = (ne ? te.slice(0, he) : te).map((j) => d ? d(j) : j);
116
+ X((j) => {
117
+ const de = t ? ge : [...j, ...ge];
118
+ return re.current.set(U, {
119
+ rows: de,
120
+ hasMore: ne
121
+ }), de;
122
+ }), $(ne);
123
+ } finally {
124
+ Y.current === ie && le(!1);
125
+ }
126
+ },
127
+ [
128
+ u,
129
+ G,
130
+ k,
131
+ O,
132
+ B,
133
+ V,
134
+ d,
135
+ M,
136
+ y,
137
+ ce
138
+ ]
139
+ );
140
+ R(() => {
141
+ if (!n) return;
142
+ const e = re.current.get(w);
143
+ if (e) {
144
+ X(e.rows), $(e.hasMore), _(-1), C();
145
+ return;
146
+ }
147
+ X([]), $(!1);
148
+ const t = window.setTimeout(
149
+ () => {
150
+ _(-1), C(), L(0, !0, w);
151
+ },
152
+ c ? 0 : 120
153
+ );
154
+ return () => {
155
+ window.clearTimeout(t);
156
+ };
157
+ }, [
158
+ L,
159
+ n,
160
+ w,
161
+ C,
162
+ c
163
+ ]), R(() => {
164
+ if (!n || h < 0) return;
165
+ const e = P.current;
166
+ if (!e) return;
167
+ const t = h * N, U = t + N;
168
+ t < e.scrollTop ? e.scrollTop = t : U > e.scrollTop + e.clientHeight && (e.scrollTop = U - e.clientHeight);
169
+ }, [h, n]);
170
+ const E = o(
171
+ (e) => {
172
+ const t = e ? S(e, -1) : "";
173
+ T.current = !0, l(), J(t), W(t), A(), oe?.(e);
174
+ },
175
+ [l, A, S, oe]
176
+ ), me = o(() => {
177
+ T.current = !1, l(), x(!0), m(!0);
178
+ }, [l]), _e = o(() => {
179
+ T.current = !1, l(), x(!0), m(!0);
180
+ }, [l]), Ae = o(() => {
181
+ if (l(), Q) {
182
+ E(null);
183
+ return;
184
+ }
185
+ if (T.current || I !== null) {
186
+ A();
187
+ return;
188
+ }
189
+ K.current = window.setTimeout(() => {
190
+ H();
191
+ }, 150);
192
+ }, [
193
+ l,
194
+ A,
195
+ Q,
196
+ I,
197
+ H,
198
+ E
199
+ ]), He = o((e) => {
200
+ T.current = !1, J(null), W(e), x(!0), m(!1), _(v(e).length === 0 ? -1 : 0);
201
+ }, []), Le = o(
202
+ (e) => {
203
+ if (e.key === "ArrowDown") {
204
+ e.preventDefault(), x(!0), m(!1), _(
205
+ (t) => Math.min(t + 1, s.length - 1)
206
+ );
207
+ return;
208
+ }
209
+ if (e.key === "ArrowUp") {
210
+ e.preventDefault(), m(!1), _((t) => Math.max(t - 1, 0));
211
+ return;
212
+ }
213
+ if (e.key === "Enter") {
214
+ if (e.preventDefault(), Q) {
215
+ E(null);
216
+ return;
217
+ }
218
+ if (h >= 0 && s[h]) {
219
+ E(s[h]);
220
+ return;
221
+ }
222
+ if (s.length === 1) {
223
+ E(s[0]);
224
+ return;
225
+ }
226
+ H();
227
+ return;
228
+ }
229
+ e.key === "Escape" && (e.preventDefault(), H());
230
+ },
231
+ [s, h, Q, H, E]
232
+ ), Me = o(
233
+ (e) => {
234
+ const t = e.currentTarget;
235
+ ue(t.scrollTop), t.scrollTop + t.clientHeight >= t.scrollHeight - qe && !D && Z && L(a.length, !1, w);
236
+ },
237
+ [Z, D, L, w, a.length]
238
+ );
239
+ return R(() => {
240
+ !n || c || !F || D || !Z || s.length > 0 || a.length === 0 || a.length >= Ue || L(a.length, !1, w);
241
+ }, [
242
+ s.length,
243
+ Z,
244
+ D,
245
+ L,
246
+ F,
247
+ n,
248
+ w,
249
+ a.length,
250
+ c
251
+ ]), {
252
+ bottomSpacerHeight: Se,
253
+ filteredRows: s,
254
+ handleBlur: Ae,
255
+ handleChange: He,
256
+ handleFocus: me,
257
+ handleKeyDown: Le,
258
+ handleOpenAllItems: _e,
259
+ handleSuggestionsScroll: Me,
260
+ inputValue: p,
261
+ isLoading: D,
262
+ opened: n,
263
+ highlightedIndex: h,
264
+ resetToSelectedValue: H,
265
+ selectedLabel: f,
266
+ selectItem: E,
267
+ suggestionsContainerRef: P,
268
+ topSpacerHeight: Re,
269
+ visibleRows: Ee,
270
+ visibleStartIndex: q
271
+ };
272
+ };
273
+ export {
274
+ Ge as useSqlPrefixFilterComboboxState
275
+ };
@@ -9,3 +9,4 @@ export * from './Form/styles';
9
9
  export * from './Form/Row';
10
10
  export * from './styles';
11
11
  export * from './NumberInput';
12
+ export * from './SqlPrefixFilterCombobox/SqlPrefixFilterCombobox';
@@ -1,37 +1,39 @@
1
1
  import { AutoTextArea as r } from "./AutoTextArea.js";
2
2
  import { BillitIdentifier as m } from "./BillitIdentifier/index.js";
3
3
  import { Button as p, StyledButton as x } from "./Button.js";
4
- import { Select as d } from "./Select.js";
5
- import { IconButton as i } from "./IconButton.js";
6
- import { IndeterminateCheckbox as f } from "./IndeterminateCheckbox.js";
4
+ import { Select as i } from "./Select.js";
5
+ import { IconButton as d } from "./IconButton.js";
6
+ import { IndeterminateCheckbox as u } from "./IndeterminateCheckbox.js";
7
7
  import { CardForm as C } from "./Form/index.js";
8
- import { FormCondensedFields as c, FormFields as y, FormGroupContainer as b, FormGroupHeader as s, FormRowContainer as I, FormRowLabel as B, ReadOnlyValue as R, StyledCheckbox as k, inputCss as w } from "./Form/styles.js";
8
+ import { FormCondensedFields as b, FormFields as c, FormGroupContainer as y, FormGroupHeader as s, FormRowContainer as I, FormRowLabel as B, ReadOnlyValue as R, StyledCheckbox as k, inputCss as w } from "./Form/styles.js";
9
9
  import { FormRow as h } from "./Form/Row.js";
10
- import { Input as L, StackedLabelContainer as N, StyledNumericFormat as T, StyledTextArea as H, inputStyle as O } from "./styles.js";
11
- import { NumberInput as g } from "./NumberInput.js";
10
+ import { Input as L, StackedLabelContainer as N, StyledNumericFormat as T, StyledTextArea as q, inputStyle as H } from "./styles.js";
11
+ import { NumberInput as P } from "./NumberInput.js";
12
+ import { SqlPrefixFilterCombobox as g } from "./SqlPrefixFilterCombobox/SqlPrefixFilterCombobox.js";
12
13
  export {
13
14
  r as AutoTextArea,
14
15
  m as BillitIdentifier,
15
16
  p as Button,
16
17
  C as CardForm,
17
- c as FormCondensedFields,
18
- y as FormFields,
19
- b as FormGroupContainer,
18
+ b as FormCondensedFields,
19
+ c as FormFields,
20
+ y as FormGroupContainer,
20
21
  s as FormGroupHeader,
21
22
  h as FormRow,
22
23
  I as FormRowContainer,
23
24
  B as FormRowLabel,
24
- i as IconButton,
25
- f as IndeterminateCheckbox,
25
+ d as IconButton,
26
+ u as IndeterminateCheckbox,
26
27
  L as Input,
27
- g as NumberInput,
28
+ P as NumberInput,
28
29
  R as ReadOnlyValue,
29
- d as Select,
30
+ i as Select,
31
+ g as SqlPrefixFilterCombobox,
30
32
  N as StackedLabelContainer,
31
33
  x as StyledButton,
32
34
  k as StyledCheckbox,
33
35
  T as StyledNumericFormat,
34
- H as StyledTextArea,
36
+ q as StyledTextArea,
35
37
  w as inputCss,
36
- O as inputStyle
38
+ H as inputStyle
37
39
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addev-be/ui",
3
- "version": "3.3.3",
3
+ "version": "3.3.7",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "watch": "vite build --watch",
@@ -74,7 +74,7 @@
74
74
  "update-version": "../../node/update-version.mjs"
75
75
  },
76
76
  "devDependencies": {
77
- "@addev-be/framework-utils": "^3.3.3",
77
+ "@addev-be/framework-utils": "^3.3.7",
78
78
  "@testing-library/dom": "^10.4.1",
79
79
  "@testing-library/react": "^16.3.0",
80
80
  "@testing-library/react-hooks": "^8.0.1",
@@ -114,5 +114,9 @@
114
114
  "tailwind-merge": "^3.5.0",
115
115
  "uuid": "^13.0.0",
116
116
  "zod": "^4.1.9"
117
+ },
118
+ "publishConfig": {
119
+ "registry": "https://registry.npmjs.org/",
120
+ "access": "public"
117
121
  }
118
122
  }
@@ -0,0 +1,176 @@
1
+ import { CardForm } from '../Form';
2
+ import styled from 'styled-components';
3
+ import {
4
+ useSqlPrefixFilterComboboxState,
5
+ type UseSqlPrefixFilterComboboxStateProps,
6
+ } from './useSqlPrefixFilterComboboxState';
7
+
8
+ const MAX_HEIGHT_REM = 20;
9
+ const SUGGESTION_ITEM_HEIGHT = 40;
10
+
11
+ const ComboboxContainer = styled.div`
12
+ position: relative;
13
+ width: 100%;
14
+ `;
15
+
16
+ const ComboboxInput = styled.input`
17
+ font-family: var(--font-input);
18
+ font-size: var(--text-base);
19
+ font-weight: 500;
20
+ border: 1px solid var(--color-base-300);
21
+ color: var(--color-text-800);
22
+ background: var(--color-base-0);
23
+ box-sizing: border-box;
24
+ width: 100%;
25
+ padding: var(--space-2);
26
+ border-radius: var(--rounded-md);
27
+
28
+ &:focus {
29
+ outline: none;
30
+ border-color: var(--color-sky-500);
31
+ box-shadow: 0 0 0 1px var(--color-sky-500);
32
+ }
33
+ `;
34
+
35
+ const SuggestionsContainer = styled.div`
36
+ position: absolute;
37
+ top: calc(100% + var(--space-1));
38
+ left: 0;
39
+ right: 0;
40
+ z-index: 20;
41
+ max-height: ${MAX_HEIGHT_REM}rem;
42
+ background: var(--color-base-0);
43
+ border: 1px solid var(--color-base-300);
44
+ border-radius: var(--rounded-md);
45
+ box-shadow: var(--shadow-lg);
46
+ overflow-y: auto;
47
+ overflow-x: hidden;
48
+ `;
49
+
50
+ const SuggestionButton = styled.button<{ $highlighted: boolean }>`
51
+ width: 100%;
52
+ border: 0;
53
+ height: ${SUGGESTION_ITEM_HEIGHT}px;
54
+ padding: 0 var(--space-3);
55
+ text-align: left;
56
+ cursor: pointer;
57
+ background: ${({ $highlighted }) =>
58
+ $highlighted ? 'var(--color-base-100)' : 'var(--color-base-0)'};
59
+ color: var(--color-text-800);
60
+ display: flex;
61
+ align-items: center;
62
+
63
+ &:hover {
64
+ background: var(--color-base-100);
65
+ }
66
+ `;
67
+
68
+ const SuggestionsStatus = styled.div`
69
+ padding: var(--space-2) var(--space-3);
70
+ color: var(--color-text-500);
71
+ font-size: var(--text-sm);
72
+ `;
73
+
74
+ export type SqlPrefixFilterComboboxProps<T> =
75
+ UseSqlPrefixFilterComboboxStateProps<T> & {
76
+ label?: string;
77
+ itemKey: (item: T, index: number) => string;
78
+ noResultsText?: string;
79
+ placeholder?: string;
80
+ readOnly?: boolean;
81
+ };
82
+
83
+ export const SqlPrefixFilterCombobox = <T,>({
84
+ label,
85
+ value,
86
+ itemKey,
87
+ itemLabel,
88
+ readOnly = false,
89
+ noResultsText = 'Aucun resultat',
90
+ placeholder,
91
+ ...props
92
+ }: SqlPrefixFilterComboboxProps<T>) => {
93
+ const {
94
+ bottomSpacerHeight,
95
+ filteredRows,
96
+ handleBlur,
97
+ handleChange,
98
+ handleFocus,
99
+ handleKeyDown,
100
+ handleOpenAllItems,
101
+ handleSuggestionsScroll,
102
+ inputValue,
103
+ isLoading,
104
+ opened,
105
+ highlightedIndex,
106
+ selectedLabel,
107
+ selectItem,
108
+ suggestionsContainerRef,
109
+ topSpacerHeight,
110
+ visibleRows,
111
+ visibleStartIndex,
112
+ } = useSqlPrefixFilterComboboxState<T>({
113
+ ...props,
114
+ itemLabel,
115
+ value,
116
+ });
117
+
118
+ if (readOnly) {
119
+ return <CardForm.Input label={label} value={selectedLabel} readOnly />;
120
+ }
121
+
122
+ return (
123
+ <CardForm.Row label={label}>
124
+ <ComboboxContainer>
125
+ <ComboboxInput
126
+ type="text"
127
+ value={inputValue}
128
+ placeholder={opened ? undefined : placeholder}
129
+ autoComplete="off"
130
+ onMouseDown={handleOpenAllItems}
131
+ onClick={handleOpenAllItems}
132
+ onFocus={handleFocus}
133
+ onBlur={handleBlur}
134
+ onKeyDown={handleKeyDown}
135
+ onChange={(e) => handleChange(e.target.value)}
136
+ />
137
+ {opened && (
138
+ <SuggestionsContainer
139
+ ref={suggestionsContainerRef}
140
+ onScroll={handleSuggestionsScroll}
141
+ >
142
+ {filteredRows.length > 0 ? (
143
+ <>
144
+ <div style={{ height: topSpacerHeight }} />
145
+ {visibleRows.map((item, index) => {
146
+ const absoluteIndex = visibleStartIndex + index;
147
+ return (
148
+ <SuggestionButton
149
+ key={itemKey(item, absoluteIndex)}
150
+ type="button"
151
+ $highlighted={absoluteIndex === highlightedIndex}
152
+ onMouseDown={(event) => {
153
+ event.preventDefault();
154
+ selectItem(item);
155
+ }}
156
+ >
157
+ {itemLabel(item, absoluteIndex)}
158
+ </SuggestionButton>
159
+ );
160
+ })}
161
+ <div style={{ height: bottomSpacerHeight }} />
162
+ </>
163
+ ) : isLoading ? (
164
+ <SuggestionsStatus>Chargement...</SuggestionsStatus>
165
+ ) : (
166
+ <SuggestionsStatus>{noResultsText}</SuggestionsStatus>
167
+ )}
168
+ {isLoading && filteredRows.length > 0 && (
169
+ <SuggestionsStatus>Chargement...</SuggestionsStatus>
170
+ )}
171
+ </SuggestionsContainer>
172
+ )}
173
+ </ComboboxContainer>
174
+ </CardForm.Row>
175
+ );
176
+ };
@@ -0,0 +1,522 @@
1
+ import {
2
+ ConditionDTO,
3
+ OrderByDTO,
4
+ SqlRequestDTO,
5
+ SqlRequestRow,
6
+ useSqlRequestHandler,
7
+ } from '../../../services/sqlRequests';
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+
10
+ const INITIAL_PAGE_SIZE_DEFAULT = 25;
11
+ const PAGE_SIZE_DEFAULT = 50;
12
+ const SUGGESTION_ITEM_HEIGHT = 40;
13
+ const LOAD_MORE_THRESHOLD_PX = 80;
14
+ const VISIBLE_ITEMS_COUNT = 8;
15
+ const VIRTUALIZATION_BUFFER = 6;
16
+ const SERVER_FALLBACK_PREFIX_LENGTH = 2;
17
+ const MAX_AUTOFETCH_ROWS = 250;
18
+
19
+ const normalizeSearchValue = (value?: string | null) =>
20
+ (value ?? '')
21
+ .normalize('NFD')
22
+ .replace(/[\u0300-\u036f]/g, '')
23
+ .trim()
24
+ .toLowerCase();
25
+
26
+ const normalizeLooseSearchValue = (value?: string | null) =>
27
+ normalizeSearchValue(value).replace(/[^a-z0-9]+/g, '');
28
+
29
+ const getServerFilterValue = (value?: string | null) => {
30
+ const compactValue = normalizeLooseSearchValue(value);
31
+
32
+ if (!compactValue) return '';
33
+
34
+ const normalizedValue = normalizeSearchValue(value);
35
+ const leadingToken = normalizedValue.match(/^[a-z0-9]+/)?.[0] ?? '';
36
+
37
+ if (/[^a-z0-9]/i.test(value ?? '')) {
38
+ return leadingToken || compactValue.slice(0, SERVER_FALLBACK_PREFIX_LENGTH);
39
+ }
40
+
41
+ return compactValue.slice(0, SERVER_FALLBACK_PREFIX_LENGTH);
42
+ };
43
+
44
+ export type UseSqlPrefixFilterComboboxStateProps<T> = {
45
+ requestName: string;
46
+ columns: string[];
47
+ returnColumns?: string[];
48
+ parser?: (row: SqlRequestRow<T>) => T;
49
+ value: T | null;
50
+ itemLabel: (item: T, index: number) => string;
51
+ filterField: string;
52
+ conditions?: ConditionDTO[];
53
+ orderBy?: OrderByDTO[];
54
+ onChange?: (value: T | null) => void;
55
+ initialPageSize?: number;
56
+ pageSize?: number;
57
+ };
58
+
59
+ export const useSqlPrefixFilterComboboxState = <T>({
60
+ requestName,
61
+ columns,
62
+ returnColumns = columns,
63
+ parser,
64
+ value,
65
+ itemLabel,
66
+ filterField,
67
+ conditions = [],
68
+ orderBy = [],
69
+ onChange,
70
+ initialPageSize = INITIAL_PAGE_SIZE_DEFAULT,
71
+ pageSize = PAGE_SIZE_DEFAULT,
72
+ }: UseSqlPrefixFilterComboboxStateProps<T>) => {
73
+ const closeTimeoutRef = useRef<number | null>(null);
74
+ const suggestionsContainerRef = useRef<HTMLDivElement | null>(null);
75
+ const requestIdRef = useRef(0);
76
+ const selectingItemRef = useRef(false);
77
+ const cacheRef = useRef<Map<string, { rows: T[]; hasMore: boolean }>>(
78
+ new Map()
79
+ );
80
+
81
+ const [sqlRequest] = useSqlRequestHandler<T>(requestName);
82
+ const selectedLabel = useMemo(
83
+ () => (value ? itemLabel(value, -1) : ''),
84
+ [itemLabel, value]
85
+ );
86
+
87
+ const [inputValue, setInputValue] = useState(selectedLabel);
88
+ const [opened, setOpened] = useState(false);
89
+ const [showAllOnFocus, setShowAllOnFocus] = useState(false);
90
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
91
+ const [rows, setRows] = useState<T[]>([]);
92
+ const [hasMore, setHasMore] = useState(false);
93
+ const [isLoading, setIsLoading] = useState(false);
94
+ const [scrollTop, setScrollTop] = useState(0);
95
+ const [pendingSelectionLabel, setPendingSelectionLabel] = useState<
96
+ string | null
97
+ >(null);
98
+
99
+ const matchingQuery = useMemo(() => {
100
+ if (!opened || showAllOnFocus) return '';
101
+ return normalizeLooseSearchValue(inputValue);
102
+ }, [inputValue, opened, showAllOnFocus]);
103
+ const isInputEmpty = useMemo(
104
+ () => normalizeSearchValue(inputValue).length === 0,
105
+ [inputValue]
106
+ );
107
+
108
+ const serverFilterValue = useMemo(
109
+ () => (!opened || showAllOnFocus ? '' : getServerFilterValue(inputValue)),
110
+ [inputValue, opened, showAllOnFocus]
111
+ );
112
+
113
+ const requestScopeKey = useMemo(
114
+ () =>
115
+ JSON.stringify({
116
+ requestName,
117
+ columns,
118
+ returnColumns,
119
+ filterField,
120
+ conditions,
121
+ orderBy,
122
+ initialPageSize,
123
+ pageSize,
124
+ hasParser: Boolean(parser),
125
+ }),
126
+ [
127
+ columns,
128
+ conditions,
129
+ filterField,
130
+ initialPageSize,
131
+ orderBy,
132
+ pageSize,
133
+ parser,
134
+ requestName,
135
+ returnColumns,
136
+ ]
137
+ );
138
+ const requestCacheKey = useMemo(
139
+ () => `${requestScopeKey}::${serverFilterValue || '__all__'}`,
140
+ [requestScopeKey, serverFilterValue]
141
+ );
142
+
143
+ const filteredRows = useMemo(() => {
144
+ if (showAllOnFocus || !matchingQuery) return rows;
145
+
146
+ return rows.filter((item, index) =>
147
+ normalizeLooseSearchValue(itemLabel(item, index)).startsWith(
148
+ matchingQuery
149
+ )
150
+ );
151
+ }, [itemLabel, matchingQuery, rows, showAllOnFocus]);
152
+
153
+ const visibleStartIndex = Math.max(
154
+ 0,
155
+ Math.floor(scrollTop / SUGGESTION_ITEM_HEIGHT) - VIRTUALIZATION_BUFFER
156
+ );
157
+ const visibleLength = VISIBLE_ITEMS_COUNT + VIRTUALIZATION_BUFFER * 2;
158
+ const visibleEndIndex = Math.min(
159
+ filteredRows.length,
160
+ visibleStartIndex + visibleLength
161
+ );
162
+ const visibleRows = useMemo(
163
+ () => filteredRows.slice(visibleStartIndex, visibleEndIndex),
164
+ [filteredRows, visibleEndIndex, visibleStartIndex]
165
+ );
166
+ const topSpacerHeight = visibleStartIndex * SUGGESTION_ITEM_HEIGHT;
167
+ const bottomSpacerHeight =
168
+ (filteredRows.length - visibleEndIndex) * SUGGESTION_ITEM_HEIGHT;
169
+
170
+ const clearCloseTimeout = useCallback(() => {
171
+ if (closeTimeoutRef.current !== null) {
172
+ window.clearTimeout(closeTimeoutRef.current);
173
+ closeTimeoutRef.current = null;
174
+ }
175
+ }, []);
176
+
177
+ useEffect(
178
+ () => () => {
179
+ clearCloseTimeout();
180
+ },
181
+ [clearCloseTimeout]
182
+ );
183
+
184
+ useEffect(() => {
185
+ if (!opened) {
186
+ selectingItemRef.current = false;
187
+ }
188
+ }, [opened]);
189
+
190
+ useEffect(() => {
191
+ if (!opened) {
192
+ setInputValue(pendingSelectionLabel ?? selectedLabel);
193
+ }
194
+ }, [opened, pendingSelectionLabel, selectedLabel]);
195
+
196
+ useEffect(() => {
197
+ if (
198
+ pendingSelectionLabel !== null &&
199
+ pendingSelectionLabel === selectedLabel
200
+ ) {
201
+ setPendingSelectionLabel(null);
202
+ }
203
+ }, [pendingSelectionLabel, selectedLabel]);
204
+
205
+ const resetSuggestionsScroll = useCallback(() => {
206
+ if (suggestionsContainerRef.current) {
207
+ suggestionsContainerRef.current.scrollTop = 0;
208
+ }
209
+ setScrollTop(0);
210
+ }, []);
211
+
212
+ const closeSuggestions = useCallback(() => {
213
+ setOpened(false);
214
+ setShowAllOnFocus(false);
215
+ setHighlightedIndex(-1);
216
+ setRows([]);
217
+ setHasMore(false);
218
+ resetSuggestionsScroll();
219
+ }, [resetSuggestionsScroll]);
220
+
221
+ const resetToSelectedValue = useCallback(() => {
222
+ selectingItemRef.current = false;
223
+ setPendingSelectionLabel(null);
224
+ setInputValue(selectedLabel);
225
+ closeSuggestions();
226
+ }, [closeSuggestions, selectedLabel]);
227
+
228
+ const loadRows = useCallback(
229
+ async (start: number, reset: boolean, cacheKey: string) => {
230
+ const currentRequestId = ++requestIdRef.current;
231
+ setIsLoading(true);
232
+
233
+ const nextConditions: ConditionDTO[] = [...conditions];
234
+
235
+ if (serverFilterValue) {
236
+ nextConditions.push({
237
+ field: filterField,
238
+ operator: 'startsWith',
239
+ value: serverFilterValue,
240
+ });
241
+ }
242
+
243
+ const requestedLength = (reset ? initialPageSize : pageSize) + 1;
244
+
245
+ const request: SqlRequestDTO = {
246
+ columns,
247
+ returnColumns,
248
+ conditions: nextConditions,
249
+ orderBy,
250
+ start,
251
+ length: requestedLength,
252
+ getCount: false,
253
+ };
254
+
255
+ try {
256
+ const response = await sqlRequest(request);
257
+
258
+ if (requestIdRef.current !== currentRequestId) return;
259
+
260
+ const fetchedRows = response.data ?? [];
261
+ const pageLength = reset ? initialPageSize : pageSize;
262
+ const nextHasMore = fetchedRows.length > pageLength;
263
+ const nextRows = (
264
+ nextHasMore ? fetchedRows.slice(0, pageLength) : fetchedRows
265
+ ).map((row) => (parser ? parser(row) : (row as T)));
266
+
267
+ setRows((prevRows) => {
268
+ const mergedRows = reset ? nextRows : [...prevRows, ...nextRows];
269
+ cacheRef.current.set(cacheKey, {
270
+ rows: mergedRows,
271
+ hasMore: nextHasMore,
272
+ });
273
+ return mergedRows;
274
+ });
275
+ setHasMore(nextHasMore);
276
+ } finally {
277
+ if (requestIdRef.current === currentRequestId) {
278
+ setIsLoading(false);
279
+ }
280
+ }
281
+ },
282
+ [
283
+ columns,
284
+ conditions,
285
+ filterField,
286
+ initialPageSize,
287
+ orderBy,
288
+ pageSize,
289
+ parser,
290
+ returnColumns,
291
+ serverFilterValue,
292
+ sqlRequest,
293
+ ]
294
+ );
295
+
296
+ useEffect(() => {
297
+ if (!opened) return;
298
+
299
+ const cachedValue = cacheRef.current.get(requestCacheKey);
300
+ if (cachedValue) {
301
+ setRows(cachedValue.rows);
302
+ setHasMore(cachedValue.hasMore);
303
+ setHighlightedIndex(-1);
304
+ resetSuggestionsScroll();
305
+ return;
306
+ }
307
+
308
+ setRows([]);
309
+ setHasMore(false);
310
+
311
+ const timeoutId = window.setTimeout(
312
+ () => {
313
+ setHighlightedIndex(-1);
314
+ resetSuggestionsScroll();
315
+ loadRows(0, true, requestCacheKey);
316
+ },
317
+ showAllOnFocus ? 0 : 120
318
+ );
319
+
320
+ return () => {
321
+ window.clearTimeout(timeoutId);
322
+ };
323
+ }, [
324
+ loadRows,
325
+ opened,
326
+ requestCacheKey,
327
+ resetSuggestionsScroll,
328
+ showAllOnFocus,
329
+ ]);
330
+
331
+ useEffect(() => {
332
+ if (!opened || highlightedIndex < 0) return;
333
+
334
+ const container = suggestionsContainerRef.current;
335
+
336
+ if (!container) return;
337
+
338
+ const itemTop = highlightedIndex * SUGGESTION_ITEM_HEIGHT;
339
+ const itemBottom = itemTop + SUGGESTION_ITEM_HEIGHT;
340
+
341
+ if (itemTop < container.scrollTop) {
342
+ container.scrollTop = itemTop;
343
+ } else if (itemBottom > container.scrollTop + container.clientHeight) {
344
+ container.scrollTop = itemBottom - container.clientHeight;
345
+ }
346
+ }, [highlightedIndex, opened]);
347
+
348
+ const selectItem = useCallback(
349
+ (item: T | null) => {
350
+ const nextLabel = item ? itemLabel(item, -1) : '';
351
+ selectingItemRef.current = true;
352
+ clearCloseTimeout();
353
+ setPendingSelectionLabel(nextLabel);
354
+ setInputValue(nextLabel);
355
+ closeSuggestions();
356
+ onChange?.(item);
357
+ },
358
+ [clearCloseTimeout, closeSuggestions, itemLabel, onChange]
359
+ );
360
+
361
+ const handleFocus = useCallback(() => {
362
+ selectingItemRef.current = false;
363
+ clearCloseTimeout();
364
+ setOpened(true);
365
+ setShowAllOnFocus(true);
366
+ }, [clearCloseTimeout]);
367
+
368
+ const handleOpenAllItems = useCallback(() => {
369
+ selectingItemRef.current = false;
370
+ clearCloseTimeout();
371
+ setOpened(true);
372
+ setShowAllOnFocus(true);
373
+ }, [clearCloseTimeout]);
374
+
375
+ const handleBlur = useCallback(() => {
376
+ clearCloseTimeout();
377
+
378
+ if (isInputEmpty) {
379
+ selectItem(null);
380
+ return;
381
+ }
382
+
383
+ if (selectingItemRef.current || pendingSelectionLabel !== null) {
384
+ closeSuggestions();
385
+ return;
386
+ }
387
+
388
+ closeTimeoutRef.current = window.setTimeout(() => {
389
+ resetToSelectedValue();
390
+ }, 150);
391
+ }, [
392
+ clearCloseTimeout,
393
+ closeSuggestions,
394
+ isInputEmpty,
395
+ pendingSelectionLabel,
396
+ resetToSelectedValue,
397
+ selectItem,
398
+ ]);
399
+
400
+ const handleChange = useCallback((nextValue: string) => {
401
+ selectingItemRef.current = false;
402
+ setPendingSelectionLabel(null);
403
+ setInputValue(nextValue);
404
+ setOpened(true);
405
+ setShowAllOnFocus(false);
406
+ setHighlightedIndex(normalizeSearchValue(nextValue).length === 0 ? -1 : 0);
407
+ }, []);
408
+
409
+ const handleKeyDown = useCallback(
410
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
411
+ if (event.key === 'ArrowDown') {
412
+ event.preventDefault();
413
+ setOpened(true);
414
+ setShowAllOnFocus(false);
415
+ setHighlightedIndex((prev) =>
416
+ Math.min(prev + 1, filteredRows.length - 1)
417
+ );
418
+ return;
419
+ }
420
+
421
+ if (event.key === 'ArrowUp') {
422
+ event.preventDefault();
423
+ setShowAllOnFocus(false);
424
+ setHighlightedIndex((prev) => Math.max(prev - 1, 0));
425
+ return;
426
+ }
427
+
428
+ if (event.key === 'Enter') {
429
+ event.preventDefault();
430
+
431
+ if (isInputEmpty) {
432
+ selectItem(null);
433
+ return;
434
+ }
435
+
436
+ if (highlightedIndex >= 0 && filteredRows[highlightedIndex]) {
437
+ selectItem(filteredRows[highlightedIndex]);
438
+ return;
439
+ }
440
+
441
+ if (filteredRows.length === 1) {
442
+ selectItem(filteredRows[0]);
443
+ return;
444
+ }
445
+
446
+ resetToSelectedValue();
447
+ return;
448
+ }
449
+
450
+ if (event.key === 'Escape') {
451
+ event.preventDefault();
452
+ resetToSelectedValue();
453
+ }
454
+ },
455
+ [filteredRows, highlightedIndex, isInputEmpty, resetToSelectedValue, selectItem]
456
+ );
457
+
458
+ const handleSuggestionsScroll = useCallback(
459
+ (event: React.UIEvent<HTMLDivElement>) => {
460
+ const container = event.currentTarget;
461
+ setScrollTop(container.scrollTop);
462
+
463
+ const isNearBottom =
464
+ container.scrollTop + container.clientHeight >=
465
+ container.scrollHeight - LOAD_MORE_THRESHOLD_PX;
466
+
467
+ if (isNearBottom && !isLoading && hasMore) {
468
+ loadRows(rows.length, false, requestCacheKey);
469
+ }
470
+ },
471
+ [hasMore, isLoading, loadRows, requestCacheKey, rows.length]
472
+ );
473
+
474
+ useEffect(() => {
475
+ if (
476
+ !opened ||
477
+ showAllOnFocus ||
478
+ !matchingQuery ||
479
+ isLoading ||
480
+ !hasMore ||
481
+ filteredRows.length > 0 ||
482
+ rows.length === 0 ||
483
+ rows.length >= MAX_AUTOFETCH_ROWS
484
+ ) {
485
+ return;
486
+ }
487
+
488
+ loadRows(rows.length, false, requestCacheKey);
489
+ }, [
490
+ filteredRows.length,
491
+ hasMore,
492
+ isLoading,
493
+ loadRows,
494
+ matchingQuery,
495
+ opened,
496
+ requestCacheKey,
497
+ rows.length,
498
+ showAllOnFocus,
499
+ ]);
500
+
501
+ return {
502
+ bottomSpacerHeight,
503
+ filteredRows,
504
+ handleBlur,
505
+ handleChange,
506
+ handleFocus,
507
+ handleKeyDown,
508
+ handleOpenAllItems,
509
+ handleSuggestionsScroll,
510
+ inputValue,
511
+ isLoading,
512
+ opened,
513
+ highlightedIndex,
514
+ resetToSelectedValue,
515
+ selectedLabel,
516
+ selectItem,
517
+ suggestionsContainerRef,
518
+ topSpacerHeight,
519
+ visibleRows,
520
+ visibleStartIndex,
521
+ };
522
+ };
@@ -9,3 +9,4 @@ export * from './Form/styles';
9
9
  export * from './Form/Row';
10
10
  export * from './styles';
11
11
  export * from './NumberInput';
12
+ export * from './SqlPrefixFilterCombobox/SqlPrefixFilterCombobox';