@rovula/ui 0.1.21 → 0.1.23

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 (85) hide show
  1. package/dist/cjs/bundle.css +204 -26
  2. package/dist/cjs/bundle.js +675 -675
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/Badge/Badge.d.ts +40 -0
  5. package/dist/cjs/types/components/Badge/Badge.stories.d.ts +295 -0
  6. package/dist/cjs/types/components/Badge/Badge.styles.d.ts +7 -0
  7. package/dist/cjs/types/components/Badge/index.d.ts +2 -0
  8. package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +4 -8
  9. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +1 -6
  10. package/dist/cjs/types/components/DropdownMenu/DropdownMenu.d.ts +5 -1
  11. package/dist/cjs/types/components/DropdownMenu/DropdownMenu.stories.d.ts +16 -0
  12. package/dist/cjs/types/index.d.ts +3 -1
  13. package/dist/cjs/types/patterns/menu/Menu.d.ts +72 -0
  14. package/dist/cjs/types/{components/Menu → patterns/menu}/Menu.stories.d.ts +18 -10
  15. package/dist/cjs/types/utils/mergeRefs.d.ts +20 -0
  16. package/dist/components/ActionButton/ActionButton.styles.js +9 -1
  17. package/dist/components/Avatar/Avatar.styles.js +2 -2
  18. package/dist/components/Badge/Badge.js +36 -0
  19. package/dist/components/Badge/Badge.stories.js +51 -0
  20. package/dist/components/Badge/Badge.styles.js +62 -0
  21. package/dist/components/Badge/index.js +2 -0
  22. package/dist/components/Dropdown/Dropdown.js +54 -163
  23. package/dist/components/Dropdown/Dropdown.stories.js +29 -0
  24. package/dist/components/DropdownMenu/DropdownMenu.js +24 -11
  25. package/dist/components/DropdownMenu/DropdownMenu.stories.js +54 -10
  26. package/dist/components/TextInput/TextInput.js +9 -4
  27. package/dist/esm/bundle.css +204 -26
  28. package/dist/esm/bundle.js +1545 -1545
  29. package/dist/esm/bundle.js.map +1 -1
  30. package/dist/esm/types/components/Badge/Badge.d.ts +40 -0
  31. package/dist/esm/types/components/Badge/Badge.stories.d.ts +295 -0
  32. package/dist/esm/types/components/Badge/Badge.styles.d.ts +7 -0
  33. package/dist/esm/types/components/Badge/index.d.ts +2 -0
  34. package/dist/esm/types/components/Dropdown/Dropdown.d.ts +4 -8
  35. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +1 -6
  36. package/dist/esm/types/components/DropdownMenu/DropdownMenu.d.ts +5 -1
  37. package/dist/esm/types/components/DropdownMenu/DropdownMenu.stories.d.ts +16 -0
  38. package/dist/esm/types/index.d.ts +3 -1
  39. package/dist/esm/types/patterns/menu/Menu.d.ts +72 -0
  40. package/dist/esm/types/{components/Menu → patterns/menu}/Menu.stories.d.ts +18 -10
  41. package/dist/esm/types/utils/mergeRefs.d.ts +20 -0
  42. package/dist/index.d.ts +118 -73
  43. package/dist/index.js +2 -1
  44. package/dist/patterns/menu/Menu.js +95 -0
  45. package/dist/patterns/menu/Menu.stories.js +611 -0
  46. package/dist/src/theme/global.css +393 -43
  47. package/dist/utils/mergeRefs.js +42 -0
  48. package/package.json +1 -1
  49. package/src/components/ActionButton/ActionButton.styles.ts +9 -1
  50. package/src/components/Avatar/Avatar.styles.ts +2 -2
  51. package/src/components/Badge/Badge.stories.tsx +128 -0
  52. package/src/components/Badge/Badge.styles.ts +70 -0
  53. package/src/components/Badge/Badge.tsx +103 -0
  54. package/src/components/Badge/index.ts +3 -0
  55. package/src/components/Dropdown/Dropdown.stories.tsx +170 -1
  56. package/src/components/Dropdown/Dropdown.tsx +186 -276
  57. package/src/components/DropdownMenu/DropdownMenu.stories.tsx +1050 -113
  58. package/src/components/DropdownMenu/DropdownMenu.tsx +117 -56
  59. package/src/components/TextInput/TextInput.tsx +42 -32
  60. package/src/index.ts +3 -1
  61. package/src/patterns/menu/Menu.stories.tsx +1100 -0
  62. package/src/patterns/menu/Menu.tsx +286 -0
  63. package/src/theme/presets/colors.js +14 -0
  64. package/src/theme/themes/variable-mapping.css +30 -0
  65. package/src/theme/themes/variable.css +37 -6
  66. package/src/theme/themes/xspector/baseline.css +0 -1
  67. package/src/theme/tokens/baseline.css +2 -1
  68. package/src/theme/tokens/components/badge.css +54 -0
  69. package/src/theme/tokens/components/dropdown-menu.css +15 -4
  70. package/src/utils/mergeRefs.ts +46 -0
  71. package/dist/cjs/types/components/Menu/Menu.d.ts +0 -65
  72. package/dist/cjs/types/components/Menu/helpers.d.ts +0 -19
  73. package/dist/cjs/types/components/Menu/index.d.ts +0 -4
  74. package/dist/components/Menu/Menu.js +0 -64
  75. package/dist/components/Menu/Menu.stories.js +0 -406
  76. package/dist/components/Menu/helpers.js +0 -28
  77. package/dist/components/Menu/index.js +0 -3
  78. package/dist/esm/types/components/Menu/Menu.d.ts +0 -65
  79. package/dist/esm/types/components/Menu/helpers.d.ts +0 -19
  80. package/dist/esm/types/components/Menu/index.d.ts +0 -4
  81. package/src/components/Menu/Menu.stories.tsx +0 -586
  82. package/src/components/Menu/Menu.tsx +0 -235
  83. package/src/components/Menu/helpers.ts +0 -45
  84. package/src/components/Menu/index.ts +0 -7
  85. package/src/theme/themes/xspector/components/dropdown-menu.css +0 -28
