@noya-app/noya-designsystem 0.1.93 → 0.1.95

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 (43) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +20 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +167 -95
  5. package/dist/index.d.ts +167 -95
  6. package/dist/index.js +12689 -12005
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +13912 -13233
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +5 -5
  11. package/src/__tests__/SharedDragProvider.test.ts +72 -29
  12. package/src/__tests__/fuzzyScorer.test.ts +49 -1
  13. package/src/components/Button.tsx +1 -1
  14. package/src/components/Combobox.tsx +23 -3
  15. package/src/components/ComboboxMenu.tsx +32 -15
  16. package/src/components/ContextMenu.tsx +26 -7
  17. package/src/components/DimensionInput.tsx +12 -2
  18. package/src/components/DropdownMenu.tsx +37 -9
  19. package/src/components/InputField.tsx +1 -0
  20. package/src/components/InspectorContainer.tsx +16 -1
  21. package/src/components/List.tsx +25 -3
  22. package/src/components/Popover.tsx +13 -1
  23. package/src/components/Section.tsx +35 -11
  24. package/src/components/SegmentedControl.tsx +14 -4
  25. package/src/components/Toast.tsx +2 -2
  26. package/src/components/Toolbar.tsx +16 -1
  27. package/src/components/ToolbarItems.tsx +322 -0
  28. package/src/components/Tooltip.tsx +8 -1
  29. package/src/components/__tests__/DropdownMenu.test.ts +19 -0
  30. package/src/components/__tests__/Toast.test.tsx +138 -0
  31. package/src/components/__tests__/ToolbarItems.test.tsx +205 -0
  32. package/src/components/internal/Menu.tsx +4 -1
  33. package/src/components/internal/TextInput.tsx +43 -2
  34. package/src/components/internal/__tests__/TextInput.test.tsx +77 -0
  35. package/src/components/listView/ListViewEditableRowTitle.tsx +37 -1
  36. package/src/components/listView/ListViewRow.tsx +3 -0
  37. package/src/components/listView/__tests__/ListViewEditableRowTitle.test.tsx +69 -0
  38. package/src/components/sorting/Sortable.tsx +4 -1
  39. package/src/index.tsx +0 -1
  40. package/src/utils/combobox.ts +22 -5
  41. package/src/utils/fuzzyScorer.ts +71 -0
  42. package/tailwind.config.ts +7 -0
  43. package/src/components/InspectorPrimitives.tsx +0 -188
@@ -295,9 +295,24 @@ export const List = memoGeneric(
295
295
  }}
