@noya-app/noya-designsystem 0.1.77 → 0.1.78

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 (81) hide show
  1. package/.turbo/turbo-build.log +20 -12
  2. package/CHANGELOG.md +9 -0
  3. package/dist/chunk-D57E6H3M.mjs +36 -0
  4. package/dist/chunk-D57E6H3M.mjs.map +1 -0
  5. package/dist/emojis.d.mts +1 -0
  6. package/dist/emojis.d.ts +1 -0
  7. package/dist/emojis.js +31 -0
  8. package/dist/emojis.js.map +1 -0
  9. package/dist/emojis.mjs +8 -0
  10. package/dist/emojis.mjs.map +1 -0
  11. package/dist/index.css +1 -1
  12. package/dist/index.d.mts +142 -90
  13. package/dist/index.d.ts +142 -90
  14. package/dist/index.js +3171 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +3349 -1918
  17. package/dist/index.mjs.map +1 -1
  18. package/package.json +11 -7
  19. package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +1 -1
  20. package/src/components/Avatar.tsx +10 -10
  21. package/src/components/Banner.tsx +1 -1
  22. package/src/components/BaseToolbar.tsx +1 -1
  23. package/src/components/Button.tsx +2 -1
  24. package/src/components/Chip.tsx +1 -1
  25. package/src/components/ColorSwatchControl.tsx +22 -14
  26. package/src/components/CommandPalette.tsx +10 -6
  27. package/src/components/ContextMenu.tsx +249 -38
  28. package/src/components/Dialog.tsx +70 -67
  29. package/src/components/Drawer.tsx +56 -17
  30. package/src/components/DropdownMenu.tsx +305 -46
  31. package/src/components/EditableText.tsx +1 -1
  32. package/src/components/EmojiPicker.tsx +645 -0
  33. package/src/components/GridView.tsx +5 -1
  34. package/src/components/IVirtualizedList.tsx +21 -1
  35. package/src/components/InputField.tsx +2 -10
  36. package/src/components/InspectorContainer.tsx +4 -2
  37. package/src/components/Message.tsx +5 -16
  38. package/src/components/Popover.tsx +73 -107
  39. package/src/components/Progress.tsx +18 -18
  40. package/src/components/ScrollArea.tsx +66 -31
  41. package/src/components/ScrollableSidebar.tsx +1 -1
  42. package/src/components/SegmentedControl.tsx +166 -38
  43. package/src/components/SelectMenu.tsx +193 -101
  44. package/src/components/Slider.tsx +40 -38
  45. package/src/components/Switch.tsx +4 -4
  46. package/src/components/TextArea.tsx +1 -1
  47. package/src/components/Toast.tsx +99 -26
  48. package/src/components/Toolbar.tsx +114 -16
  49. package/src/components/Tooltip.tsx +18 -8
  50. package/src/components/Virtualized.tsx +193 -14
  51. package/src/components/VisuallyHidden.tsx +20 -0
  52. package/src/components/__tests__/Virtualized.math.test.ts +426 -1
  53. package/src/components/__tests__/Virtualized.test.tsx +129 -1
  54. package/src/components/ai-assistant/AIAssistantLayout.tsx +11 -6
  55. package/src/components/internal/Menu.tsx +4 -0
  56. package/src/components/listView/ListViewEditableRowTitle.tsx +1 -1
  57. package/src/components/listView/ListViewRoot.tsx +5 -1
  58. package/src/components/resizablePanels/Panel.tsx +94 -0
  59. package/src/components/resizablePanels/PanelGroup.tsx +498 -0
  60. package/src/components/resizablePanels/PanelGroupContext.tsx +14 -0
  61. package/src/components/resizablePanels/PanelResizeHandle.tsx +61 -0
  62. package/src/components/resizablePanels/index.ts +7 -0
  63. package/src/components/resizablePanels/types.ts +65 -0
  64. package/src/components/workspace/DrawerWorkspaceLayout.tsx +22 -7
  65. package/src/components/workspace/PanelWorkspaceLayout.tsx +30 -84
  66. package/src/components/workspace/WorkspaceLayout.tsx +28 -58
  67. package/src/components/workspace/types.ts +6 -4
  68. package/src/contexts/DialogContext.tsx +15 -24
  69. package/src/emojis.ts +1 -0
  70. package/src/hooks/useTriggerToggle.ts +95 -0
  71. package/src/index.css +2 -0
  72. package/src/index.tsx +1 -2
  73. package/src/theme/proseTheme.ts +2 -0
  74. package/src/utils/mergeProps.ts +57 -0
  75. package/src/utils/skinTone.ts +90 -0
  76. package/tsup.config.ts +1 -1
  77. package/src/__tests__/workspaceLayout.test.ts +0 -281
  78. package/src/components/ScrollArea2.tsx +0 -76
  79. package/src/components/internal/MenuViewport.tsx +0 -178
  80. package/src/components/internal/SelectItem.tsx +0 -138
  81. package/src/components/workspace/panelStorage.ts +0 -216
