aplance-ui 1.3.0 → 1.4.0

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.
@@ -1,484 +1,500 @@
1
- import type { Align, Position } from "../../../primitives/popover/popover";
2
- import {
3
- type CSSProperties,
4
- type ReactNode,
5
- useCallback,
6
- useEffect,
7
- useId,
8
- useMemo,
9
- useState,
10
- } from "react";
11
- import clsx from "clsx";
12
- import { IconPlus, IconTrash } from "@tabler/icons-react";
13
- import { Input } from "../input/input";
14
- import {
15
- Popover,
16
- PopoverTrigger,
17
- PopoverMenu,
18
- } from "../../../primitives/popover/popover";
19
- import type { ComponentProps } from "react";
20
- import sCommon from "../common/input-field-common.module.css";
21
- import sPickerCommon from "../common/picker-popover-common.module.css";
22
- import { Button, type ButtonVariant } from "../../button/button";
23
- import s from "./custom-select.module.css";
24
- import { FieldMessage } from "../common/field-message/field-message";
25
- import { SelectButton, type SelectButtonProps } from "../common/select-button/select-button";
26
- import { Icon } from "../../../primitives/icon/icon";
27
- import type { BaseInputFieldProps } from "../common/types";
28
-
29
- export type CustomSelectOption = {
30
- value: string;
31
- label: string;
32
- icon?: ReactNode;
33
- optionStyle?: CSSProperties;
34
- optionClassName?: string;
35
- optionButtonVariant?: ButtonVariant;
36
- applyToSelector?: boolean;
37
- disabled?: boolean;
38
- /** Renders a trailing delete affordance on the option row. Clicking it does
39
- * NOT select the option - the consumer typically opens a confirm dialog and
40
- * performs the deletion (e.g. removing a folder/tag from its pool). */
41
- onDelete?: () => void;
42
- };
43
-
44
- export type CustomSelectProps = Omit<
45
- BaseInputFieldProps,
46
- // its used in selectButton so no need it here
47
- "buttonStyle"
48
- > & {
49
- // Select specific props
50
- placeholder?: string;
51
- searchPlaceholder?: string;
52
- options: CustomSelectOption[];
53
- value?: string | string[];
54
- defaultValue?: string | string[];
55
- onChange?: (value: string | string[]) => void;
56
- multiple?: boolean;
57
- searchable?: boolean;
58
- /**
59
- * Lets the user create a new option from the search term when nothing in
60
- * `options` matches it. Requires `searchable`. The created value is emitted
61
- * through `onChange` (and added to `selectedValues`); the consumer is
62
- * responsible for persisting it / pushing it into the next `options` list.
63
- */
64
- allowCreate?: boolean;
65
- showDisplayText?: boolean;
66
- showDisplayIcon?: boolean;
67
- onClear?: () => void;
68
-
69
- // Popover behavior defaults
70
- closeOnPathChange?: boolean;
71
- closeOnClickOutside?: boolean;
72
- animationDuration?: number;
73
- position?: Position;
74
- align?: Align;
75
-
76
- // Nested popover props
77
- popover?: Omit<ComponentProps<typeof Popover>, "children">;
78
- popoverTrigger?: ComponentProps<typeof PopoverTrigger>;
79
- popoverMenu?: ComponentProps<typeof PopoverMenu>;
80
-
81
- // Options inside menu
82
- optionsContainerClassName?: string;
83
- optionsContainerStyle?: CSSProperties;
84
-
85
- // Select button props
86
- selectButton?: SelectButtonProps;
87
- };
88
-
89
- export const CustomSelect = ({
90
- // Base props
91
- label,
92
- className,
93
- style,
94
- id,
95
- info,
96
- errors,
97
- layout = "default",
98
-
99
- // Select options and behavior
100
- options,
101
- value,
102
- defaultValue,
103
- onChange,
104
- multiple = false,
105
- searchable = false,
106
- allowCreate = false,
107
- showDisplayIcon = true,
108
- showDisplayText = true,
109
- disabled = false,
110
- readOnly = false,
111
- required = false,
112
- onClear,
113
- placeholder = "Select...",
114
- searchPlaceholder = "Search options...",
115
-
116
- // Popover behavior
117
- closeOnPathChange = true,
118
- closeOnClickOutside = true,
119
- animationDuration = 200,
120
- position = "bottom",
121
- align = "start",
122
-
123
- // Menu options props
124
- optionsContainerClassName,
125
- optionsContainerStyle,
126
-
127
- // Nested popover props
128
- popover,
129
- popoverTrigger,
130
- popoverMenu,
131
- selectButton,
132
- }: CustomSelectProps) => {
133
- const [searchTerm, setSearchTerm] = useState("");
134
- const [isOpen, setIsOpen] = useState(false);
135
-
136
- // Internal state for uncontrolled mode
137
- const [internalValue, setInternalValue] = useState<string | string[]>(() => {
138
- if (defaultValue !== undefined) {
139
- return Array.isArray(defaultValue)
140
- ? defaultValue
141
- : defaultValue
142
- ? [defaultValue]
143
- : [];
144
- }
145
- return multiple ? [] : "";
146
- });
147
-
148
- const generatedId = useId();
149
- const inputId = id ?? generatedId;
150
- const isControlled = value !== undefined;
151
- const actualValue = isControlled ? value : internalValue;
152
- const interactive = !disabled && !readOnly;
153
-
154
- // Normalize to array for consistent handling
155
- const selectedValues = useMemo(
156
- () =>
157
- Array.isArray(actualValue)
158
- ? actualValue
159
- : actualValue
160
- ? [actualValue]
161
- : [],
162
- [actualValue],
163
- );
164
-
165
- // A selected value may not exist in `options` - e.g. when `allowCreate` is
166
- // on and the consumer just stores the value without pushing it back into
167
- // the options list. Synthesize a "ghost" entry so the trigger displays it,
168
- // the menu shows it (multi-select can uncheck), and the "Create" check
169
- // doesn't redundantly offer something already selected.
170
- const optionsWithSelected = useMemo(() => {
171
- if (selectedValues.length === 0) return options;
172
- const known = new Set(options.map((option) => option.value));
173
- const ghosts = selectedValues
174
- .filter((value) => !known.has(value))
175
- .map<CustomSelectOption>((value) => ({ value, label: value }));
176
- return ghosts.length > 0 ? [...ghosts, ...options] : options;
177
- }, [options, selectedValues]);
178
-
179
- // Filter options based on search
180
- const filteredOptions = useMemo(
181
- () =>
182
- searchable && searchTerm
183
- ? optionsWithSelected.filter((option) =>
184
- option.label.toLowerCase().includes(searchTerm.toLowerCase()),
185
- )
186
- : optionsWithSelected,
187
- [optionsWithSelected, searchable, searchTerm],
188
- );
189
-
190
- // "Create '<term>'" appears when allowCreate is on and the typed term
191
- // doesn't match any existing option (by label or value, case-insensitive).
192
- const trimmedTerm = searchTerm.trim();
193
- const showCreateOption =
194
- allowCreate &&
195
- searchable &&
196
- trimmedTerm.length > 0 &&
197
- !optionsWithSelected.some(
198
- (option) =>
199
- option.label.toLowerCase() === trimmedTerm.toLowerCase() ||
200
- option.value.toLowerCase() === trimmedTerm.toLowerCase(),
201
- );
202
-
203
- // Get all selected option data in one place. `optionsWithSelected` is used
204
- // for the lookup so ghost entries (selected-but-not-in-options) resolve
205
- // back to a displayable label.
206
- const selectedOptionData = useMemo(() => {
207
- const hasValue = selectedValues.length > 0;
208
- const selectedOption = optionsWithSelected.find(
209
- (opt) => opt.value === selectedValues[0],
210
- );
211
- const isMultipleSelection = multiple && selectedValues.length > 1;
212
- const singleLabel = selectedOption?.label ?? selectedValues[0];
213
-
214
- return {
215
- hasValue,
216
- option: selectedOption,
217
- label: singleLabel,
218
- icon: selectedOption?.icon,
219
- displayText: !hasValue
220
- ? placeholder
221
- : isMultipleSelection
222
- ? `${selectedValues.length} selected`
223
- : singleLabel,
224
- shouldShowIcon:
225
- showDisplayIcon && selectedOption?.icon && selectedValues.length === 1,
226
- appliedClassName: selectedOption?.applyToSelector
227
- ? selectedOption.optionClassName
228
- : undefined,
229
- appliedStyle: selectedOption?.applyToSelector
230
- ? selectedOption.optionStyle
231
- : undefined,
232
- };
233
- }, [multiple, optionsWithSelected, placeholder, selectedValues, showDisplayIcon]);
234
-
235
- const popoverOnClose = popover?.onClose;
236
- const handlePopoverClose = useCallback(() => {
237
- setSearchTerm("");
238
- popoverOnClose?.();
239
- }, [popoverOnClose]);
240
-
241
- const {
242
- className: popoverClassName,
243
- offset: popoverOffset = 4,
244
- ...popoverRest
245
- } = popover ?? {};
246
-
247
- const {
248
- className: popoverTriggerClassName,
249
- style: popoverTriggerStyle,
250
- ...popoverTriggerRest
251
- } = popoverTrigger ?? {};
252
-
253
- const {
254
- className: popoverMenuClassName,
255
- style: popoverMenuStyle,
256
- ...popoverMenuRest
257
- } = popoverMenu ?? {};
258
-
259
- // Handle external value changes for controlled mode
260
- useEffect(() => {
261
- if (isControlled) {
262
- const normalized = Array.isArray(value) ? value : value ? [value] : [];
263
- const isDifferent =
264
- normalized.length !== selectedValues.length ||
265
- normalized.some((val, i) => val !== selectedValues[i]);
266
-
267
- if (isDifferent && !normalized.length) {
268
- onClear?.();
269
- }
270
- }
271
- }, [value, onClear, selectedValues, isControlled]);
272
-
273
- const handleSelect = (option: CustomSelectOption) => {
274
- if (!interactive || option.disabled) return;
275
-
276
- const optionValue = option.value;
277
-
278
- let newValues: string | string[];
279
-
280
- if (multiple) {
281
- newValues = selectedValues.includes(optionValue)
282
- ? selectedValues.filter((val) => val !== optionValue)
283
- : [...selectedValues, optionValue];
284
- } else {
285
- newValues = optionValue;
286
- setIsOpen(false);
287
- }
288
-
289
- if (!isControlled) {
290
- setInternalValue(newValues);
291
- }
292
-
293
- onChange?.(newValues);
294
- };
295
-
296
- const handleClear = (e: React.MouseEvent) => {
297
- e.stopPropagation();
298
- if (!interactive) return;
299
-
300
- const newValue = multiple ? [] : "";
301
-
302
- if (!isControlled) {
303
- setInternalValue(newValue);
304
- }
305
-
306
- onChange?.(newValue);
307
- onClear?.();
308
- };
309
-
310
- return (
311
- <div className={clsx(sCommon.container, className)} style={style}>
312
- {label && (
313
- <div
314
- className={clsx(
315
- sCommon.field_container,
316
- layout !== "default" && sCommon[layout],
317
- )}
318
- >
319
- <label className={sCommon.label} htmlFor={inputId}>
320
- {label}
321
- </label>
322
- </div>
323
- )}
324
-
325
- <Popover
326
- closeOnPathChange={closeOnPathChange}
327
- closeOnClickOutside={closeOnClickOutside}
328
- animationDuration={animationDuration}
329
- isOpen={isOpen}
330
- setIsOpen={setIsOpen}
331
- position={position}
332
- align={align}
333
- className={clsx(sPickerCommon.picker_popover, popoverClassName)}
334
- offset={popoverOffset}
335
- {...popoverRest}
336
- onClose={handlePopoverClose}
337
- >
338
- <PopoverTrigger
339
- asChild
340
- className={clsx(
341
- sPickerCommon.picker_popover_trigger,
342
- popoverTriggerClassName,
343
- )}
344
- style={popoverTriggerStyle}
345
- disabled={disabled || readOnly}
346
- {...popoverTriggerRest}
347
- >
348
- <SelectButton
349
- disabled={disabled || readOnly}
350
- required={required}
351
- showClear={selectedOptionData.hasValue && interactive}
352
- onClear={handleClear}
353
- id={inputId}
354
- className={clsx(
355
- !selectedOptionData.hasValue &&
356
- sPickerCommon.picker_value_placeholder,
357
- selectButton?.className,
358
- selectedOptionData.appliedClassName,
359
- )}
360
- style={{
361
- ...selectButton?.style,
362
- ...selectedOptionData.appliedStyle,
363
- }}
364
- iconStart={
365
- selectedOptionData.shouldShowIcon ? (
366
- <Icon>{selectedOptionData.icon}</Icon>
367
- ) : undefined
368
- }
369
- {...selectButton}
370
- >
371
- {showDisplayText && (
372
- <span className={clsx(s.option_label, sCommon.truncate_text)}>
373
- {selectedOptionData.displayText}
374
- </span>
375
- )}
376
- </SelectButton>
377
- </PopoverTrigger>
378
-
379
- {!disabled && !readOnly && (
380
- <PopoverMenu
381
- className={clsx(
382
- sPickerCommon.picker_popover_menu_base,
383
- s.popover_menu,
384
- popoverMenuClassName,
385
- )}
386
- style={popoverMenuStyle}
387
- {...popoverMenuRest}
388
- >
389
- {searchable && (
390
- <div className={s.search_container}>
391
- <Input
392
- type="search"
393
- placeholder={searchPlaceholder}
394
- value={searchTerm}
395
- onChange={(e) => setSearchTerm(e.target.value)}
396
- onClick={(e) => e.stopPropagation()}
397
- showClear={true}
398
- autoFocus
399
- />
400
- </div>
401
- )}
402
-
403
- <div
404
- className={clsx(s.options_container, optionsContainerClassName)}
405
- style={optionsContainerStyle}
406
- >
407
- {showCreateOption && (
408
- <Button
409
- as="div"
410
- variant="ghost"
411
- className={s.option}
412
- onClick={() => {
413
- handleSelect({ value: trimmedTerm, label: trimmedTerm });
414
- setSearchTerm("");
415
- }}
416
- >
417
- <Icon size="sm" aria-hidden="true">
418
- <IconPlus />
419
- </Icon>
420
- <span
421
- className={clsx(s.option_label, sCommon.truncate_text)}
422
- >
423
- Create &ldquo;{trimmedTerm}&rdquo;
424
- </span>
425
- </Button>
426
- )}
427
- {filteredOptions.length === 0 && !showCreateOption ? (
428
- <p className={s.no_options}>No options found</p>
429
- ) : (
430
- filteredOptions.map((option) => (
431
- <Button
432
- key={option.value}
433
- as="div"
434
- variant={option.optionButtonVariant || "ghost"}
435
- className={clsx(s.option, option.optionClassName)}
436
- style={option.optionStyle}
437
- isActive={selectedValues.includes(option.value)}
438
- disabled={disabled || readOnly || option.disabled}
439
- onClick={() => handleSelect(option)}
440
- >
441
- {multiple && (
442
- <Input
443
- type="checkbox"
444
- className={sCommon.option_checkbox}
445
- checked={selectedValues.includes(option.value)}
446
- disabled={disabled || readOnly || option.disabled}
447
- />
448
- )}
449
- {option.icon && option.icon}
450
- <span
451
- className={clsx(s.option_label, sCommon.truncate_text)}
452
- >
453
- {option.label}
454
- </span>
455
- {option.onDelete && interactive ? (
456
- <Icon
457
- size="sm"
458
- clickable
459
- className={s.option_delete}
460
- aria-label={`Delete ${option.label}`}
461
- onClick={(e) => {
462
- e.stopPropagation();
463
- // Close the popover BEFORE handing off - the consumer
464
- // opens a confirm dialog, which must not sit under
465
- // the still-open popover in the stacking order.
466
- setIsOpen(false);
467
- option.onDelete?.();
468
- }}
469
- >
470
- <IconTrash />
471
- </Icon>
472
- ) : null}
473
- </Button>
474
- ))
475
- )}
476
- </div>
477
- </PopoverMenu>
478
- )}
479
- </Popover>
480
-
481
- <FieldMessage errors={errors} info={info} />
482
- </div>
483
- );
484
- };
1
+ import type { Align, Position } from "../../../primitives/popover/popover";
2
+ import {
3
+ type CSSProperties,
4
+ type ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useState,
10
+ } from "react";
11
+ import clsx from "clsx";
12
+ import { IconPlus, IconTrash } from "@tabler/icons-react";
13
+ import { Input } from "../input/input";
14
+ import {
15
+ Popover,
16
+ PopoverTrigger,
17
+ PopoverMenu,
18
+ } from "../../../primitives/popover/popover";
19
+ import type { ComponentProps } from "react";
20
+ import sCommon from "../common/input-field-common.module.css";
21
+ import sPickerCommon from "../common/picker-popover-common.module.css";
22
+ import { Button, type ButtonVariant } from "../../button/button";
23
+ import s from "./custom-select.module.css";
24
+ import { FieldMessage } from "../common/field-message/field-message";
25
+ import { SelectButton, type SelectButtonProps } from "../common/select-button/select-button";
26
+ import { Icon } from "../../../primitives/icon/icon";
27
+ import type { BaseInputFieldProps } from "../common/types";
28
+
29
+ export type CustomSelectOption = {
30
+ value: string;
31
+ label: string;
32
+ icon?: ReactNode;
33
+ /** Trailing slot after the label and before any delete affordance. The
34
+ * component positions it; consumers own its visual styling. */
35
+ meta?: ReactNode;
36
+ optionStyle?: CSSProperties;
37
+ optionClassName?: string;
38
+ optionButtonVariant?: ButtonVariant;
39
+ applyToSelector?: boolean;
40
+ disabled?: boolean;
41
+ /** Renders a trailing delete affordance on the option row. Clicking it does
42
+ * NOT select the option - the consumer typically opens a confirm dialog and
43
+ * performs the deletion (e.g. removing a folder/tag from its pool). */
44
+ onDelete?: () => void;
45
+ };
46
+
47
+ export type CustomSelectProps = Omit<
48
+ BaseInputFieldProps,
49
+ // its used in selectButton so no need it here
50
+ "buttonStyle"
51
+ > & {
52
+ // Select specific props
53
+ placeholder?: string;
54
+ searchPlaceholder?: string;
55
+ options: CustomSelectOption[];
56
+ value?: string | string[];
57
+ defaultValue?: string | string[];
58
+ onChange?: (value: string | string[]) => void;
59
+ multiple?: boolean;
60
+ searchable?: boolean;
61
+ /**
62
+ * Lets the user create a new option from the search term when nothing in
63
+ * `options` matches it. Requires `searchable`. The created value is emitted
64
+ * through `onChange` (and added to `selectedValues`); the consumer is
65
+ * responsible for persisting it / pushing it into the next `options` list.
66
+ */
67
+ allowCreate?: boolean;
68
+ showDisplayText?: boolean;
69
+ showDisplayIcon?: boolean;
70
+ onClear?: () => void;
71
+
72
+ // Popover behavior defaults
73
+ closeOnPathChange?: boolean;
74
+ closeOnClickOutside?: boolean;
75
+ animationDuration?: number;
76
+ position?: Position;
77
+ align?: Align;
78
+ autoPosition?: boolean;
79
+ collisionPadding?: number;
80
+
81
+ // Nested popover props
82
+ popover?: Omit<ComponentProps<typeof Popover>, "children">;
83
+ popoverTrigger?: ComponentProps<typeof PopoverTrigger>;
84
+ popoverMenu?: ComponentProps<typeof PopoverMenu>;
85
+
86
+ // Options inside menu
87
+ optionsContainerClassName?: string;
88
+ optionsContainerStyle?: CSSProperties;
89
+
90
+ // Select button props
91
+ selectButton?: SelectButtonProps;
92
+ };
93
+
94
+ export const CustomSelect = ({
95
+ // Base props
96
+ label,
97
+ className,
98
+ style,
99
+ id,
100
+ info,
101
+ errors,
102
+ layout = "default",
103
+
104
+ // Select options and behavior
105
+ options,
106
+ value,
107
+ defaultValue,
108
+ onChange,
109
+ multiple = false,
110
+ searchable = false,
111
+ allowCreate = false,
112
+ showDisplayIcon = true,
113
+ showDisplayText = true,
114
+ disabled = false,
115
+ readOnly = false,
116
+ required = false,
117
+ onClear,
118
+ placeholder = "Select...",
119
+ searchPlaceholder = "Search options...",
120
+
121
+ // Popover behavior
122
+ closeOnPathChange = true,
123
+ closeOnClickOutside = true,
124
+ animationDuration = 200,
125
+ position = "bottom",
126
+ align = "start",
127
+ autoPosition,
128
+ collisionPadding,
129
+
130
+ // Menu options props
131
+ optionsContainerClassName,
132
+ optionsContainerStyle,
133
+
134
+ // Nested popover props
135
+ popover,
136
+ popoverTrigger,
137
+ popoverMenu,
138
+ selectButton,
139
+ }: CustomSelectProps) => {
140
+ const [searchTerm, setSearchTerm] = useState("");
141
+ const [isOpen, setIsOpen] = useState(false);
142
+
143
+ // Internal state for uncontrolled mode
144
+ const [internalValue, setInternalValue] = useState<string | string[]>(() => {
145
+ if (defaultValue !== undefined) {
146
+ return Array.isArray(defaultValue)
147
+ ? defaultValue
148
+ : defaultValue
149
+ ? [defaultValue]
150
+ : [];
151
+ }
152
+ return multiple ? [] : "";
153
+ });
154
+
155
+ const generatedId = useId();
156
+ const inputId = id ?? generatedId;
157
+ const isControlled = value !== undefined;
158
+ const actualValue = isControlled ? value : internalValue;
159
+ const interactive = !disabled && !readOnly;
160
+
161
+ // Normalize to array for consistent handling
162
+ const selectedValues = useMemo(
163
+ () =>
164
+ Array.isArray(actualValue)
165
+ ? actualValue
166
+ : actualValue
167
+ ? [actualValue]
168
+ : [],
169
+ [actualValue],
170
+ );
171
+
172
+ // A selected value may not exist in `options` - e.g. when `allowCreate` is
173
+ // on and the consumer just stores the value without pushing it back into
174
+ // the options list. Synthesize a "ghost" entry so the trigger displays it,
175
+ // the menu shows it (multi-select can uncheck), and the "Create" check
176
+ // doesn't redundantly offer something already selected.
177
+ const optionsWithSelected = useMemo(() => {
178
+ if (selectedValues.length === 0) return options;
179
+ const known = new Set(options.map((option) => option.value));
180
+ const ghosts = selectedValues
181
+ .filter((value) => !known.has(value))
182
+ .map<CustomSelectOption>((value) => ({ value, label: value }));
183
+ return ghosts.length > 0 ? [...ghosts, ...options] : options;
184
+ }, [options, selectedValues]);
185
+
186
+ // Filter options based on search
187
+ const filteredOptions = useMemo(
188
+ () =>
189
+ searchable && searchTerm
190
+ ? optionsWithSelected.filter((option) =>
191
+ option.label.toLowerCase().includes(searchTerm.toLowerCase()),
192
+ )
193
+ : optionsWithSelected,
194
+ [optionsWithSelected, searchable, searchTerm],
195
+ );
196
+
197
+ // "Create '<term>'" appears when allowCreate is on and the typed term
198
+ // doesn't match any existing option (by label or value, case-insensitive).
199
+ const trimmedTerm = searchTerm.trim();
200
+ const showCreateOption =
201
+ allowCreate &&
202
+ searchable &&
203
+ trimmedTerm.length > 0 &&
204
+ !optionsWithSelected.some(
205
+ (option) =>
206
+ option.label.toLowerCase() === trimmedTerm.toLowerCase() ||
207
+ option.value.toLowerCase() === trimmedTerm.toLowerCase(),
208
+ );
209
+
210
+ // Get all selected option data in one place. `optionsWithSelected` is used
211
+ // for the lookup so ghost entries (selected-but-not-in-options) resolve
212
+ // back to a displayable label.
213
+ const selectedOptionData = useMemo(() => {
214
+ const hasValue = selectedValues.length > 0;
215
+ const selectedOption = optionsWithSelected.find(
216
+ (opt) => opt.value === selectedValues[0],
217
+ );
218
+ const isMultipleSelection = multiple && selectedValues.length > 1;
219
+ const singleLabel = selectedOption?.label ?? selectedValues[0];
220
+
221
+ return {
222
+ hasValue,
223
+ option: selectedOption,
224
+ label: singleLabel,
225
+ icon: selectedOption?.icon,
226
+ displayText: !hasValue
227
+ ? placeholder
228
+ : isMultipleSelection
229
+ ? `${selectedValues.length} selected`
230
+ : singleLabel,
231
+ shouldShowIcon:
232
+ showDisplayIcon && selectedOption?.icon && selectedValues.length === 1,
233
+ appliedClassName: selectedOption?.applyToSelector
234
+ ? selectedOption.optionClassName
235
+ : undefined,
236
+ appliedStyle: selectedOption?.applyToSelector
237
+ ? selectedOption.optionStyle
238
+ : undefined,
239
+ };
240
+ }, [multiple, optionsWithSelected, placeholder, selectedValues, showDisplayIcon]);
241
+
242
+ const popoverOnClose = popover?.onClose;
243
+ const handlePopoverClose = useCallback(() => {
244
+ setSearchTerm("");
245
+ popoverOnClose?.();
246
+ }, [popoverOnClose]);
247
+
248
+ const {
249
+ className: popoverClassName,
250
+ offset: popoverOffset = 4,
251
+ autoPosition: popoverAutoPosition,
252
+ collisionPadding: popoverCollisionPadding,
253
+ ...popoverRest
254
+ } = popover ?? {};
255
+ const resolvedAutoPosition = popoverAutoPosition ?? autoPosition;
256
+ const resolvedCollisionPadding = popoverCollisionPadding ?? collisionPadding;
257
+
258
+ const {
259
+ className: popoverTriggerClassName,
260
+ style: popoverTriggerStyle,
261
+ ...popoverTriggerRest
262
+ } = popoverTrigger ?? {};
263
+
264
+ const {
265
+ className: popoverMenuClassName,
266
+ style: popoverMenuStyle,
267
+ ...popoverMenuRest
268
+ } = popoverMenu ?? {};
269
+
270
+ // Handle external value changes for controlled mode
271
+ useEffect(() => {
272
+ if (isControlled) {
273
+ const normalized = Array.isArray(value) ? value : value ? [value] : [];
274
+ const isDifferent =
275
+ normalized.length !== selectedValues.length ||
276
+ normalized.some((val, i) => val !== selectedValues[i]);
277
+
278
+ if (isDifferent && !normalized.length) {
279
+ onClear?.();
280
+ }
281
+ }
282
+ }, [value, onClear, selectedValues, isControlled]);
283
+
284
+ const handleSelect = (option: CustomSelectOption) => {
285
+ if (!interactive || option.disabled) return;
286
+
287
+ const optionValue = option.value;
288
+
289
+ let newValues: string | string[];
290
+
291
+ if (multiple) {
292
+ newValues = selectedValues.includes(optionValue)
293
+ ? selectedValues.filter((val) => val !== optionValue)
294
+ : [...selectedValues, optionValue];
295
+ } else {
296
+ newValues = optionValue;
297
+ setIsOpen(false);
298
+ }
299
+
300
+ if (!isControlled) {
301
+ setInternalValue(newValues);
302
+ }
303
+
304
+ onChange?.(newValues);
305
+ };
306
+
307
+ const handleClear = (e: React.MouseEvent) => {
308
+ e.stopPropagation();
309
+ if (!interactive) return;
310
+
311
+ const newValue = multiple ? [] : "";
312
+
313
+ if (!isControlled) {
314
+ setInternalValue(newValue);
315
+ }
316
+
317
+ onChange?.(newValue);
318
+ onClear?.();
319
+ };
320
+
321
+ return (
322
+ <div className={clsx(sCommon.container, className)} style={style}>
323
+ {label && (
324
+ <div
325
+ className={clsx(
326
+ sCommon.field_container,
327
+ layout !== "default" && sCommon[layout],
328
+ )}
329
+ >
330
+ <label className={sCommon.label} htmlFor={inputId}>
331
+ {label}
332
+ </label>
333
+ </div>
334
+ )}
335
+
336
+ <Popover
337
+ closeOnPathChange={closeOnPathChange}
338
+ closeOnClickOutside={closeOnClickOutside}
339
+ animationDuration={animationDuration}
340
+ isOpen={isOpen}
341
+ setIsOpen={setIsOpen}
342
+ position={position}
343
+ align={align}
344
+ autoPosition={resolvedAutoPosition}
345
+ collisionPadding={resolvedCollisionPadding}
346
+ className={clsx(sPickerCommon.picker_popover, popoverClassName)}
347
+ offset={popoverOffset}
348
+ {...popoverRest}
349
+ onClose={handlePopoverClose}
350
+ >
351
+ <PopoverTrigger
352
+ asChild
353
+ className={clsx(
354
+ sPickerCommon.picker_popover_trigger,
355
+ popoverTriggerClassName,
356
+ )}
357
+ style={popoverTriggerStyle}
358
+ disabled={disabled || readOnly}
359
+ {...popoverTriggerRest}
360
+ >
361
+ <SelectButton
362
+ disabled={disabled || readOnly}
363
+ required={required}
364
+ showClear={selectedOptionData.hasValue && interactive}
365
+ onClear={handleClear}
366
+ id={inputId}
367
+ className={clsx(
368
+ !selectedOptionData.hasValue &&
369
+ sPickerCommon.picker_value_placeholder,
370
+ selectButton?.className,
371
+ selectedOptionData.appliedClassName,
372
+ )}
373
+ style={{
374
+ ...selectButton?.style,
375
+ ...selectedOptionData.appliedStyle,
376
+ }}
377
+ iconStart={
378
+ selectedOptionData.shouldShowIcon ? (
379
+ <Icon>{selectedOptionData.icon}</Icon>
380
+ ) : undefined
381
+ }
382
+ {...selectButton}
383
+ >
384
+ {showDisplayText && (
385
+ <span className={clsx(s.option_label, sCommon.truncate_text)}>
386
+ {selectedOptionData.displayText}
387
+ </span>
388
+ )}
389
+ </SelectButton>
390
+ </PopoverTrigger>
391
+
392
+ {!disabled && !readOnly && (
393
+ <PopoverMenu
394
+ className={clsx(
395
+ sPickerCommon.picker_popover_menu_base,
396
+ s.popover_menu,
397
+ popoverMenuClassName,
398
+ )}
399
+ style={popoverMenuStyle}
400
+ {...popoverMenuRest}
401
+ >
402
+ {searchable && (
403
+ <div className={s.search_container}>
404
+ <Input
405
+ type="search"
406
+ placeholder={searchPlaceholder}
407
+ value={searchTerm}
408
+ onChange={(e) => setSearchTerm(e.target.value)}
409
+ onClick={(e) => e.stopPropagation()}
410
+ showClear={true}
411
+ autoFocus
412
+ />
413
+ </div>
414
+ )}
415
+
416
+ <div
417
+ className={clsx(s.options_container, optionsContainerClassName)}
418
+ style={optionsContainerStyle}
419
+ >
420
+ {showCreateOption && (
421
+ <Button
422
+ as="div"
423
+ variant="ghost"
424
+ className={s.option}
425
+ onClick={() => {
426
+ handleSelect({ value: trimmedTerm, label: trimmedTerm });
427
+ setSearchTerm("");
428
+ }}
429
+ >
430
+ <Icon size="sm" aria-hidden="true">
431
+ <IconPlus />
432
+ </Icon>
433
+ <span
434
+ className={clsx(s.option_label, sCommon.truncate_text)}
435
+ >
436
+ Create &ldquo;{trimmedTerm}&rdquo;
437
+ </span>
438
+ </Button>
439
+ )}
440
+ {filteredOptions.length === 0 && !showCreateOption ? (
441
+ <p className={s.no_options}>No options found</p>
442
+ ) : (
443
+ filteredOptions.map((option) => (
444
+ <Button
445
+ key={option.value}
446
+ as="div"
447
+ variant={option.optionButtonVariant || "ghost"}
448
+ className={clsx(s.option, option.optionClassName)}
449
+ style={option.optionStyle}
450
+ isActive={selectedValues.includes(option.value)}
451
+ disabled={disabled || readOnly || option.disabled}
452
+ onClick={() => handleSelect(option)}
453
+ >
454
+ {multiple && (
455
+ <Input
456
+ type="checkbox"
457
+ className={sCommon.option_checkbox}
458
+ checked={selectedValues.includes(option.value)}
459
+ disabled={disabled || readOnly || option.disabled}
460
+ />
461
+ )}
462
+ {option.icon && option.icon}
463
+ <span
464
+ className={clsx(s.option_label, sCommon.truncate_text)}
465
+ >
466
+ {option.label}
467
+ </span>
468
+ {option.meta != null ? (
469
+ <span className={s.option_meta}>{option.meta}</span>
470
+ ) : null}
471
+ {option.onDelete && interactive ? (
472
+ <Icon
473
+ size="sm"
474
+ clickable
475
+ className={s.option_delete}
476
+ aria-label={`Delete ${option.label}`}
477
+ onClick={(e) => {
478
+ e.stopPropagation();
479
+ // Close the popover BEFORE handing off - the consumer
480
+ // opens a confirm dialog, which must not sit under
481
+ // the still-open popover in the stacking order.
482
+ setIsOpen(false);
483
+ option.onDelete?.();
484
+ }}
485
+ >
486
+ <IconTrash />
487
+ </Icon>
488
+ ) : null}
489
+ </Button>
490
+ ))
491
+ )}
492
+ </div>
493
+ </PopoverMenu>
494
+ )}
495
+ </Popover>
496
+
497
+ <FieldMessage errors={errors} info={info} />
498
+ </div>
499
+ );
500
+ };