@@ -1,5 +1,6 @@
1
+ "use client";
2
+
1
3
  import React, {
2
- CSSProperties,
3
4
  ReactNode,
4
5
  forwardRef,
5
6
  useCallback,
@@ -10,13 +11,31 @@ import React, {
10
11
  useState,
11
12
  Fragment,
12
13
  } from "react";
13
- import * as Portal from "@radix-ui/react-portal";
14
+ import {
15
+ Combobox,
16
+ ComboboxInput,
17
+ ComboboxOptions,
18
+ ComboboxOption,
19
+ } from "@headlessui/react";
14
20
  import TextInput, { InputProps } from "../TextInput/TextInput";
15
21
  import { customInputVariant, dropdownIconVariant } from "./Dropdown.styles";
16
- import { Menu, MenuItemType, MenuOption } from "../Menu/Menu";
17
-
18
- import { ChevronDownIcon } from "@heroicons/react/16/solid";
19
22
  import { cn } from "@/utils/cn";
23
+ import Icon from "../Icon/Icon";
24
+ import { ChevronDownIcon } from "@heroicons/react/16/solid";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Shared menu item styles (used by Dropdown items + renderOptions consumers)
28
+ // ---------------------------------------------------------------------------
29
+ export const menuItemBaseStyles = cn(
30
+ "relative flex gap-1 cursor-pointer select-none box-border items-center py-4 pl-4 pr-8 typography-subtitle4 outline-none transition-colors",
31
+ "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
32
+ "active:opacity-75",
33
+ "hover:bg-[var(--dropdown-menu-hover-bg)] hover:text-[var(--dropdown-menu-hover-text)]",
34
+ );
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Types
38
+ // ---------------------------------------------------------------------------
20
39
 
21
40
  type RenderLabelCallbackArg = {
22
41
  value: string;
@@ -37,7 +56,6 @@ export type DropdownProps = {
37
56
  size?: "sm" | "md" | "lg";
38
57
  rounded?: "none" | "normal" | "full";
39
58
  variant?: "flat" | "outline" | "underline";
40
- defaultMenuItemType?: MenuOption["type"];
41
59
  helperText?: string;
42
60
  errorMessage?: string;
43
61
  filterMode?: boolean;
@@ -45,6 +63,7 @@ export type DropdownProps = {
45
63
  disabled?: boolean;
46
64
  error?: boolean;
47
65
  required?: boolean;
66
+ /** @deprecated no longer needed */
48
67
  modal?: boolean;
49
68
  className?: string;
50
69
  optionContainerClassName?: string;
@@ -59,11 +78,13 @@ export type DropdownProps = {
59
78
  optionsFiltered: Options[];
60
79
  selectedOption: Options | null | undefined;
61
80
  onClick: (option: Options) => void;
62
- style?: CSSProperties;
63
- dropdownRef?: React.RefObject<HTMLUListElement>;
64
81
  }) => ReactNode;
65
82
  } & Omit<InputProps, "value" | "onSelect">;
66
83
 
84
+ // ---------------------------------------------------------------------------
85
+ // Dropdown
86
+ // ---------------------------------------------------------------------------
87
+
67
88
  const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
68
89
  (
69
90
  {
@@ -74,7 +95,6 @@ const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
74
95
  size = "md",
75
96
  rounded = "normal",
76
97
  variant = "outline",
77
- defaultMenuItemType = "checkbox",
78
98
  helperText,
79
99
  errorMessage,
80
100
  fullwidth = true,
@@ -82,7 +102,7 @@ const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
82
102
  error = false,
83
103
  filterMode = false,
84
104
  required = true,
85
- modal = false,
105
+ modal: _modal,
86
106
  onChangeText,
87
107
  onSelect,
88
108
  renderOptions: customRenderOptions,
@@ -96,305 +116,195 @@ const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
96
116
  ) => {
97
117
  const _id = id || `${label}-select`;
98
118
 
99
- const [isFocused, setIsFocused] = useState(false);
100
- const [selectedOption, setSelectedOption] = useState<
101
- Options | null | undefined
102
- >(null);
103
- const [textValue, setTextValue] = useState("");
104
- const keyCode = useRef("");
119
+ const [selectedOption, setSelectedOption] = useState<Options | null>(null);
120
+ const [query, setQuery] = useState("");
105
121
 
106
- const dropdownRef = useRef<HTMLUListElement>(null);
107
122
  const inputRef = useRef<HTMLInputElement>(null);
108
- const [dropdownStyles, setDropdownStyles] = useState<CSSProperties>({});
109
- const [isAbove, setIsAbove] = useState(false);
110
- const [isInsideDialog, setIsInsideDialog] = useState(false);
111
123
 
112
- useImperativeHandle(ref, () => inputRef?.current as HTMLInputElement);
124
+ // Expose the inner <input> element via forwardRef
125
+ useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
113
126
 
127
+ // Sync external value prop
114
128
  useEffect(() => {
115
- setSelectedOption(value);
116
- setTextValue(value?.label ?? "");
129
+ setSelectedOption(value ?? null);
117
130
  }, [value]);
118
131
 
119
- /** Auto-detect if inside a Dialog */
120
- useEffect(() => {
121
- let node: HTMLElement | null = inputRef.current;
122
- while (node) {
123
- if (node.getAttribute("role") === "dialog") {
124
- setIsInsideDialog(true);
125
- return;
126
- }
127
- node = node.parentElement;
128
- }
129
- setIsInsideDialog(false);
130
- }, []);
131
-
132
- const handleOnChangeText = useCallback(
133
- (event: React.ChangeEvent<HTMLInputElement>) => {
134
- onChangeText?.(event);
135
- setTextValue(event.target.value);
132
+ const optionsFiltered = useMemo(() => {
133
+ if (!filterMode || !query) return options;
134
+ return options.filter((opt) =>
135
+ opt.label.toLowerCase().includes(query.toLowerCase()),
136
+ );
137
+ }, [options, filterMode, query]);
136
138
 
137
- if (!event.target.value) {
138
- clearMismatchValue(event as any);
139
+ const handleSelect = useCallback(
140
+ (option: Options | null) => {
141
+ setSelectedOption(option);
142
+ setQuery("");
143
+ if (option) {
144
+ onSelect?.(option);
145
+ // After selection Headless UI keeps focus on the input (via rAF refocus).
146
+ // Blur after that rAF so the next click triggers onFocus → immediate open.
147
+ setTimeout(() => inputRef.current?.blur(), 0);
139
148
  }
140
149
  },
141
- [onChangeText],
150
+ [onSelect],
142
151
  );
143
152
 
144
- const handleOptionClick = useCallback(
145
- (option: Options) => {
146
- setSelectedOption(option);
147
- setTextValue(option.label);
148
- onSelect?.(option);
149
- setIsFocused(false);
153
+ const handleInputChange = useCallback(
154
+ (e: React.ChangeEvent<HTMLInputElement>) => {
155
+ if (filterMode) setQuery(e.target.value);
156
+ onChangeText?.(e);
150
157
  },
151
- [onSelect],
158
+ [filterMode, onChangeText],
152
159
  );
153
160
 
154
- const optionsFiltered = useMemo(() => {
155
- return options.filter(
156
- (option) =>
157
- !filterMode ||
158
- option.label?.toLowerCase().includes(textValue?.toLowerCase()),
159
- );
160
- }, [options, filterMode, textValue]);
161
-
162
- const usePortal = isInsideDialog ? false : modal;
163
-
164
- const updateDropdownPosition = useCallback(() => {
165
- if (inputRef.current && dropdownRef.current) {
166
- const rect = inputRef.current.getBoundingClientRect();
167
- const dropdownHeight = dropdownRef.current.offsetHeight;
168
- const spaceBelow = window.innerHeight - rect.bottom;
169
- const spaceAbove = rect.top;
170
-
171
- const shouldOpenAbove =
172
- spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
173
- setIsAbove(shouldOpenAbove);
174
-
175
- if (usePortal) {
176
- setDropdownStyles({
177
- position: "absolute",
178
- top: shouldOpenAbove
179
- ? `${rect.top - dropdownHeight}px`
180
- : `${rect.bottom}px`,
181
- left: `${rect.left}px`,
182
- width: `${rect.width}px`,
183
- zIndex: 9999,
184
- });
185
- } else {
186
- setDropdownStyles({
187
- position: "absolute",
188
- top: shouldOpenAbove ? `-${dropdownHeight}px` : "100%",
189
- left: "0",
190
- width: "100%",
191
- zIndex: 9999,
192
- });
193
- }
194
- }
195
- }, [modal, isInsideDialog, usePortal]);
196
-
197
- useEffect(() => {
198
- if (isFocused) {
199
- updateDropdownPosition();
200
- window.addEventListener("resize", updateDropdownPosition);
201
- }
202
-
203
- return () => {
204
- window.removeEventListener("resize", updateDropdownPosition);
205
- };
206
- }, [isFocused, updateDropdownPosition]);
207
-
208
- const renderOptions = () => {
161
+ const renderOptionList = () => {
209
162
  if (customRenderOptions) {
210
163
  return customRenderOptions({
211
164
  optionsFiltered,
212
165
  selectedOption,
213
- onClick: handleOptionClick,
214
- style: dropdownStyles,
215
- dropdownRef,
166
+ onClick: handleSelect,
216
167
  });
217
168
  }
218
169
 
219
- // Convert options to MenuItemType
220
- let finalMenuItems: MenuItemType[];
170
+ if (optionsFiltered.length === 0) {
171
+ return (
172
+ <div
173
+ className={cn(
174
+ "px-4 py-14 text-center text-input-text",
175
+ optionNotFoundItemClassName,
176
+ )}
177
+ >
178
+ Not found
179
+ </div>
180
+ );
181
+ }
221
182
 
222
- finalMenuItems = optionsFiltered.map((option) => {
183
+ return optionsFiltered.map((option) => {
223
184
  if (option.renderLabel) {
224
- return {
225
- type: "custom",
226
- render: () => (
227
- <Fragment key={option.value}>
228
- {option.renderLabel!({
229
- value: option.value,
230
- label: option.label,
231
- handleOnClick: () => handleOptionClick(option),
232
- className: cn(
233
- "relative flex gap-3 cursor-pointer select-none box-border items-center py-4 pl-9 pr-4 typography-subtitle4 outline-none transition-colors",
234
- "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
235
- "active:opacity-75",
236
- "hover:bg-[var(--dropdown-menu-hover-bg)] hover:text-[var(--dropdown-menu-hover-text)]",
237
- {
238
- "bg-[var(--dropdown-menu-selected-bg)] text-[var(--dropdown-menu-selected-text)] typography-subtitle5":
239
- selectedOption?.value === option.value,
240
- },
241
- optionItemClassName,
242
- ),
243
- })}
244
- </Fragment>
245
- ),
246
- };
185
+ return (
186
+ <Fragment key={option.value}>
187
+ {option.renderLabel({
188
+ value: option.value,
189
+ label: option.label,
190
+ handleOnClick: () => handleSelect(option),
191
+ className: cn(
192
+ menuItemBaseStyles,
193
+ selectedOption?.value === option.value &&
194
+ "bg-[var(--dropdown-menu-selected-bg)] text-[var(--dropdown-menu-selected-text)]",
195
+ optionItemClassName,
196
+ ),
197
+ })}
198
+ </Fragment>
199
+ );
247
200
  }
248
201
 
249
- return {
250
- type: "item",
251
- item: {
252
- type: defaultMenuItemType,
253
- value: option.value,
254
- label: option.label,
255
- },
256
- };
257
- });
258
-
259
- // Add "not found" message if no results
260
- if (finalMenuItems.length === 0) {
261
- finalMenuItems.push({
262
- type: "custom",
263
- render: () => (
264
- <div
265
- key="not-found"
266
- className={cn(
267
- "px-4 py-14 text-center text-input-text",
268
- optionNotFoundItemClassName,
269
- )}
270
- >
271
- Not found
272
- </div>
273
- ),
274
- });
275
- }
276
-
277
- return (
278
- <Menu
279
- ref={dropdownRef as any}
280
- items={finalMenuItems}
281
- selectedValues={selectedOption?.value ? [selectedOption.value] : []}
282
- onSelect={(value) => {
283
- const option = optionsFiltered.find((opt) => opt.value === value);
284
- if (option) {
285
- handleOptionClick(option);
202
+ return (
203
+ <ComboboxOption
204
+ key={option.value}
205
+ value={option}
206
+ className={({ focus, selected }) =>
207
+ cn(
208
+ menuItemBaseStyles,
209
+ (selected || selectedOption?.value === option.value) &&
210
+ "bg-[var(--dropdown-menu-selected-bg)] text-[var(--dropdown-menu-selected-text)]",
211
+ focus &&
212
+ "bg-[var(--dropdown-menu-hover-bg)] text-[var(--dropdown-menu-hover-text)]",
213
+ optionItemClassName,
214
+ )
286
215
  }
287
- }}
288
- className={cn(
289
- "absolute mt-1 w-full max-h-60 overflow-y-auto",
290
- !usePortal && (isAbove ? "bottom-full mb-1" : "top-full mt-1"),
291
- optionContainerClassName,
292
- )}
293
- style={dropdownStyles}
294
- />
295
- );
296
- };
297
-
298
- const handleOnFocus = useCallback(
299
- (e: React.FocusEvent<HTMLInputElement, Element>) => {
300
- setIsFocused(true);
301
- props?.onFocus?.(e);
302
- },
303
- [props?.onFocus],
304
- );
305
-
306
- const clearMismatchValue = useCallback(
307
- (e: React.FocusEvent<HTMLInputElement, Element>) => {
308
- const matchSelectedValue = optionsFiltered.find(
309
- (opt) =>
310
- opt.value === e.target?.value || opt.label === e.target?.value,
216
+ >
217
+ {({ selected }) => (
218
+ <>
219
+ <span className="shrink-0 size-4 flex items-center justify-center">
220
+ {(selected || selectedOption?.value === option.value) && (
221
+ <Icon
222
+ type="heroicons"
223
+ name="check"
224
+ className="size-4 text-[var(--dropdown-menu-selected-text)]"
225
+ />
226
+ )}
227
+ </span>
228
+ {option.label}
229
+ </>
230
+ )}
231
+ </ComboboxOption>
311
232
  );
312
- const isMatchSelectedValue = !!matchSelectedValue;
313
-
314
- let option = matchSelectedValue || {
315
- value: "",
316
- label: "",
317
- };
318
-
319
- if (!isMatchSelectedValue && textValue) {
320
- option = {
321
- value: "",
322
- label: "",
323
- };
324
- }
325
-
326
- if (keyCode.current === "Enter") {
327
- return;
328
- }
329
-
330
- setSelectedOption(option);
331
- setTextValue(option.label);
332
- onSelect?.(option);
333
- },
334
- [optionsFiltered, textValue],
335
- );
336
-
337
- const handleOnBlur = useCallback(
338
- (e: React.FocusEvent<HTMLInputElement, Element>) => {
339
- setTimeout(() => setIsFocused(false), 200);
340
- clearMismatchValue(e);
341
- props?.onBlur?.(e);
342
- },
343
- [props?.onBlur, clearMismatchValue],
344
- );
345
-
346
- const handleOnKeyDown = useCallback(
347
- (e: React.KeyboardEvent<HTMLInputElement>) => {
348
- keyCode.current = e.code;
349
- props?.onKeyDown?.(e);
350
- },
351
- [props?.onKeyDown],
352
- );
233
+ });
234
+ };
353
235
 
354
236
  return (
355
- <div className={`relative ${fullwidth ? "w-full" : ""}`}>
356
- <TextInput
357
- hasClearIcon={false}
358
- endIcon={
359
- <ChevronDownIcon
360
- className={dropdownIconVariant({ isFocus: isFocused })}
237
+ <Combobox
238
+ value={selectedOption}
239
+ onChange={handleSelect}
240
+ immediate
241
+ by="value"
242
+ disabled={disabled}
243
+ >
244
+ {({ open }) => (
245
+ <div className={cn("relative", fullwidth && "w-full")}>
246
+ {/*
247
+ * ComboboxInput as={TextInput}:
248
+ * Now that TextInput uses mergeRefs(externalRef, internalRef) on <input>,
249
+ * Headless UI receives a direct HTMLInputElement ref — no imperative handle
250
+ * wrapper — so it can attach its event listeners correctly.
251
+ */}
252
+ <ComboboxInput
253
+ as={TextInput}
254
+ ref={inputRef}
255
+ hasClearIcon={false}
256
+ endIcon={
257
+ <ChevronDownIcon
258
+ className={dropdownIconVariant({ isFocus: open })}
259
+ />
260
+ }
261
+ label={label}
262
+ placeholder=" "
263
+ autoComplete="off"
264
+ rounded={rounded}
265
+ variant={variant}
266
+ helperText={helperText}
267
+ errorMessage={errorMessage}
268
+ fullwidth={fullwidth}
269
+ error={error}
270
+ required={required}
271
+ id={_id}
272
+ disabled={disabled}
273
+ size={size}
274
+ className={
275
+ segmentedInput ? customInputVariant({ size }) : undefined
276
+ }
277
+ displayValue={(opt: unknown) =>
278
+ (opt as Options | null)?.label ?? ""
279
+ }
280
+ readOnly={!filterMode}
281
+ onChange={handleInputChange}
282
+ {...props}
361
283
  />
362
- }
363
- {...props}
364
- ref={inputRef}
365
- readOnly={!filterMode}
366
- value={textValue}
367
- onChange={handleOnChangeText}
368
- label={label}
369
- placeholder=" "
370
- type="text"
371
- autoComplete="off"
372
- rounded={rounded}
373
- variant={variant}
374
- helperText={helperText}
375
- errorMessage={errorMessage}
376
- fullwidth={fullwidth}
377
- error={error}
378
- required={required}
379
- id={_id}
380
- disabled={disabled}
381
- size={size}
382
- className={segmentedInput ? customInputVariant({ size }) : undefined}
383
- onFocus={handleOnFocus}
384
- onBlur={handleOnBlur}
385
- onKeyDown={handleOnKeyDown}
386
- />
387
- {isFocused &&
388
- (usePortal ? (
389
- <Portal.Root container={document.body}>
390
- {renderOptions()}
391
- </Portal.Root>
392
- ) : (
393
- renderOptions()
394
- ))}
395
- </div>
284
+
285
+ {/*
286
+ * No anchor = no forced portal — content stays inside Dialog DOM.
287
+ * Radix Dialog won't see option clicks as "outside" → Dialog won't close.
288
+ * z-[51] beats the Dialog overlay (z-50) for items that visually overflow.
289
+ */}
290
+ <ComboboxOptions
291
+ className={cn(
292
+ "absolute top-full left-0 w-full -mt-3 z-[51]",
293
+ "min-w-[154px] max-h-60 overflow-y-auto",
294
+ "rounded-md bg-modal-dropdown-surface border border-bg-stroke3 text-text-g-contrast-high",
295
+ optionContainerClassName,
296
+ )}
297
+ style={{ boxShadow: "var(--dropdown-menu-shadow)" }}
298
+ >
299
+ {renderOptionList()}
300
+ </ComboboxOptions>
301
+ </div>
302
+ )}
303
+ </Combobox>
396
304
  );
397
305
  },
398
306
  );
399
307
 
308
+ Dropdown.displayName = "Dropdown";
309
+
400
310
  export default Dropdown;