296
296
  onKeyDown={(e: React.KeyboardEvent) => {
297
297
  if (e.key !== "Enter" && e.key !== " ") return;
298
+ // A key already claimed by a global shortcut (e.g. a
299
+ // capture-phase keymap handler) must never fall through to
300
+ // row selection/rename.
301
+ if (e.nativeEvent.defaultPrevented) return;
298
302
  e.preventDefault();
303
+ // Fully consume the key so global shortcut handlers never
304
+ // double-handle the same press.
305
+ e.stopPropagation();
299
306
  handleSelect(id, e);
300
- if (e.key === "Enter") {
307
+ // Rename is strictly the plain Enter press — chorded Enter
308
+ // (or any other shortcut) must never open the row editor.
309
+ if (
310
+ e.key === "Enter" &&
311
+ !e.metaKey &&
312
+ !e.ctrlKey &&
313
+ !e.altKey &&
314
+ !e.shiftKey
315
+ ) {
301
316
  setRenamingId(id);
302
317
  }
303
318
  }}
@@ -324,7 +339,9 @@ export const List = memoGeneric(
324
339
  {thumbnail && (
325
340
  <div
326
341
  className={cx(
327
- "n-overflow-hidden n-flex-none n-flex n-items-center n-justify-center",
342
+ // outline-none: focusable-by-click (tabIndex -1)
343
+ // wrapper must never show the UA focus outline.
344
+ "n-overflow-hidden n-flex-none n-flex n-items-center n-justify-center n-outline-none",
328
345
  thumbnailSize === "auto" && thumbnailSizeMap[size],
329
346
  isSelected && "n-text-selected-list-item-text",
330
347
  roundedMap[size]
@@ -337,7 +354,12 @@ export const List = memoGeneric(
337
354
  <div className="n-flex n-flex-col n-flex-1 n-w-0">
338
355
  <div className="n-flex n-items-center n-min-h-input-height n-flex-1 n-gap-2">
339
356
  <div
340
- className="n-flex-1 n-w-0 n-flex n-items-center"
357
+ // outline-none: clicking the title focuses this
358
+ // tabIndex -1 wrapper, and a later keydown can promote
359
+ // it to :focus-visible — without this, that painted the
360
+ // UA's black outline around the row title (reading as a
361
+ // stuck rename editor). Focus styling lives on the row.
362
+ className="n-flex-1 n-w-0 n-flex n-items-center n-outline-none"
341
363
  tabIndex={-1}
342
364
  >
343
365
  {isRenaming ? (
@@ -31,6 +31,8 @@ interface Props {
31
31
  style?: React.CSSProperties;
32
32
  portalContainer?: HTMLElement | null;
33
33
  side?: "top" | "bottom" | "left" | "right";
34
+ /** Cross-axis alignment against the anchor. @default "center" */
35
+ align?: "start" | "center" | "end";
34
36
  initialFocus?: React.ComponentProps<
35
37
  typeof PopoverPrimitive.Popup
36
38
  >["initialFocus"];
@@ -39,6 +41,13 @@ interface Props {
39
41
  * Return false to prevent the popover from closing.
40
42
  */
41
43
  shouldCloseOnInteractOutside?: (event: PointerEvent) => boolean;
44
+ /**
45
+ * Whether the trigger ultimately renders a native `<button>`. Pass false
46
+ * for non-button triggers (e.g. an input container) so Base UI doesn't log
47
+ * a nativeButton mismatch console error.
48
+ * @default true
49
+ */
50
+ nativeButtonTrigger?: boolean;
42
51
  }
43
52
 
44
53
  export const popoverStyle = {
@@ -54,6 +63,7 @@ export function Popover({
54
63
  closable,
55
64
  open,
56
65
  side,
66
+ align = "center",
57
67
  sideOffset = 4,
58
68
  showArrow = true,
59
69
  onOpenChange,
@@ -63,6 +73,7 @@ export function Popover({
63
73
  style,
64
74
  initialFocus,
65
75
  shouldCloseOnInteractOutside,
76
+ nativeButtonTrigger = true,
66
77
  }: Props) {
67
78
  const defaultId = useId();
68
79
  const portalScopeId = usePortalScopeId();
@@ -91,6 +102,7 @@ export function Popover({
91
102
  return (
92
103
  <PopoverPrimitive.Root open={isOpen} onOpenChange={handleOpenChange}>
93
104
  <PopoverPrimitive.Trigger
105
+ nativeButton={nativeButtonTrigger}
94
106
  render={(triggerProps) => {
95
107
  const element = React.isValidElement(trigger) ? (
96
108
  trigger
@@ -122,7 +134,7 @@ export function Popover({
122
134
  {...portalScopeProps(portalScopeId)}
123
135
  anchor={anchorElement}
124
136
  side={side}
125
- align="center"
137
+ align={align}
126
138
  sideOffset={sideOffset}
127
139
  collisionPadding={8}
128
140
  arrowPadding={2}
@@ -6,7 +6,6 @@ import {
6
6
  IconName,
7
7
  IndentContext,
8
8
  INPUT_HEIGHT,
9
- InspectorPrimitives,
10
9
  renderIcon,
11
10
  Spacer,
12
11
  Text,
@@ -14,6 +13,37 @@ import {
14
13
  } from "@noya-app/noya-designsystem";
15
14
  import { clientStorage, usePersistentStateString } from "@noya-app/react-utils";
16
15
  import React, { CSSProperties, memo, useMemo } from "react";
16
+ import { cx } from "../utils/classNames";
17
+
18
+ // Local copies of the bare section-header shells (the shared set moved to
19
+ // @noya-app/inspector as `FieldLayout`, which this package cannot depend on
20
+ // without a cycle). Keep the class lists in sync with FieldLayout.
21
+ const SectionHeaderShell = ({
22
+ className,
23
+ ...props
24
+ }: React.HTMLAttributes<HTMLDivElement>) => (
25
+ <div className={cx(`n-flex n-items-center`, className)} {...props} />
26
+ );
27
+
28
+ const TitleShell = ({
29
+ className,
30
+ $textStyle,
31
+ ...props
32
+ }: React.HTMLAttributes<HTMLDivElement> & {
33
+ $textStyle?: "small" | "heading5" | "heading4" | "heading3";
34
+ }) => (
35
+ <div
36
+ className={cx(
37
+ `n-flex n-select-none ${
38
+ $textStyle
39
+ ? `${textStyles[$textStyle]} n-text-text`
40
+ : "n-font-sans n-text-label n-text-text-muted n-font-semibold"
41
+ } `,
42
+ className
43
+ )}
44
+ {...props}
45
+ />
46
+ );
17
47
 
18
48
  const titleIconStyle: CSSProperties = {
19
49
  marginRight: "6px",
@@ -57,19 +87,13 @@ export function SectionHeader({
57
87
  );
58
88
 
59
89
  const titleElement = title ? (
60
- <InspectorPrimitives.Title
61
- $textStyle={titleTextStyle}
62
- className={titleClassName}
63
- >
90
+ <TitleShell $textStyle={titleTextStyle} className={titleClassName}>
64
91
  {title}
65
- </InspectorPrimitives.Title>
92
+ </TitleShell>
66
93
  ) : null;
67
94
 
68
95
  return (
69
- <InspectorPrimitives.SectionHeader
70
- style={headerStyle}
71
- className={className}
72
- >
96
+ <SectionHeaderShell style={headerStyle} className={className}>
73
97
  {titleIcon && (
74
98
  <div style={titleIconStyle} className={textStyles[titleTextStyle]}>
75
99
  {"\u200B"}
@@ -98,7 +122,7 @@ export function SectionHeader({
98
122
  </Text>
99
123
  )}
100
124
  {right}
101
- </InspectorPrimitives.SectionHeader>
125
+ </SectionHeaderShell>
102
126
  );
103
127
  }
104
128
 
@@ -30,7 +30,11 @@ const EXPANSION_THRESHOLD_PX = 20;
30
30
  // Debounce delay (ms) before re-enabling indicator transition after resize stops
31
31
  const RESIZE_SETTLE_MS = 150;
32
32
 
33
- type SegmentedControlColorScheme = "primary" | "secondary";
33
+ /**
34
+ * "primaryTint" is Figma-UI3-style: a light primary-tinted active segment
35
+ * with a primary-colored glyph, instead of the solid primary fill.
36
+ */
37
+ type SegmentedControlColorScheme = "primary" | "secondary" | "primaryTint";
34
38
  type SegmentedControlVariant = "default" | "tabs" | "buttons";
35
39
 
36
40
  type SegmentedControlContextValue = {
@@ -187,12 +191,16 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
187
191
  ? // default variant: only text color, background handled by sliding indicator
188
192
  colorScheme === "secondary" || colorScheme === "primary"
189
193
  ? "data-[pressed]:n-text-white"
190
- : "data-[pressed]:n-text-segmented-control-item-active-text"
194
+ : colorScheme === "primaryTint"
195
+ ? "data-[pressed]:n-text-primary"
196
+ : "data-[pressed]:n-text-segmented-control-item-active-text"
191
197
  : colorScheme === "secondary"
192
198
  ? "data-[pressed]:n-bg-secondary data-[pressed]:n-text-white"
193
199
  : colorScheme === "primary"
194
200
  ? "data-[pressed]:n-bg-primary data-[pressed]:n-text-white"
195
- : "data-[pressed]:n-bg-segmented-control-item-active-background data-[pressed]:n-text-segmented-control-item-active-text",
201
+ : colorScheme === "primaryTint"
202
+ ? "data-[pressed]:n-bg-primary-pastel data-[pressed]:n-text-primary"
203
+ : "data-[pressed]:n-bg-segmented-control-item-active-background data-[pressed]:n-text-segmented-control-item-active-text",
196
204
  "focus-visible:n-z-interactable",
197
205
  // checked default (all colorSchemes) - hide separators near selected
198
206
  variant === "default" &&
@@ -485,7 +493,9 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
485
493
  ? "n-bg-secondary"
486
494
  : colorScheme === "primary"
487
495
  ? "n-bg-primary"
488
- : "n-bg-segmented-control-item-active-background";
496
+ : colorScheme === "primaryTint"
497
+ ? "n-bg-primary-pastel"
498
+ : "n-bg-segmented-control-item-active-background";
489
499
 
490
500
  return (
491
501
  <SegmentedControlContext.Provider value={contextValue}>
@@ -39,7 +39,7 @@ function ToastContent({ toast }: { toast: ToastObject }) {
39
39
  return (
40
40
  <ToastPrimitive.Root
41
41
  toast={toast}
42
- className="n-relative n-flex n-items-start n-gap-2 n-rounded n-text-sm n-bg-popover-background n-shadow-popover n-text-text-muted n-px-3 n-py-2.5 n-pr-10"
42
+ className="n-pointer-events-auto n-relative n-flex n-items-start n-gap-2 n-rounded n-text-sm n-bg-popover-background n-shadow-popover n-text-text-muted n-px-3 n-py-2.5 n-pr-10"
43
43
  >
44
44
  <div className="n-flex n-flex-col n-gap-0.5 n-min-w-0">
45
45
  {toast.data?.title && (
@@ -87,7 +87,7 @@ function ToastProviderInner({ children }: { children: React.ReactNode }) {
87
87
  return (
88
88
  <ToastContext.Provider value={{ showToast }}>
89
89
  {children}
90
- <ToastPrimitive.Viewport className="n-fixed n-bottom-0 n-right-0 n-flex n-flex-col n-p-5 n-gap-2.5 n-min-w-[200px] n-max-w-[100vw] n-m-0 n-list-none n-z-[2147483647] n-outline-none">
90
+ <ToastPrimitive.Viewport className="n-pointer-events-none n-fixed n-bottom-0 n-right-0 n-flex n-flex-col n-p-5 n-gap-2.5 n-min-w-[200px] n-max-w-[100vw] n-m-0 n-list-none n-z-[2147483647] n-outline-none">
91
91
  {toastManager.toasts.map((toast) => (
92
92
  <ToastContent key={toast.id} toast={toast} />
93
93
  ))}
@@ -3,6 +3,16 @@
3
3
  import React, { forwardRef, HTMLAttributes } from "react";
4
4
  import { cx } from "../utils/classNames";
5
5
  import { toolbarTransparentStyle } from "./BaseToolbar";
6
+ import { ToolbarItems } from "./ToolbarItems";
7
+
8
+ export type {
9
+ ToolbarGroupItem,
10
+ ToolbarItem,
11
+ ToolbarItemsProps,
12
+ ToolbarPopoverItem,
13
+ ToolbarSeparatorItem,
14
+ } from "./ToolbarItems";
15
+ export { ToolbarItems } from "./ToolbarItems";
6
16
 
7
17
  export type ToolbarSize = "small" | "medium";
8
18
 
@@ -14,7 +24,7 @@ export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
14
24
  size?: ToolbarSize;
15
25
  }
16
26
 
17
- export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(
27
+ const ToolbarRoot = forwardRef<HTMLDivElement, ToolbarProps>(
18
28
  function Toolbar(
19
29
  { children, className, role = "toolbar", size = "medium", style, ...props },
20
30
  ref
@@ -36,3 +46,8 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(
36
46
  );
37
47
  }
38
48
  );
49
+
50
+ /** The floating toolbar surface. `Toolbar.Items` renders a declarative
51
+ * `ToolbarItem[]` (icon buttons, separators, split-button groups, popovers)
52
+ * inside it. */
53
+ export const Toolbar = Object.assign(ToolbarRoot, { Items: ToolbarItems });
@@ -0,0 +1,322 @@
1
+ "use client";
2
+
3
+ import React, { ReactNode, useEffect, useState } from "react";
4
+ import { cx } from "../utils/classNames";
5
+ import { ToolbarShortcut } from "./AppToolbar";
6
+ import { DividerVertical } from "./Divider";
7
+ import { DropdownMenu } from "./DropdownMenu";
8
+ import { renderIcon } from "./Icons";
9
+ import { MenuItemIcon, SelectableMenuItem } from "./internal/Menu";
10
+ import { Popover } from "./Popover";
11
+ import { Tooltip } from "./Tooltip";
12
+
13
+ export type ToolbarSeparatorItem = { type: "separator" };
14
+
15
+ /** A split-button slot: the primary button activates the group's
16
+ * representative tool and the adjacent chevron opens a flyout menu listing
17
+ * every item in the group. */
18
+ export type ToolbarGroupItem<T extends string> = {
19
+ type: "group";
20
+ id: string;
21
+ items: SelectableMenuItem<T>[];
22
+ };
23
+
24
+ /** A button (icon or text) that opens a Popover with arbitrary content. */
25
+ export type ToolbarPopoverItem = {
26
+ type: "popover";
27
+ id: string;
28
+ title: ReactNode;
29
+ icon?: MenuItemIcon;
30
+ content: ReactNode | ((close: () => void) => ReactNode);
31
+ };
32
+
33
+ /**
34
+ * A declarative item for `ToolbarItems` (`Toolbar.Items`). Selectable items
35
+ * reuse the menu system's `SelectableMenuItem`, so the same definitions can
36
+ * feed a toolbar, a dropdown, or a context menu.
37
+ */
38
+ export type ToolbarItem<T extends string> =
39
+ | SelectableMenuItem<T>
40
+ | ToolbarSeparatorItem
41
+ | ToolbarGroupItem<T>
42
+ | ToolbarPopoverItem;
43
+
44
+ type PopupSide = "top" | "bottom" | "left" | "right";
45
+
46
+ export interface ToolbarItemsProps<T extends string> {
47
+ items: ToolbarItem<T>[];
48
+ onSelect: (value: T) => void;
49
+ /**
50
+ * Side the group flyouts and popovers open on. Bottom-of-screen toolbars
51
+ * want "top".
52
+ * @default "top"
53
+ */
54
+ side?: PopupSide;
55
+ /** @default "top" */
56
+ tooltipSide?: PopupSide;
57
+ }
58
+
59
+ const POPUP_OFFSET = 10;
60
+
61
+ function labelForTitle(title: ReactNode): string | undefined {
62
+ return typeof title === "string" ? title : undefined;
63
+ }
64
+
65
+ /** Square icon button styled by the standard light tokens: transparent on the
66
+ * toolbar surface, `input-background-light` on hover, and the solid primary
67
+ * square with a white glyph when checked (the design system's active-tool
68
+ * treatment). */
69
+ function ToolbarItemButton<T extends string>({
70
+ item,
71
+ tooltipSide,
72
+ onClick,
73
+ }: {
74
+ item: SelectableMenuItem<T>;
75
+ tooltipSide: PopupSide;
76
+ onClick: () => void;
77
+ }) {
78
+ const label = labelForTitle(item.title);
79
+
80
+ const button = (
81
+ <button
82
+ type="button"
83
+ aria-label={label}
84
+ aria-pressed={item.checked ?? false}
85
+ disabled={item.disabled}
86
+ onClick={onClick}
87
+ className={cx(
88
+ "n-flex n-h-input-height n-flex-none n-cursor-default n-items-center n-justify-center n-rounded n-border-0 n-outline-none n-transition-colors focus-visible:n-ring-2 focus-visible:n-ring-primary",
89
+ // Icon items are square at the design system's floating-toolbar
90
+ // control size (27px, see Button variant="floating" and
91
+ // INPUT_HEIGHT); text titles get auto width with the same padding
92
+ // and type style as the popover text trigger.
93
+ item.icon
94
+ ? "n-w-input-height n-p-0"
95
+ : "n-px-2 n-py-0 n-font-sans n-text-[12px] n-font-medium",
96
+ item.checked
97
+ ? "n-bg-primary n-text-white"
98
+ : "n-bg-transparent n-text-text hover:n-bg-input-background-light",
99
+ item.disabled && "n-pointer-events-none n-opacity-30"
100
+ )}
101
+ >
102
+ {item.icon ? renderIcon(item.icon) : item.title}
103
+ </button>
104
+ );
105
+
106
+ if (item.disabled) return button;
107
+
108
+ return (
109
+ <Tooltip
110
+ side={tooltipSide}
111
+ sideOffset={POPUP_OFFSET}
112
+ content={
113
+ item.tooltip ??
114
+ (item.shortcut ? (
115
+ <ToolbarShortcut shortcut={item.shortcut} label={item.title} />
116
+ ) : (
117
+ item.title
118
+ ))
119
+ }
120
+ >
121
+ {button}
122
+ </Tooltip>
123
+ );
124
+ }
125
+
126
+ /** Returns a plain <button> element (not a component) so DropdownMenu's
127
+ * native-button trigger detection works. */
128
+ function renderGroupChevron({
129
+ label,
130
+ open,
131
+ disabled,
132
+ }: {
133
+ label: string;
134
+ open: boolean;
135
+ disabled?: boolean;
136
+ }) {
137
+ return (
138
+ <button
139
+ type="button"
140
+ aria-label={label}
141
+ disabled={disabled}
142
+ className={cx(
143
+ "n--ml-0.5 n-flex n-h-input-height n-w-3.5 n-flex-none n-cursor-default n-items-center n-justify-center n-rounded n-border-0 n-p-0 n-outline-none n-transition-colors focus-visible:n-ring-2 focus-visible:n-ring-primary",
144
+ open
145
+ ? "n-bg-input-background-light n-text-text"
146
+ : "n-bg-transparent n-text-text-muted hover:n-bg-input-background-light hover:n-text-text",
147
+ disabled && "n-pointer-events-none n-opacity-30"
148
+ )}
149
+ >
150
+ {renderIcon("DropdownChevronIcon")}
151
+ </button>
152
+ );
153
+ }
154
+
155
+ /** The split-button group: primary button shows the representative item
156
+ * (checked child > last-used child > first child) and the narrow chevron
157
+ * opens the group's items in a dropdown flyout. */
158
+ function ToolbarItemGroup<T extends string>({
159
+ group,
160
+ onSelect,
161
+ side,
162
+ tooltipSide,
163
+ }: {
164
+ group: ToolbarGroupItem<T>;
165
+ onSelect: (value: T) => void;
166
+ side: PopupSide;
167
+ tooltipSide: PopupSide;
168
+ }) {
169
+ const { items } = group;
170
+ const checkedItem = items.find((item) => item.checked);
171
+ const [lastUsed, setLastUsed] = useState<T | undefined>(undefined);
172
+
173
+ const checkedValue = checkedItem?.value;
174
+ useEffect(() => {
175
+ if (checkedValue !== undefined) setLastUsed(checkedValue);
176
+ }, [checkedValue]);
177
+
178
+ const representative =
179
+ checkedItem ?? items.find((item) => item.value === lastUsed) ?? items[0];
180
+
181
+ if (!representative) return null;
182
+
183
+ const allDisabled = items.every((item) => item.disabled);
184
+ const label = labelForTitle(representative.title);
185
+ const chevronLabel = `${label ?? "More"} tools`;
186
+
187
+ return (
188
+ <div className="n-flex n-items-center">
189
+ <ToolbarItemButton
190
+ item={{ ...representative, checked: checkedItem !== undefined }}
191
+ tooltipSide={tooltipSide}
192
+ onClick={() => {
193
+ setLastUsed(representative.value);
194
+ onSelect(representative.value);
195
+ }}
196
+ />
197
+ {allDisabled ? (
198
+ // Not wrapped in DropdownMenu (whose trigger would re-enable the
199
+ // native button) — a fully disabled group has nothing to open.
200
+ renderGroupChevron({
201
+ label: chevronLabel,
202
+ open: false,
203
+ disabled: true,
204
+ })
205
+ ) : (
206
+ <DropdownMenu
207
+ items={items}
208
+ side={side}
209
+ sideOffset={POPUP_OFFSET + 4}
210
+ // Pin the flyout's left edge to the group instead of letting it
211
+ // float centered under the narrow chevron (27px button + 2px gap).
212
+ align="start"
213
+ alignOffset={-29}
214
+ shouldBindKeyboardShortcuts={false}
215
+ onSelect={(value) => {
216
+ setLastUsed(value);
217
+ onSelect(value);
218
+ }}
219
+ >
220
+ {({ open }) =>
221
+ renderGroupChevron({
222
+ label: chevronLabel,
223
+ open,
224
+ })
225
+ }
226
+ </DropdownMenu>
227
+ )}
228
+ </div>
229
+ );
230
+ }
231
+
232
+ function ToolbarItemPopover({
233
+ item,
234
+ side,
235
+ }: {
236
+ item: ToolbarPopoverItem;
237
+ side: PopupSide;
238
+ }) {
239
+ const [open, setOpen] = useState(false);
240
+ const label = labelForTitle(item.title);
241
+
242
+ const trigger = (
243
+ <button
244
+ type="button"
245
+ aria-label={label}
246
+ className={cx(
247
+ "n-flex n-h-input-height n-flex-none n-cursor-default n-items-center n-justify-center n-rounded n-border-0 n-outline-none n-transition-colors focus-visible:n-ring-2 focus-visible:n-ring-primary",
248
+ item.icon
249
+ ? "n-w-input-height n-p-0"
250
+ : "n-px-2 n-font-sans n-text-[12px] n-font-medium",
251
+ open
252
+ ? "n-bg-input-background-light n-text-text"
253
+ : "n-bg-transparent n-text-text hover:n-bg-input-background-light"
254
+ )}
255
+ >
256
+ {item.icon ? renderIcon(item.icon) : item.title}
257
+ </button>
258
+ );
259
+
260
+ return (
261
+ <Popover
262
+ open={open}
263
+ onOpenChange={setOpen}
264
+ side={side}
265
+ showArrow={false}
266
+ sideOffset={POPUP_OFFSET}
267
+ trigger={trigger}
268
+ >
269
+ {typeof item.content === "function"
270
+ ? item.content(() => setOpen(false))
271
+ : item.content}
272
+ </Popover>
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Renders a flat list of `ToolbarItem`s inside a `Toolbar`, styled entirely
278
+ * by the standard (light) theme tokens. Also available as `Toolbar.Items`.
279
+ */
280
+ export function ToolbarItems<T extends string>({
281
+ items,
282
+ onSelect,
283
+ side = "top",
284
+ tooltipSide = "top",
285
+ }: ToolbarItemsProps<T>) {
286
+ return (
287
+ <>
288
+ {items.map((item, index) => {
289
+ if (item.type === "separator") {
290
+ return <DividerVertical key={`separator-${index}`} />;
291
+ }
292
+
293
+ if (item.type === "group") {
294
+ return (
295
+ <ToolbarItemGroup
296
+ key={item.id}
297
+ group={item}
298
+ onSelect={onSelect}
299
+ side={side}
300
+ tooltipSide={tooltipSide}
301
+ />
302
+ );
303
+ }
304
+
305
+ if (item.type === "popover") {
306
+ return (
307
+ <ToolbarItemPopover key={item.id} item={item} side={side} />
308
+ );
309
+ }
310
+
311
+ return (
312
+ <ToolbarItemButton
313
+ key={item.value}
314
+ item={item}
315
+ tooltipSide={tooltipSide}
316
+ onClick={() => onSelect(item.value)}
317
+ />
318
+ );
319
+ })}
320
+ </>
321
+ );
322
+ }
@@ -15,6 +15,9 @@ interface Props {
15
15
  sideOffset?: number;
16
16
  side?: "top" | "bottom" | "left" | "right";
17
17
  portalContainer?: HTMLElement | null;
18
+ /** Overrides the popup chrome entirely (e.g. a dark chip over a dark
19
+ * toolbar). The default light surface is used when omitted. */
20
+ contentClassName?: string;
18
21
  }
19
22
 
20
23
  export const Tooltip = React.memo(function Tooltip({
@@ -23,6 +26,7 @@ export const Tooltip = React.memo(function Tooltip({
23
26
  sideOffset = 2,
24
27
  side = "bottom",
25
28
  portalContainer,
29
+ contentClassName,
26
30
  }: Props) {
27
31
  const portalScopeId = usePortalScopeId();
28
32
 
@@ -44,7 +48,10 @@ export const Tooltip = React.memo(function Tooltip({
44
48
  className="n-z-menu"
45
49
  >
46
50
  <TooltipPrimitive.Popup
47
- className={`${textStyles.small} n-text-text n-rounded-sm n-p-2 n-bg-popover-background n-shadow-tooltip n-z-menu`}
51
+ className={
52
+ contentClassName ??
53
+ `${textStyles.small} n-text-text n-rounded-sm n-p-2 n-bg-popover-background n-shadow-tooltip n-z-menu`
54
+ }
48
55
  >
49
56
  {content}
50
57
  </TooltipPrimitive.Popup>
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getCheckedMenuItemAccessibilityProps } from "../DropdownMenu";
3
+
4
+ describe("DropdownMenu checked items", () => {
5
+ test("uses checkbox menu semantics when checked state is provided", () => {
6
+ expect(getCheckedMenuItemAccessibilityProps(false)).toEqual({
7
+ role: "menuitemcheckbox",
8
+ "aria-checked": false,
9
+ });
10
+ expect(getCheckedMenuItemAccessibilityProps(true)).toEqual({
11
+ role: "menuitemcheckbox",
12
+ "aria-checked": true,
13
+ });
14
+ });
15
+
16
+ test("leaves ordinary menu items unchanged", () => {
17
+ expect(getCheckedMenuItemAccessibilityProps(undefined)).toEqual({});
18
+ });
19
+ });