kelt-ui-kit-react 2.0.1 → 2.0.2

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kelt-ui-kit-react",
3
3
  "type": "module",
4
- "version": "2.0.1",
4
+ "version": "2.0.2",
5
5
  "private": false,
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
package/src/App.menu.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NavLink } from "react-router-dom";
2
2
  import { ActionView } from "./action/Action.view";
3
+ import { AddressSearchView } from "./addressSearch/AddressSearch.view";
3
4
  import { BadgeView } from "./badge/Badge.view";
4
5
  import { ButtonView } from "./button/Button.view";
5
6
  import { ButtonFileView } from "./button/buttonFile/ButtonFile.view";
@@ -242,6 +243,13 @@ export const MenuList: MenuProps[] = [
242
243
  children: <NavLink to="/button-file">Button File</NavLink>,
243
244
  element: <ButtonFileView />,
244
245
  },
246
+ {
247
+ id: 30,
248
+ name: "Address search",
249
+ link: "/address-search",
250
+ children: <NavLink to="/address-search">Address search</NavLink>,
251
+ element: <AddressSearchView />,
252
+ },
245
253
  ];
246
254
 
247
255
  export const AppMenu = (): JSX.Element => {
@@ -0,0 +1,246 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import { Icon } from "../icon/Icon";
9
+ import {
10
+ GeocodedAddress,
11
+ GeocodingProvider,
12
+ } from "../geocoding/geocoding.interface";
13
+ import { geocodingService } from "../geocoding/GeocodingService";
14
+ import "./addressSearch.css";
15
+
16
+ export interface AddressSearchProps {
17
+ id?: string;
18
+ className?: string;
19
+ label?: string;
20
+ placeholder?: string;
21
+ value?: string;
22
+ disabled?: boolean;
23
+ minimumQueryLength?: number;
24
+ debounceTime?: number;
25
+ limit?: number;
26
+ provider?: GeocodingProvider;
27
+ allowManualEntry?: boolean;
28
+ manualEntryLabel?: string;
29
+ onChange?: (value: string) => void;
30
+ onManualEntry?: (addressLine: string) => void;
31
+ onSelect: (address: GeocodedAddress) => void;
32
+ }
33
+
34
+ export const AddressSearch = ({
35
+ id,
36
+ className,
37
+ label = "Rechercher une adresse",
38
+ placeholder = "Numéro, rue, code postal, ville",
39
+ value,
40
+ disabled = false,
41
+ minimumQueryLength = 3,
42
+ debounceTime = 300,
43
+ limit = 5,
44
+ provider = geocodingService,
45
+ allowManualEntry = true,
46
+ manualEntryLabel = "Je ne trouve pas mon adresse",
47
+ onChange,
48
+ onManualEntry,
49
+ onSelect,
50
+ }: AddressSearchProps) => {
51
+ const generatedId = useId();
52
+ const inputId = id ?? `address-search-${generatedId}`;
53
+ const listboxId = `${inputId}-results`;
54
+ const [query, setQuery] = useState(value ?? "");
55
+ const [results, setResults] = useState<GeocodedAddress[]>([]);
56
+ const [loading, setLoading] = useState(false);
57
+ const [error, setError] = useState(false);
58
+ const [isOpen, setIsOpen] = useState(false);
59
+ const [activeIndex, setActiveIndex] = useState(-1);
60
+ const containerRef = useRef<HTMLDivElement>(null);
61
+ const requestRef = useRef<AbortController>();
62
+ const skipNextSearchRef = useRef(false);
63
+
64
+ useEffect(() => {
65
+ if (value !== undefined) setQuery(value);
66
+ }, [value]);
67
+
68
+ useEffect(() => {
69
+ const handlePointerDown = (event: PointerEvent) => {
70
+ if (!containerRef.current?.contains(event.target as Node)) {
71
+ setIsOpen(false);
72
+ }
73
+ };
74
+
75
+ document.addEventListener("pointerdown", handlePointerDown);
76
+ return () => document.removeEventListener("pointerdown", handlePointerDown);
77
+ }, []);
78
+
79
+ useEffect(() => {
80
+ if (skipNextSearchRef.current) {
81
+ skipNextSearchRef.current = false;
82
+ return;
83
+ }
84
+
85
+ const normalizedQuery = query.trim();
86
+ if (normalizedQuery.length < minimumQueryLength || disabled) {
87
+ requestRef.current?.abort();
88
+ setResults([]);
89
+ setLoading(false);
90
+ setError(false);
91
+ setIsOpen(false);
92
+ return;
93
+ }
94
+
95
+ const timeout = window.setTimeout(async () => {
96
+ requestRef.current?.abort();
97
+ const controller = new AbortController();
98
+ requestRef.current = controller;
99
+ setLoading(true);
100
+ setError(false);
101
+
102
+ try {
103
+ const addresses = await provider.search(normalizedQuery, {
104
+ limit,
105
+ signal: controller.signal,
106
+ });
107
+ setResults(addresses);
108
+ setActiveIndex(-1);
109
+ setIsOpen(true);
110
+ } catch (requestError) {
111
+ if ((requestError as Error).name !== "AbortError") {
112
+ setResults([]);
113
+ setError(true);
114
+ setIsOpen(true);
115
+ }
116
+ } finally {
117
+ if (!controller.signal.aborted) setLoading(false);
118
+ }
119
+ }, debounceTime);
120
+
121
+ return () => {
122
+ window.clearTimeout(timeout);
123
+ requestRef.current?.abort();
124
+ };
125
+ }, [debounceTime, disabled, limit, minimumQueryLength, provider, query]);
126
+
127
+ const selectAddress = useCallback(
128
+ (address: GeocodedAddress) => {
129
+ requestRef.current?.abort();
130
+ skipNextSearchRef.current = true;
131
+ setQuery(address.addressLine);
132
+ setResults([]);
133
+ setIsOpen(false);
134
+ setActiveIndex(-1);
135
+ onChange?.(address.addressLine);
136
+ onSelect(address);
137
+ },
138
+ [onChange, onSelect],
139
+ );
140
+
141
+ const selectManualAddress = useCallback(() => {
142
+ const addressLine = query.trim();
143
+ if (!addressLine) return;
144
+
145
+ requestRef.current?.abort();
146
+ skipNextSearchRef.current = true;
147
+ setQuery(addressLine);
148
+ setResults([]);
149
+ setIsOpen(false);
150
+ setActiveIndex(-1);
151
+ onChange?.(addressLine);
152
+ onManualEntry?.(addressLine);
153
+ }, [onChange, onManualEntry, query]);
154
+
155
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
156
+ if (!isOpen || results.length === 0) return;
157
+
158
+ if (event.key === "ArrowDown") {
159
+ event.preventDefault();
160
+ setActiveIndex((index) => (index + 1) % results.length);
161
+ } else if (event.key === "ArrowUp") {
162
+ event.preventDefault();
163
+ setActiveIndex((index) => (index <= 0 ? results.length - 1 : index - 1));
164
+ } else if (event.key === "Enter" && activeIndex >= 0) {
165
+ event.preventDefault();
166
+ selectAddress(results[activeIndex]);
167
+ } else if (event.key === "Escape") {
168
+ setIsOpen(false);
169
+ }
170
+ };
171
+
172
+ return (
173
+ <div ref={containerRef} className={`address-search ${className ?? ""}`}>
174
+ {label && <label htmlFor={inputId}>{label}</label>}
175
+ <div className="address-search-input">
176
+ <Icon classIcon="bi-geo-alt" />
177
+ <input
178
+ id={inputId}
179
+ type="search"
180
+ role="combobox"
181
+ aria-autocomplete="list"
182
+ aria-expanded={isOpen}
183
+ aria-controls={listboxId}
184
+ aria-activedescendant={
185
+ activeIndex >= 0 ? `${inputId}-option-${activeIndex}` : undefined
186
+ }
187
+ autoComplete="off"
188
+ disabled={disabled}
189
+ value={query}
190
+ placeholder={placeholder}
191
+ onChange={(event) => {
192
+ setQuery(event.target.value);
193
+ onChange?.(event.target.value);
194
+ }}
195
+ onFocus={() => results.length > 0 && setIsOpen(true)}
196
+ onKeyDown={handleKeyDown}
197
+ />
198
+ {loading && <span className="address-search-spinner" aria-hidden />}
199
+ </div>
200
+
201
+ {isOpen && (
202
+ <div className="address-search-results">
203
+ <div id={listboxId} role="listbox">
204
+ {error ? (
205
+ <div className="address-search-status" role="alert">
206
+ La recherche d'adresse est indisponible.
207
+ </div>
208
+ ) : results.length === 0 && !loading ? (
209
+ <div className="address-search-status">Aucune adresse trouvée.</div>
210
+ ) : (
211
+ results.map((address, index) => (
212
+ <button
213
+ id={`${inputId}-option-${index}`}
214
+ key={`${address.id}-${index}`}
215
+ type="button"
216
+ role="option"
217
+ aria-selected={activeIndex === index}
218
+ className={activeIndex === index ? "active" : ""}
219
+ onMouseDown={(event) => event.preventDefault()}
220
+ onMouseEnter={() => setActiveIndex(index)}
221
+ onClick={() => selectAddress(address)}
222
+ >
223
+ <span>{address.addressLine}</span>
224
+ <small>
225
+ {address.postalCode} {address.city}
226
+ </small>
227
+ </button>
228
+ ))
229
+ )}
230
+ </div>
231
+ {allowManualEntry && query.trim() && !loading && (
232
+ <button
233
+ type="button"
234
+ className="address-search-manual"
235
+ onMouseDown={(event) => event.preventDefault()}
236
+ onClick={selectManualAddress}
237
+ >
238
+ <Icon classIcon="bi-pencil" />
239
+ {manualEntryLabel}
240
+ </button>
241
+ )}
242
+ </div>
243
+ )}
244
+ </div>
245
+ );
246
+ };
@@ -0,0 +1,52 @@
1
+ import { useMemo } from "react";
2
+ import { DynamicForm } from "../form/Form";
3
+ import { TypeInputEnum } from "../form/form.enum";
4
+ import { FormInterface } from "../form/form.interface";
5
+
6
+ export const AddressSearchView = () => {
7
+ const fields = useMemo<FormInterface[]>(
8
+ () => [
9
+ {
10
+ name: "adresse1",
11
+ label: "Adresse",
12
+ type: TypeInputEnum.ADDRESS,
13
+ value: "",
14
+ col: { xs: 12, md: 6 },
15
+ addressSearch: {
16
+ mapAddress: (address) => ({
17
+ adresse1: address.addressLine,
18
+ codePostal: address.postalCode,
19
+ ville: address.city,
20
+ paysIso2: address.countryCode,
21
+ }),
22
+ },
23
+ },
24
+ {
25
+ name: "codePostal",
26
+ label: "Code postal",
27
+ type: TypeInputEnum.TEXT,
28
+ value: "",
29
+ col: { xs: 12, md: 6 },
30
+ },
31
+ {
32
+ name: "ville",
33
+ label: "Ville",
34
+ type: TypeInputEnum.TEXT,
35
+ value: "",
36
+ col: { xs: 12, md: 6 },
37
+ },
38
+ {
39
+ name: "paysIso2",
40
+ label: "Pays",
41
+ type: TypeInputEnum.TEXT,
42
+ value: "FR",
43
+ col: { xs: 12, md: 6 },
44
+ },
45
+ ],
46
+ [],
47
+ );
48
+
49
+ return (
50
+ <DynamicForm initialForm={fields} hideSubmit />
51
+ );
52
+ };
@@ -0,0 +1,133 @@
1
+ .address-search {
2
+ position: relative;
3
+ width: 100%;
4
+
5
+ >label {
6
+ display: block;
7
+ padding-bottom: 0.15rem;
8
+ font-size: 0.85rem;
9
+ font-weight: 600;
10
+ }
11
+ }
12
+
13
+ .address-search-input {
14
+ position: relative;
15
+ display: flex;
16
+ align-items: center;
17
+
18
+ >.bi {
19
+ position: absolute;
20
+ left: 0.75rem;
21
+ z-index: 1;
22
+ color: var(--primary-color);
23
+ }
24
+
25
+ >input {
26
+ box-sizing: border-box;
27
+ width: 100%;
28
+ min-width: 0;
29
+ padding-left: 2.25rem;
30
+ padding-right: 2.25rem;
31
+ }
32
+ }
33
+
34
+ .address-search-spinner {
35
+ position: absolute;
36
+ right: 0.75rem;
37
+ width: 0.9rem;
38
+ height: 0.9rem;
39
+ border: 2px solid #dedede;
40
+ border-top-color: var(--primary-color);
41
+ border-radius: 50%;
42
+ animation: address-search-spin 0.7s linear infinite;
43
+ }
44
+
45
+ .address-search-results {
46
+ position: absolute;
47
+ top: calc(100% + 0.25rem);
48
+ right: 0;
49
+ left: 0;
50
+ z-index: 20;
51
+ overflow: hidden;
52
+ border: 1px solid #cfbdbd;
53
+ border-radius: 4px;
54
+ background: #fff;
55
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.14);
56
+
57
+ [role="listbox"] {
58
+ max-height: 15rem;
59
+ overflow-y: auto;
60
+ }
61
+
62
+ [role="listbox"]>button {
63
+ display: flex;
64
+ width: 100%;
65
+ min-width: 0;
66
+ max-height: none;
67
+ margin: 0;
68
+ padding: 0.65rem 0.75rem;
69
+ flex-direction: column;
70
+ align-items: flex-start;
71
+ gap: 0.2rem;
72
+ border: 0;
73
+ border-bottom: 1px solid #ece8e8;
74
+ border-radius: 0;
75
+ background: #fff;
76
+ color: inherit;
77
+ font-size: 0.85rem;
78
+ line-height: 1.3;
79
+ text-align: left;
80
+ overflow-wrap: anywhere;
81
+ cursor: pointer;
82
+
83
+ &:last-child {
84
+ border-bottom: 0;
85
+ }
86
+
87
+ &:hover,
88
+ &.active {
89
+ background: color-mix(in srgb, var(--primary-color), white 92%);
90
+ }
91
+
92
+ >small {
93
+ color: #686868;
94
+ }
95
+ }
96
+ }
97
+
98
+ .address-search-manual {
99
+ display: flex;
100
+ width: 100%;
101
+ min-width: 0;
102
+ max-height: none;
103
+ margin: 0;
104
+ padding: 0.65rem 0.75rem;
105
+ align-items: center;
106
+ gap: 0.5rem;
107
+ border: 0;
108
+ border-top: 1px solid #dedede;
109
+ border-radius: 0;
110
+ background: #fff;
111
+ color: var(--primary-color);
112
+ font-size: 0.85rem;
113
+ font-weight: 600;
114
+ text-align: left;
115
+ cursor: pointer;
116
+
117
+ &:hover,
118
+ &:focus-visible {
119
+ background: color-mix(in srgb, var(--primary-color), white 92%);
120
+ }
121
+ }
122
+
123
+ .address-search-status {
124
+ padding: 0.75rem;
125
+ color: #686868;
126
+ font-size: 0.85rem;
127
+ }
128
+
129
+ @keyframes address-search-spin {
130
+ to {
131
+ transform: rotate(360deg);
132
+ }
133
+ }