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