@@ -1,14 +1,18 @@
1
1
  "use client";
2
2
 
3
+ import { Toggle } from "@base-ui/react/toggle";
4
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
3
5
  import { memoGeneric } from "@noya-app/react-utils";
4
- import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
5
6
  import React, {
6
7
  createContext,
7
8
  forwardRef,
8
9
  ReactNode,
9
10
  useCallback,
10
11
  useContext,
12
+ useLayoutEffect,
11
13
  useMemo,
14
+ useRef,
15
+ useState,
12
16
  } from "react";
13
17
  import { useLabel } from "../hooks/useLabel";
14
18
  import { cx } from "../utils/classNames";
@@ -22,12 +26,16 @@ type SegmentedControlVariant = "default" | "tabs" | "buttons";
22
26
  type SegmentedControlContextValue = {
23
27
  /** @default primary */
24
28
  colorScheme?: SegmentedControlColorScheme;
29
+ onSelect?: (value: string) => void;
30
+ itemWidth?: SegmentedControlItemWidth;
25
31
  };
26
32
 
27
33
  const SegmentedControlContext = createContext<SegmentedControlContextValue>({
28
34
  colorScheme: "primary",
29
35
  });
30
36
 
37
+ type SegmentedControlItemWidth = "auto" | "equal" | "fit";
38
+
31
39
  export interface SegmentedControlProps<T extends string> {
32
40
  id?: string;
33
41
  value?: T;
@@ -39,6 +47,14 @@ export interface SegmentedControlProps<T extends string> {
39
47
  className?: string;
40
48
  style?: React.CSSProperties;
41
49
  variant?: SegmentedControlVariant;
50
+ /**
51
+ * Controls item width distribution.
52
+ * - "auto": Items try to be equal width, but won't shrink below their content
53
+ * - "equal": Items are always equal width (may truncate content)
54
+ * - "fit": Items fit their content
55
+ * @default "auto"
56
+ */
57
+ itemWidth?: SegmentedControlItemWidth;
42
58
  }
43
59
 
44
60
  export type SegmentedControlItemProps<T extends string> = {
@@ -67,17 +83,31 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
67
83
  }: SegmentedControlItemProps<T>,
68
84
  forwardedRef: React.ForwardedRef<HTMLButtonElement>
69
85
  ) {
70
- const { colorScheme } = useContext(SegmentedControlContext);
86
+ const { colorScheme, onSelect, itemWidth = "auto" } =
87
+ useContext(SegmentedControlContext);
88
+
89
+ const handlePointerDown = useCallback(
90
+ (event: React.PointerEvent) => {
91
+ if (disabled) return;
92
+ onSelect?.((value ?? "").toString());
93
+ // Prevent default to avoid double-triggering on click
94
+ event.preventDefault();
95
+ },
96
+ [disabled, onSelect, value]
97
+ );
71
98
 
72
99
  const itemElement = (
73
- <ToggleGroupPrimitive.Item
100
+ <Toggle
74
101
  ref={forwardedRef}
75
102
  value={(value ?? "").toString()}
76
103
  disabled={disabled}
104
+ onPointerDown={handlePointerDown}
77
105
  className={cx(
78
106
  "n-group",
79
107
  "n-flex n-items-center n-justify-center",
80
- "n-font-sans n-text-button n-relative n-flex-1 n-appearance-none focus:n-outline-none n-transition-colors",
108
+ "n-font-sans n-text-button n-relative n-appearance-none focus:n-outline-none n-transition-colors",
109
+ // item width distribution (only for default variant with fit mode)
110
+ variant === "default" && itemWidth === "fit" && "n-flex-none",
81
111
  // text color
82
112
  variant === "buttons"
83
113
  ? "n-text-button-text"
@@ -88,36 +118,44 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
88
118
  : variant === "buttons"
89
119
  ? "n-font-medium"
90
120
  : "n-font-normal",
91
- // focus ring
121
+ // focus ring (visible for keyboard users only)
92
122
  variant === "tabs"
93
123
  ? ""
94
124
  : variant === "buttons"
95
- ? "focus:n-ring-2 focus:n-ring-primary focus:n-shadow-[0_0_0_1px_var(--n-popover-background)_inset] n-transition-all"
125
+ ? "focus-visible:n-ring-2 focus-visible:n-ring-primary focus-visible:n-shadow-[0_0_0_1px_var(--n-popover-background)_inset] n-transition-all"
96
126
  : colorScheme === "secondary"
97
- ? "focus:n-ring-2 focus:n-ring-secondary focus:n-ring-offset-1"
98
- : "focus:n-ring-2 focus:n-ring-primary focus:n-ring-offset-1",
127
+ ? "focus-visible:n-ring-2 focus-visible:n-ring-secondary focus-visible:n-ring-offset-1"
128
+ : "focus-visible:n-ring-2 focus-visible:n-ring-primary focus-visible:n-ring-offset-1",
99
129
  // checked
100
130
  variant === "tabs"
101
- ? "aria-checked:n-text-primary"
131
+ ? "data-[pressed]:n-text-primary"
102
132
  : variant === "buttons"
103
- ? "active:n-text-button-text-active active:n-bg-button-background-active aria-checked:n-text-button-text-active aria-checked:n-bg-button-background-active"
104
- : colorScheme === "secondary"
105
- ? "aria-checked:n-bg-secondary aria-checked:n-text-white"
106
- : colorScheme === "primary"
107
- ? "aria-checked:n-bg-primary aria-checked:n-text-white"
108
- : "aria-checked:n-bg-segmented-control-item-active-background aria-checked:n-text-segmented-control-item-active-text",
109
- "focus:n-z-interactable",
110
- // checked default (all colorSchemes)
133
+ ? "active:n-text-button-text-active active:n-bg-button-background-active data-[pressed]:n-text-button-text-active data-[pressed]:n-bg-button-background-active"
134
+ : variant === "default"
135
+ ? // default variant: only text color, background handled by sliding indicator
136
+ colorScheme === "secondary"
137
+ ? "data-[pressed]:n-text-white"
138
+ : colorScheme === "primary"
139
+ ? "data-[pressed]:n-text-white"
140
+ : "data-[pressed]:n-text-segmented-control-item-active-text"
141
+ : colorScheme === "secondary"
142
+ ? "data-[pressed]:n-bg-secondary data-[pressed]:n-text-white"
143
+ : colorScheme === "primary"
144
+ ? "data-[pressed]:n-bg-primary data-[pressed]:n-text-white"
145
+ : "data-[pressed]:n-bg-segmented-control-item-active-background data-[pressed]:n-text-segmented-control-item-active-text",
146
+ "focus-visible:n-z-interactable",
147
+ // checked default (all colorSchemes) - hide separators near selected
111
148
  variant === "default" &&
112
- "aria-checked:n-border-r-transparent [&:has(+[aria-checked=true])]:n-border-r-transparent aria-checked:n-shadow-segment",
149
+ "data-[pressed]:after:n-hidden [&:has(+[data-pressed])]:after:n-hidden",
113
150
  // hover
114
151
  variant === "buttons" && "hover:n-bg-button-background-hover",
115
152
  // spacing and borders
153
+ "n-px-input-padding-x",
116
154
  variant === "default"
117
- ? "n-px-1 n-rounded n-border-r n-border-divider last:n-border-r-0"
155
+ ? "n-rounded after:n-content-[''] after:n-absolute after:n-right-0 after:n-top-0.5 after:n-bottom-0.5 after:n-w-px after:n-bg-divider last:after:n-hidden"
118
156
  : variant === "buttons"
119
- ? "n-px-1.5 n-rounded-sm n-max-w-fit"
120
- : "n-px-1.5 n-border-y-2 n-border-y-transparent aria-checked:n-border-b-primary",
157
+ ? "n-rounded-sm n-max-w-fit"
158
+ : "n-border-y-2 n-border-y-transparent data-[pressed]:n-border-b-primary",
121
159
  // icon only buttons
122
160
  variant === "buttons" && title === "" && icon && "n-min-w-input-height",
123
161
  className
@@ -134,7 +172,7 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
134
172
  {icon && renderIcon(icon)}
135
173
  {title}
136
174
  </span>
137
- </ToggleGroupPrimitive.Item>
175
+ </Toggle>
138
176
  );
139
177
 
140
178
  return tooltip ? (
@@ -156,42 +194,132 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
156
194
  variant = "default",
157
195
  className,
158
196
  style,
197
+ itemWidth = "auto",
159
198
  }: SegmentedControlProps<T>) {
160
- const contextValue = useMemo(() => ({ colorScheme }), [colorScheme]);
161
199
  const { fieldId: id } = useLabel({ fieldId: idProp });
162
200
 
163
- const handleValueChange = useCallback(
164
- (value: T) => {
165
- if (!allowEmpty && !value) return;
166
- onValueChange?.(value);
201
+ // Track if we should animate (skip initial mount)
202
+ const hasInitialized = useRef(false);
203
+ const containerRef = useRef<HTMLDivElement>(null);
204
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
205
+
206
+ // Indicator position based on measured item dimensions
207
+ const [indicatorStyle, setIndicatorStyle] = useState<{
208
+ left: number;
209
+ width: number;
210
+ } | null>(null);
211
+
212
+ const selectedIndex = useMemo(() => {
213
+ if (!value) return -1;
214
+ return items.findIndex((item) => item.value === value);
215
+ }, [value, items]);
216
+
217
+ useLayoutEffect(() => {
218
+ if (variant !== "default" || selectedIndex < 0) {
219
+ setIndicatorStyle(null);
220
+ return;
221
+ }
222
+
223
+ const container = containerRef.current;
224
+ const selectedItem = itemRefs.current[selectedIndex];
225
+
226
+ if (!container || !selectedItem) {
227
+ setIndicatorStyle(null);
228
+ return;
229
+ }
230
+
231
+ const containerRect = container.getBoundingClientRect();
232
+ const itemRect = selectedItem.getBoundingClientRect();
233
+
234
+ setIndicatorStyle({
235
+ left: itemRect.left - containerRect.left,
236
+ width: itemRect.width,
237
+ });
238
+
239
+ // Enable animation after first measurement
240
+ hasInitialized.current = true;
241
+ }, [selectedIndex, variant, items.length]);
242
+
243
+ const handleSelect = useCallback(
244
+ (newValue: string) => {
245
+ if (!allowEmpty && !newValue) return;
246
+ onValueChange?.(newValue as T);
167
247
  },
168
248
  [allowEmpty, onValueChange]
169
249
  );
170
250
 
251
+ const handleGroupValueChange = useCallback(
252
+ (groupValue: string[]) => handleSelect(groupValue[0] ?? ""),
253
+ [handleSelect]
254
+ );
255
+
256
+ const contextValue = useMemo(
257
+ () => ({ colorScheme, onSelect: handleSelect, itemWidth }),
258
+ [colorScheme, handleSelect, itemWidth]
259
+ );
260
+
261
+ const indicatorBgClass =
262
+ colorScheme === "secondary"
263
+ ? "n-bg-secondary"
264
+ : colorScheme === "primary"
265
+ ? "n-bg-primary"
266
+ : "n-bg-segmented-control-item-active-background";
267
+
171
268
  return (
172
269
  <SegmentedControlContext.Provider value={contextValue}>
173
- <ToggleGroupPrimitive.Root
270
+ <ToggleGroup
271
+ ref={containerRef}
174
272
  id={id}
175
- type="single"
176
- value={value}
177
- onValueChange={handleValueChange}
273
+ value={value ? [value] : []}
274
+ onValueChange={handleGroupValueChange}
178
275
  className={cx(
179
- `n-appearance-none n-relative n-outline-none`,
276
+ "n-appearance-none n-relative n-outline-none",
180
277
  variant === "default" &&
181
- "n-grid n-min-h-input-height n-bg-input-background n-rounded n-py-0.5 n-px-0.5",
182
- variant === "tabs" && "n-flex n-gap-1.5 n-min-h-[33px]",
183
- variant === "buttons" && "n-flex n-gap-1.5 n-min-h-input-height",
278
+ (itemWidth === "fit" ? "n-flex n-w-fit" : "n-grid"),
279
+ variant === "default" &&
280
+ "n-min-h-input-height n-bg-input-background n-rounded n-py-0.5 n-px-0.5",
281
+ variant !== "default" && "n-flex",
282
+ variant === "tabs" && "n-gap-1.5 n-min-h-[33px]",
283
+ variant === "buttons" && "n-gap-1.5 n-min-h-input-height",
184
284
  className
185
285
  )}
186
286
  style={{
187
287
  ...style,
188
- gridTemplateColumns: `repeat(${items.length}, 1fr)`,
288
+ ...(variant === "default" &&
289
+ itemWidth !== "fit" && {
290
+ gridTemplateColumns:
291
+ itemWidth === "equal"
292
+ ? `repeat(${items.length}, 1fr)`
293
+ : `repeat(${items.length}, minmax(min-content, 1fr))`,
294
+ }),
189
295
  }}
190
296
  >
297
+ {/* Sliding indicator for default variant */}
298
+ {variant === "default" && indicatorStyle && (
299
+ <div
300
+ className={cx(
301
+ "n-absolute n-top-0.5 n-bottom-0.5 n-rounded n-shadow-segment n-pointer-events-none",
302
+ indicatorBgClass,
303
+ hasInitialized.current &&
304
+ "n-transition-[left,width] n-duration-200 n-ease-out"
305
+ )}
306
+ style={{
307
+ left: indicatorStyle.left,
308
+ width: indicatorStyle.width,
309
+ }}
310
+ />
311
+ )}
191
312
  {items.map((item, index) => (
192
- <SegmentedControlItem key={index} {...item} variant={variant} />
313
+ <SegmentedControlItem
314
+ key={index}
315
+ ref={(el) => {
316
+ itemRefs.current[index] = el;
317
+ }}
318
+ {...item}
319
+ variant={variant}
320
+ />
193
321
  ))}
194
- </ToggleGroupPrimitive.Root>
322
+ </ToggleGroup>
195
323
  </SegmentedControlContext.Provider>
196
324
  );
197
325
  });
@@ -4,13 +4,14 @@ import {
4
4
  ChevronDownIcon,
5
5
  ChevronUpIcon,
6
6
  DropdownChevronIcon,
7
+ CheckIcon,
7
8
  } from "@noya-app/noya-icons";
8
9
  import { memoGeneric } from "@noya-app/react-utils";
9
- import { Select } from "radix-ui";
10
- import { type SelectProps } from "@radix-ui/react-select";
10
+ import { Select } from "@base-ui/react/select";
11
11
  import React, {
12
12
  CSSProperties,
13
13
  FocusEventHandler,
14
+ useCallback,
14
15
  useMemo,
15
16
  useState,
16
17
  } from "react";
@@ -23,15 +24,19 @@ import {
23
24
  } from "../contexts/PortalScopeContext";
24
25
  import { getInsetEndStyles } from "../utils/inputs";
25
26
  import { Button } from "./Button";
26
- import { renderIcon } from "./Icons";
27
+ import { renderIcon, Icon, IconName } from "./Icons";
27
28
  import {
28
29
  isSelectableMenuItem,
29
30
  MenuItem,
30
31
  SelectableMenuItem,
31
32
  styles,
33
+ SectionHeaderMenuItem as SectionHeaderMenuItemComponent,
34
+ CHECKBOX_INDENT_WIDTH,
35
+ CHECKBOX_RIGHT_INSET,
36
+ CHECKBOX_WIDTH,
32
37
  } from "./internal/Menu";
33
- import { MenuComponents, MenuViewport } from "./internal/MenuViewport";
34
38
  import { Label } from "./Label";
39
+ import { Spacer } from "./Spacer";
35
40
 
36
41
  type Props<T extends string> = {
37
42
  id?: string;
@@ -47,7 +52,9 @@ type Props<T extends string> = {
47
52
  onFocus?: FocusEventHandler;
48
53
  onBlur?: FocusEventHandler;
49
54
  contentStyle?: React.CSSProperties;
50
- } & Pick<SelectProps, "open" | "onOpenChange">;
55
+ open?: boolean;
56
+ onOpenChange?: (open: boolean) => void;
57
+ };
51
58
 
52
59
  const readOnlyStyle: CSSProperties = {
53
60
  justifyContent: "flex-start",
@@ -59,75 +66,118 @@ const scrollButtonStyles =
59
66
 
60
67
  const insetEndStyles = getInsetEndStyles();
61
68
 
62
- interface SelectMenuTriggerProps {
63
- id?: string;
64
- style?: React.CSSProperties;
65
- className?: string;
66
- disabled?: boolean;
67
- icon?: React.ReactNode;
68
- placeholder?: string;
69
- isOpen?: boolean;
70
- onFocus?: FocusEventHandler;
71
- onBlur?: FocusEventHandler;
72
- label?: React.ReactNode;
73
- }
69
+ const itemStyle = (state: { disabled: boolean; highlighted: boolean }) =>
70
+ cx(
71
+ "n-flex-none n-select-none n-cursor-pointer n-rounded",
72
+ "n-py-1.5 n-px-2",
73
+ "n-transition-colors",
74
+ "n-flex n-items-center",
75
+ "n-font-sans n-text-button n-font-medium",
76
+ "focus:n-outline-none",
77
+ state.disabled && "n-text-text-disabled",
78
+ state.highlighted &&
79
+ "n-text-selected-list-item-text n-bg-selected-list-item-background"
80
+ );
74
81
 
75
- const SelectMenuTrigger = memoGeneric(function SelectMenuTrigger({
76
- style,
77
- className,
78
- disabled,
82
+ function SelectItemContent({
79
83
  icon,
80
- placeholder,
81
- isOpen,
82
- onFocus,
83
- onBlur,
84
- ...props
85
- }: SelectMenuTriggerProps) {
86
- const { label, fieldId: id } = useLabel({
87
- label: props.label,
88
- fieldId: props.id,
89
- });
90
- const labelType = useLabelType();
91
-
84
+ checked,
85
+ indented,
86
+ children,
87
+ }: {
88
+ icon?: React.ReactNode;
89
+ checked?: boolean;
90
+ indented?: boolean;
91
+ children: React.ReactNode;
92
+ }) {
92
93
  return (
93
- <Select.SelectTrigger asChild onFocus={onFocus} onBlur={onBlur}>
94
- <Button
95
- id={id}
96
- style={style}
97
- className={cx(
98
- className ?? "n-flex n-w-full",
99
- "n-flex-1 focus:n-z-interactable n-relative",
100
- isOpen && "n-ring-2 n-ring-primary"
101
- )}
102
- disabled={disabled}
103
- >
104
- {icon && <span className="n-pr-1.5">{renderIcon(icon)}</span>}
105
- {label && labelType === "inset" && (
106
- <div className={insetEndStyles}>
107
- <Label className="n-pr-4 n-text-text-disabled" htmlFor={id}>
108
- {label}
109
- </Label>
110
- </div>
111
- )}
112
- <span className="n-flex-1 n-flex">
113
- <Select.Value placeholder={placeholder} />
94
+ <>
95
+ {indented && <Spacer.Horizontal size={CHECKBOX_INDENT_WIDTH} />}
96
+ {checked ? (
97
+ <span
98
+ className="n-flex n-items-center n-relative -n-left-2.5"
99
+ style={{ marginRight: -CHECKBOX_RIGHT_INSET }}
100
+ >
101
+ <CheckIcon />
114
102
  </span>
115
- <DropdownChevronIcon
116
- className={cx(
117
- "n-transition-transform n-duration-100 n-ml-1.5",
118
- isOpen ? "n-rotate-180" : "n-rotate-0"
119
- )}
120
- />
121
- </Button>
122
- </Select.SelectTrigger>
103
+ ) : indented ? (
104
+ <Spacer.Horizontal size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET} />
105
+ ) : null}
106
+ {icon &&
107
+ (typeof icon === "string" ? (
108
+ <Icon
109
+ name={icon as IconName}
110
+ className="n-mr-2 n-w-4 n-h-4 n-flex-shrink-0"
111
+ />
112
+ ) : (
113
+ <span className="n-mr-2 n-w-4 n-h-4 n-flex-shrink-0 n-flex n-items-center n-justify-center">
114
+ {icon}
115
+ </span>
116
+ ))}
117
+ <div className="n-flex n-items-center n-flex-1">{children}</div>
118
+ </>
123
119
  );
124
- });
120
+ }
125
121
 
126
- const Components: MenuComponents = {
127
- Item: Select.Item,
128
- ItemText: Select.ItemText,
129
- Separator: Select.Separator,
130
- };
122
+ function SelectMenuItems<T extends string>({
123
+ items,
124
+ hasCheckedItem,
125
+ }: {
126
+ items: MenuItem<T>[];
127
+ hasCheckedItem: boolean;
128
+ }) {
129
+ return (
130
+ <>
131
+ {items.map((item, index) => {
132
+ switch (item.type) {
133
+ case "separator":
134
+ return (
135
+ <Select.Separator
136
+ key={index}
137
+ className={styles.separatorStyle}
138
+ />
139
+ );
140
+
141
+ case "sectionHeader":
142
+ return (
143
+ <SectionHeaderMenuItemComponent
144
+ isFirst={index === 0}
145
+ key={item.id}
146
+ {...item}
147
+ indented={hasCheckedItem}
148
+ />
149
+ );
150
+
151
+ case "popover":
152
+ case "submenu":
153
+ // Select doesn't support submenus/popovers
154
+ return null;
155
+
156
+ default:
157
+ return (
158
+ <div key={item.value} className="n-px-1">
159
+ <Select.Item
160
+ value={item.value}
161
+ disabled={item.disabled}
162
+ className={itemStyle}
163
+ >
164
+ <SelectItemContent
165
+ icon={item.icon}
166
+ checked={item.checked}
167
+ indented={hasCheckedItem}
168
+ >
169
+ <Select.ItemText>
170
+ {item.title ?? item.value}
171
+ </Select.ItemText>
172
+ </SelectItemContent>
173
+ </Select.Item>
174
+ </div>
175
+ );
176
+ }
177
+ })}
178
+ </>
179
+ );
180
+ }
131
181
 
132
182
  export const SelectMenu = memoGeneric(function SelectMenu<
133
183
  T extends string = string,
@@ -154,19 +204,35 @@ export const SelectMenu = memoGeneric(function SelectMenu<
154
204
  isSelectableMenuItem(item) && item.value === value
155
205
  );
156
206
  const icon = selectedItem?.icon;
157
- const [internalOpen, setInternalOpen] = useState(open);
207
+ const [internalOpen, setInternalOpen] = useState(open ?? false);
158
208
 
159
209
  const { label, fieldId: id } = useLabel({
160
210
  label: props.label,
161
211
  fieldId: props.id,
162
212
  });
163
213
 
164
- const handleOpenChange = (open: boolean) => {
165
- if (onOpenChange) {
166
- onOpenChange(open);
167
- }
168
- setInternalOpen(open);
169
- };
214
+ const labelType = useLabelType();
215
+
216
+ const handleOpenChange = useCallback(
217
+ (newOpen: boolean) => {
218
+ onOpenChange?.(newOpen);
219
+ setInternalOpen(newOpen);
220
+ },
221
+ [onOpenChange]
222
+ );
223
+
224
+ const handleValueChange = useCallback(
225
+ (newValue: T | null) => {
226
+ if (newValue !== null) {
227
+ onSelect?.(newValue);
228
+ }
229
+ },
230
+ [onSelect]
231
+ );
232
+
233
+ const hasCheckedItem = items.some(
234
+ (item) => isSelectableMenuItem(item) && item.checked
235
+ );
170
236
 
171
237
  const readOnlyButton = useMemo(
172
238
  () => (
@@ -186,7 +252,7 @@ export const SelectMenu = memoGeneric(function SelectMenu<
186
252
 
187
253
  const contentStyle = useMemo(() => {
188
254
  return {
189
- width: "var(--radix-select-trigger-width)",
255
+ width: "var(--anchor-width)",
190
256
  minWidth: "max-content",
191
257
  maxHeight: "500px",
192
258
  ...contentStyleProp,
@@ -196,44 +262,70 @@ export const SelectMenu = memoGeneric(function SelectMenu<
196
262
  const content = (
197
263
  <Select.Root
198
264
  value={value}
199
- onValueChange={onSelect}
265
+ onValueChange={handleValueChange}
200
266
  open={open}
201
267
  onOpenChange={handleOpenChange}
268
+ disabled={disabled}
202
269
  >
203
- <SelectMenuTrigger
270
+ <Select.Trigger
204
271
  id={id}
205
- style={style}
206
- className={className}
207
- disabled={disabled}
208
- icon={icon}
209
- placeholder={placeholder}
210
- isOpen={internalOpen}
211
272
  onFocus={onFocus}
212
273
  onBlur={onBlur}
213
- label={label}
214
- />
274
+ render={
275
+ <Button
276
+ style={style}
277
+ className={cx(
278
+ className ?? "n-flex n-w-full",
279
+ "n-flex-1 focus:n-z-interactable n-relative",
280
+ internalOpen && "n-ring-2 n-ring-primary"
281
+ )}
282
+ disabled={disabled}
283
+ />
284
+ }
285
+ >
286
+ {icon && <span className="n-pr-1.5">{renderIcon(icon)}</span>}
287
+ {label && labelType === "inset" && (
288
+ <div className={insetEndStyles}>
289
+ <Label className="n-pr-4 n-text-text-disabled" htmlFor={id}>
290
+ {label}
291
+ </Label>
292
+ </div>
293
+ )}
294
+ <span className="n-flex-1 n-flex">
295
+ <Select.Value>
296
+ {selectedItem?.title ?? value ?? placeholder}
297
+ </Select.Value>
298
+ </span>
299
+ <Select.Icon>
300
+ <DropdownChevronIcon
301
+ className={cx(
302
+ "n-transition-transform n-duration-100 n-ml-1.5",
303
+ internalOpen ? "n-rotate-180" : "n-rotate-0"
304
+ )}
305
+ />
306
+ </Select.Icon>
307
+ </Select.Trigger>
215
308
  <Select.Portal>
216
- <Select.Content
309
+ <Select.Positioner
217
310
  {...portalScopeProps(portalScopeId)}
218
- className={styles.contentStyle}
219
- position="popper"
220
311
  sideOffset={6}
221
312
  collisionPadding={8}
222
313
  align="center"
223
314
  style={contentStyle}
315
+ className="n-z-menu"
224
316
  >
225
- <Select.ScrollUpButton className={scrollButtonStyles}>
226
- <ChevronUpIcon
227
- className={cx("n-transition-transform n-duration-100")}
228
- />
229
- </Select.ScrollUpButton>
230
- <Select.Viewport>
231
- <MenuViewport items={items} Components={Components} />
232
- </Select.Viewport>
233
- <Select.ScrollDownButton className={scrollButtonStyles}>
234
- <ChevronDownIcon />
235
- </Select.ScrollDownButton>
236
- </Select.Content>
317
+ <Select.Popup className={styles.contentStyle}>
318
+ <Select.ScrollUpArrow className={scrollButtonStyles}>
319
+ <ChevronUpIcon
320
+ className={cx("n-transition-transform n-duration-100")}
321
+ />
322
+ </Select.ScrollUpArrow>
323
+ <SelectMenuItems items={items} hasCheckedItem={hasCheckedItem} />
324
+ <Select.ScrollDownArrow className={scrollButtonStyles}>
325
+ <ChevronDownIcon />
326
+ </Select.ScrollDownArrow>
327
+ </Select.Popup>
328
+ </Select.Positioner>
237
329
  </Select.Portal>
238
330
  </Select.Root>
239
331
  );