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