@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.93",
3
+ "version": "0.1.95",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -25,15 +25,15 @@
25
25
  "lint": "eslint . --max-warnings 0"
26
26
  },
27
27
  "dependencies": {
28
- "@base-ui/react": "1.0.0",
28
+ "@base-ui/react": "1.7.0",
29
29
  "@dnd-kit/core": "6.3.1",
30
30
  "@dnd-kit/sortable": "10.0.0",
31
31
  "@floating-ui/react": "^0.27.0",
32
- "@noya-app/noya-colorpicker": "0.1.36",
32
+ "@noya-app/noya-colorpicker": "0.1.37",
33
33
  "@noya-app/noya-utils": "0.1.10",
34
34
  "@noya-app/noya-geometry": "0.1.19",
35
- "@noya-app/noya-icons": "0.1.20",
36
- "@noya-app/noya-keymap": "0.1.4",
35
+ "@noya-app/noya-icons": "0.1.21",
36
+ "@noya-app/noya-keymap": "0.1.5",
37
37
  "@noya-app/noya-tailwind-config": "0.1.10",
38
38
  "@radix-ui/react-compose-refs": "^1.0.0",
39
39
  "@radix-ui/primitive": "^1.0.1",
@@ -2,38 +2,81 @@ import type { PointerSensorProps } from "@dnd-kit/core";
2
2
  import { describe, expect, test } from "bun:test";
3
3
  import { ContinuousPointerSensor } from "../components/sorting/SharedDragProvider";
4
4
 
5
- describe("ContinuousPointerSensor", () => {
6
- test("forwards the movement that activates a distance-constrained drag", () => {
7
- const target = document.createElement("div");
8
- document.body.appendChild(target);
9
- const pointerDown = new PointerEvent("pointerdown", {
10
- clientX: 10,
11
- clientY: 20,
12
- });
13
- target.dispatchEvent(pointerDown);
5
+ /**
6
+ * When a dnd-kit pointer sensor activates, it adds a document-level CAPTURE
7
+ * listener that calls stopPropagation on every "click", and detach() only
8
+ * removes it on a 50ms setTimeout. Waiting that window out keeps the
9
+ * suppression from leaking into whatever test dispatches clicks next (it
10
+ * silently breaks fireEvent.click across suite boundaries otherwise).
11
+ */
12
+ function waitForDndKitDocumentListenerCleanup() {
13
+ return new Promise((resolve) => setTimeout(resolve, 60));
14
+ }
15
+
16
+ function simulateDrag(events: string[]) {
17
+ const target = document.createElement("div");
18
+ document.body.appendChild(target);
19
+ const pointerDown = new PointerEvent("pointerdown", {
20
+ clientX: 10,
21
+ clientY: 20,
22
+ });
23
+ target.dispatchEvent(pointerDown);
14
24
 
25
+ const props = {
26
+ active: "item",
27
+ activeNode: {},
28
+ context: { current: {} },
29
+ event: pointerDown,
30
+ options: { activationConstraint: { distance: 4 } },
31
+ onAbort: () => {},
32
+ onPending: () => {},
33
+ onStart: () => {
34
+ events.push("start");
35
+ },
36
+ onMove: ({ x, y }: { x: number; y: number }) => {
37
+ events.push(`move:${x},${y}`);
38
+ },
39
+ onEnd: () => {
40
+ events.push("end");
41
+ },
42
+ onCancel: () => {},
43
+ } as unknown as PointerSensorProps;
44
+
45
+ new ContinuousPointerSensor(props);
46
+ document.dispatchEvent(
47
+ new PointerEvent("pointermove", { clientX: 30, clientY: 40 })
48
+ );
49
+ document.dispatchEvent(new PointerEvent("pointerup"));
50
+ target.remove();
51
+ }
52
+
53
+ describe("ContinuousPointerSensor", () => {
54
+ test("forwards the movement that activates a distance-constrained drag", async () => {
15
55
  const events: string[] = [];
16
- const props = {
17
- active: "item",
18
- activeNode: {},
19
- context: { current: {} },
20
- event: pointerDown,
21
- options: { activationConstraint: { distance: 4 } },
22
- onAbort: () => {},
23
- onPending: () => {},
24
- onStart: () => events.push("start"),
25
- onMove: ({ x, y }) => events.push(`move:${x},${y}`),
26
- onEnd: () => events.push("end"),
27
- onCancel: () => {},
28
- } as PointerSensorProps;
29
-
30
- new ContinuousPointerSensor(props);
31
- document.dispatchEvent(
32
- new PointerEvent("pointermove", { clientX: 30, clientY: 40 })
33
- );
34
- document.dispatchEvent(new PointerEvent("pointerup"));
56
+ simulateDrag(events);
35
57
 
36
58
  expect(events).toEqual(["start", "move:30,40", "end"]);
37
- target.remove();
59
+
60
+ await waitForDndKitDocumentListenerCleanup();
61
+ });
62
+
63
+ test("clicks propagate again after the drag's deferred cleanup", async () => {
64
+ simulateDrag([]);
65
+ await waitForDndKitDocumentListenerCleanup();
66
+
67
+ // Regression guard: an activated drag suppresses click propagation at the
68
+ // document level; after the deferred cleanup, clicks must bubble again.
69
+ const parent = document.createElement("div");
70
+ const button = document.createElement("button");
71
+ parent.appendChild(button);
72
+ document.body.appendChild(parent);
73
+ let bubbled = 0;
74
+ parent.addEventListener("click", () => {
75
+ bubbled++;
76
+ });
77
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
78
+ parent.remove();
79
+
80
+ expect(bubbled).toBe(1);
38
81
  });
39
82
  });
@@ -1,5 +1,10 @@
1
1
  import { expect, test } from "bun:test";
2
- import { fuzzyFilter, fuzzyScore, fuzzyTokenize } from "../utils/fuzzyScorer";
2
+ import {
3
+ fuzzyFilter,
4
+ fuzzyScore,
5
+ fuzzyTokenize,
6
+ wordPrefixFilter,
7
+ } from "../utils/fuzzyScorer";
3
8
 
4
9
  test("prefix match", () => {
5
10
  const result = fuzzyScore({ item: "font-bold", query: "font" });
@@ -83,3 +88,46 @@ test("tokenizes non-contiguous match", () => {
83
88
  { type: "text", text: "old" },
84
89
  ]);
85
90
  });
91
+
92
+ test("wordPrefixFilter ranks exact > prefix > word-start and drops mid-word substrings", () => {
93
+ const families = [
94
+ "Almendra Display",
95
+ "Big Shoulders Display",
96
+ "DM Serif Display",
97
+ "Gilda Display",
98
+ "Play",
99
+ "Playball",
100
+ "Playfair Display",
101
+ ];
102
+
103
+ const result = wordPrefixFilter({ items: families, query: "Play" });
104
+ const names = result.map(({ index }) => families[index]);
105
+
106
+ // Mid-word substring matches ("* Display") are excluded entirely because
107
+ // word-boundary matches exist.
108
+ expect(names).toEqual(["Play", "Playball", "Playfair Display"]);
109
+ });
110
+
111
+ test("wordPrefixFilter matches at interior word starts with highlight ranges", () => {
112
+ const families = ["DM Serif Display", "Sans Serif", "Monoserif"];
113
+
114
+ const result = wordPrefixFilter({ items: families, query: "serif" });
115
+ const names = result.map(({ index }) => families[index]);
116
+
117
+ expect(names).toEqual(["DM Serif Display", "Sans Serif"]);
118
+ expect(result[0].labelMatch).toEqual([{ start: 3, end: 8 }]);
119
+ });
120
+
121
+ test("wordPrefixFilter falls back to substrings only when nothing matches a word boundary", () => {
122
+ const families = ["Almendra Display", "Gilda Display", "Play"];
123
+
124
+ const result = wordPrefixFilter({ items: families, query: "lay" });
125
+ const names = result.map(({ index }) => families[index]);
126
+
127
+ expect(names).toEqual(["Almendra Display", "Gilda Display", "Play"]);
128
+ });
129
+
130
+ test("wordPrefixFilter with empty query returns all items unscored", () => {
131
+ const result = wordPrefixFilter({ items: ["A", "B"], query: " " });
132
+ expect(result.map(({ index }) => index)).toEqual([0, 1]);
133
+ });
@@ -103,7 +103,7 @@ export const Button = forwardRef(function Button(
103
103
  const colorScheme = colorSchemeProp ?? "default";
104
104
 
105
105
  const baseClassName = cx(
106
- "n-no-underline n-leading-[1] n-relative n-outline-none n-select-none n-text-left n-rounded n-flex n-items-center n-justify-center [&>*]:n-pointer-events-none n-[-webkit-app-region:no-drag] focus:n-ring-2 focus:n-ring-primary focus:n-shadow-[0_0_0_1px_var(--n-popover-background)_inset] n-transition-all",
106
+ "n-no-underline n-leading-[1] n-relative n-outline-none n-select-none n-text-left n-rounded n-flex n-items-center n-justify-center [&>*]:n-pointer-events-none n-[-webkit-app-region:no-drag] 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",
107
107
  variant === "ghost" ? "n-border" : "n-border-0",
108
108
  disabled ? "n-opacity-25" : "",
109
109
  !className?.includes("flex") ? "n-flex-none" : "",
@@ -14,7 +14,11 @@ import React, {
14
14
  } from "react";
15
15
  import { useLabel } from "../hooks/useLabel";
16
16
  import { cx } from "../utils/classNames";
17
- import { ComboboxState, useComboboxState } from "../utils/combobox";
17
+ import {
18
+ ComboboxFilterStrategy,
19
+ ComboboxState,
20
+ useComboboxState,
21
+ } from "../utils/combobox";
18
22
  import { ActivityIndicator } from "./ActivityIndicator";
19
23
  import { ComboboxMenu } from "./ComboboxMenu";
20
24
  import { renderIcon } from "./Icons";
@@ -48,6 +52,16 @@ export type BaseComboboxProps<T extends string> = {
48
52
  tabBehavior?: "autocomplete" | "select";
49
53
  openMenuBehavior?: "showAllItems" | "showFilteredItems";
50
54
  iconPosition?: "left" | "right";
55
+ /** Wraps each menu item's label (receives the default highlighted content) */
56
+ renderItemContent?: (
57
+ item: ScoredMenuItem<T>,
58
+ defaultContent: React.ReactNode
59
+ ) => React.ReactNode;
60
+ /**
61
+ * How filter text matches item titles ("fuzzy" default; "wordPrefix" for
62
+ * name lists). Fixed at mount.
63
+ */
64
+ filterStrategy?: ComboboxFilterStrategy;
51
65
  };
52
66
 
53
67
  export type StringModeOnlyProps = {
@@ -105,6 +119,8 @@ export const Combobox = memoGeneric(
105
119
  onFocus,
106
120
  onDeleteWhenEmpty,
107
121
  iconPosition = "right",
122
+ renderItemContent,
123
+ filterStrategy = "fuzzy",
108
124
  ...rest
109
125
  }: ComboboxProps<T>,
110
126
  forwardedRef: ForwardedRef<ComboboxRef<T>>
@@ -170,7 +186,8 @@ export const Combobox = memoGeneric(
170
186
  new ComboboxState(
171
187
  items,
172
188
  initialValueString,
173
- props.mode === "string" && (props.allowArbitraryValues ?? false)
189
+ props.mode === "string" && (props.allowArbitraryValues ?? false),
190
+ filterStrategy
174
191
  )
175
192
  );
176
193
  const state = useComboboxState(comboboxState);
@@ -525,7 +542,9 @@ export const Combobox = memoGeneric(
525
542
  menuItemsCount,
526
543
  sectionHeaderCount,
527
544
  tallMenuItemsCount,
528
- maxBaseRows: 10.5,
545
+ // Whole rows only: a half-cut last row reads as a rendering bug
546
+ // rather than a scrollable list.
547
+ maxBaseRows: 10,
529
548
  });
530
549
 
531
550
  const adjustedListSize = { width, height: computedHeight };
@@ -546,6 +565,7 @@ export const Combobox = memoGeneric(
546
565
  onHoverIndex={handleIndexChange}
547
566
  listSize={adjustedListSize}
548
567
  iconPosition={iconPosition}
568
+ renderItemContent={renderItemContent}
549
569
  style={{
550
570
  flex: `0 0 ${computedHeight}px`,
551
571
  }}
@@ -34,6 +34,15 @@ interface ComboboxMenuProps<T extends string> {
34
34
  itemHeight?: number;
35
35
  /** Optional per-item height resolver */
36
36
  getRowHeightForItem?: (item: ScoredMenuItem<T>, index: number) => number;
37
+ /**
38
+ * Wraps an item's rendered label (e.g. to style a font family's row in its
39
+ * own typeface). Receives the default label content, which includes the
40
+ * fuzzy-match highlighting.
41
+ */
42
+ renderItemContent?: (
43
+ item: ScoredMenuItem<T>,
44
+ defaultContent: React.ReactNode
45
+ ) => React.ReactNode;
37
46
  }
38
47
 
39
48
  export const ComboboxMenu = memoGeneric(
@@ -49,6 +58,7 @@ export const ComboboxMenu = memoGeneric(
49
58
  iconPosition = "right",
50
59
  itemHeight,
51
60
  getRowHeightForItem,
61
+ renderItemContent,
52
62
  }: ComboboxMenuProps<T>,
53
63
  forwardedRef: ForwardedRef<IVirtualizedList>
54
64
  ) {
@@ -127,21 +137,28 @@ export const ComboboxMenu = memoGeneric(
127
137
  </>
128
138
  )}
129
139
  <div className="n-flex n-flex-col n-min-w-0 n-flex-1">
130
- <div className="n-min-w-0 n-truncate">
131
- {tokens.map((token, j) => (
132
- <span
133
- key={`${item.value}-token-${j}`}
134
- className={cx(
135
- token.type === "match"
136
- ? "n-font-bold"
137
- : "n-font-normal",
138
- "n-whitespace-pre n-truncate"
139
- )}
140
- >
141
- {token.text}
142
- </span>
143
- ))}
144
- </div>
140
+ {(() => {
141
+ const defaultContent = (
142
+ <div className="n-min-w-0 n-truncate">
143
+ {tokens.map((token, j) => (
144
+ <span
145
+ key={`${item.value}-token-${j}`}
146
+ className={cx(
147
+ token.type === "match"
148
+ ? "n-font-bold"
149
+ : "n-font-normal",
150
+ "n-whitespace-pre n-truncate"
151
+ )}
152
+ >
153
+ {token.text}
154
+ </span>
155
+ ))}
156
+ </div>
157
+ );
158
+ return renderItemContent
159
+ ? renderItemContent(item, defaultContent)
160
+ : defaultContent;
161
+ })()}
145
162
  {tooltip && (
146
163
  <Small color="textSubtle" className="n-truncate">
147
164
  {tooltip}
@@ -26,6 +26,11 @@ import {
26
26
  import { Spacer } from "./Spacer";
27
27
  import { cloneElementWithMergedProps } from "../utils/mergeProps";
28
28
 
29
+ /** Carries the root menu's contentStyle into submenu popups (separate portals). */
30
+ const ContextMenuContentStyleContext = React.createContext<
31
+ React.CSSProperties | undefined
32
+ >(undefined);
33
+
29
34
  export interface MenuItemProps<T extends string> {
30
35
  value?: T;
31
36
  children: ReactNode;
@@ -114,6 +119,9 @@ function SubmenuItem<T extends string>({
114
119
  portalScopeId: string | undefined;
115
120
  }) {
116
121
  const [open, setOpen] = useState(false);
122
+ const inheritedContentStyle = React.useContext(
123
+ ContextMenuContentStyleContext
124
+ );
117
125
 
118
126
  const submenuHasCheckedItem = item.items.some(
119
127
  (subItem) => isSelectableMenuItem(subItem) && subItem.checked
@@ -151,7 +159,10 @@ function SubmenuItem<T extends string>({
151
159
  alignOffset={-5}
152
160
  className="n-z-menu n-outline-none"
153
161
  >
154
- <BaseContextMenu.Popup className={styles.contentStyle}>
162
+ <BaseContextMenu.Popup
163
+ className={styles.contentStyle}
164
+ style={inheritedContentStyle}
165
+ >
155
166
  <ContextMenuViewport
156
167
  items={item.items}
157
168
  onSelect={onSelect}
@@ -253,6 +264,8 @@ export interface MenuProps<T extends string> {
253
264
  onSelect: (value: T) => void;
254
265
  shouldBindKeyboardShortcuts?: boolean;
255
266
  onOpenChange?: (open: boolean) => void;
267
+ /** Extra styles (e.g. CSS variable overrides) for the menu surface. */
268
+ contentStyle?: React.CSSProperties;
256
269
  }
257
270
 
258
271
  function ContextMenuRoot<T extends string>({
@@ -261,6 +274,7 @@ function ContextMenuRoot<T extends string>({
261
274
  onSelect,
262
275
  shouldBindKeyboardShortcuts,
263
276
  onOpenChange,
277
+ contentStyle,
264
278
  }: MenuProps<T>) {
265
279
  const keymap = useMemo(
266
280
  () =>
@@ -305,12 +319,17 @@ function ContextMenuRoot<T extends string>({
305
319
  {...portalScopeProps(portalScopeId)}
306
320
  className="n-z-menu n-outline-none"
307
321
  >
308
- <BaseContextMenu.Popup className={styles.contentStyle}>
309
- <ContextMenuViewport
310
- items={items}
311
- onSelect={handleSelectItem}
312
- hasCheckedItem={hasCheckedItem}
313
- />
322
+ <BaseContextMenu.Popup
323
+ className={styles.contentStyle}
324
+ style={contentStyle}
325
+ >
326
+ <ContextMenuContentStyleContext.Provider value={contentStyle}>
327
+ <ContextMenuViewport
328
+ items={items}
329
+ onSelect={handleSelectItem}
330
+ hasCheckedItem={hasCheckedItem}
331
+ />
332
+ </ContextMenuContentStyleContext.Provider>
314
333
  </BaseContextMenu.Popup>
315
334
  </BaseContextMenu.Positioner>
316
335
  </BaseContextMenu.Portal>
@@ -3,6 +3,7 @@
3
3
  import { round } from "@noya-app/noya-utils";
4
4
  import * as React from "react";
5
5
  import { InputField } from "./InputField";
6
+ import { Label } from "./Label";
6
7
  import { LabeledField } from "./LabeledField";
7
8
 
8
9
  export type SetNumberMode = "replace" | "adjust";
@@ -30,6 +31,8 @@ interface Props {
30
31
  placeholder?: string;
31
32
  disabled?: boolean;
32
33
  trigger?: "change" | "submit";
34
+ /** Unit suffix rendered inside the field's right edge (e.g. "px", "%"). */
35
+ suffix?: string;
33
36
  }
34
37
 
35
38
  export const DimensionInput = React.memo(function DimensionInput({
@@ -38,9 +41,11 @@ export const DimensionInput = React.memo(function DimensionInput({
38
41
  onSetValue,
39
42
  label,
40
43
  size,
41
- placeholder = "multi",
44
+ // Figma shows "Mixed" in numeric fields with differing values
45
+ placeholder = "Mixed",
42
46
  disabled,
43
47
  trigger = "submit",
48
+ suffix,
44
49
  }: Props) {
45
50
  const handleNudgeValue = React.useCallback(
46
51
  (value: number) => onSetValue(value, "adjust"),
@@ -54,12 +59,17 @@ export const DimensionInput = React.memo(function DimensionInput({
54
59
 
55
60
  return (
56
61
  <LabeledField label={label} labelType="inset">
57
- <InputField.Root id={id} width={size}>
62
+ <InputField.Root
63
+ id={id}
64
+ width={size}
65
+ end={suffix ? <Label>{suffix}</Label> : undefined}
66
+ >
58
67
  <InputField.NumberInput
59
68
  value={value === undefined ? value : round(value, 2)}
60
69
  placeholder={value === undefined ? placeholder : undefined}
61
70
  onNudge={handleNudgeValue}
62
71
  disabled={disabled}
72
+ style={suffix ? { paddingRight: 4 } : undefined}
63
73
  {...(trigger === "change"
64
74
  ? { onChange: handleSetValue }
65
75
  : { onSubmit: handleSetValue })}
@@ -21,6 +21,7 @@ import {
21
21
  } from "../contexts/PortalScopeContext";
22
22
  import { cx } from "../utils/classNames";
23
23
  import { Button, ButtonProps } from "./Button";
24
+ import { IconButton } from "./IconButton";
24
25
  import { MenuProps } from "./ContextMenu";
25
26
  import { Icon, IconName } from "./Icons";
26
27
  import {
@@ -37,6 +38,14 @@ import {
37
38
  } from "./internal/Menu";
38
39
  import { Spacer } from "./Spacer";
39
40
 
41
+ /**
42
+ * Carries the root menu's contentStyle into submenu popups (they render in
43
+ * separate portals, so CSS variable overrides don't cascade to them).
44
+ */
45
+ const MenuContentStyleContext = React.createContext<
46
+ React.CSSProperties | undefined
47
+ >(undefined);
48
+
40
49
  /* ----------------------------------------------------------------------------
41
50
  * Root
42
51
  * ------------------------------------------------------------------------- */
@@ -134,6 +143,7 @@ function SubmenuItem<T extends string>({
134
143
  portalScopeId: string | undefined;
135
144
  }) {
136
145
  const [open, setOpen] = useState(false);
146
+ const inheritedContentStyle = React.useContext(MenuContentStyleContext);
137
147
 
138
148
  const submenuHasCheckedItem = item.items.some(
139
149
  (subItem) => isSelectableMenuItem(subItem) && subItem.checked
@@ -176,7 +186,10 @@ function SubmenuItem<T extends string>({
176
186
  alignOffset={-5}
177
187
  className="n-z-menu n-outline-none"
178
188
  >
179
- <Menu.Popup className={styles.contentStyle}>
189
+ <Menu.Popup
190
+ className={styles.contentStyle}
191
+ style={inheritedContentStyle}
192
+ >
180
193
  <MenuViewport
181
194
  items={item.items}
182
195
  onSelect={onSelect}
@@ -236,12 +249,16 @@ function MenuViewport<T extends string>({
236
249
  }
237
250
  return null;
238
251
 
239
- default:
252
+ default: {
253
+ const accessibilityProps = getCheckedMenuItemAccessibilityProps(
254
+ item.checked
255
+ );
240
256
  return (
241
257
  <div key={item.value} className="n-px-1">
242
258
  <Menu.Item
243
259
  disabled={item.disabled}
244
260
  className={itemStyle}
261
+ {...accessibilityProps}
245
262
  onClick={() => {
246
263
  if (onSelect && item.value) {
247
264
  // Delay onSelect to allow menu to close and focus to settle
@@ -262,12 +279,21 @@ function MenuViewport<T extends string>({
262
279
  </Menu.Item>
263
280
  </div>
264
281
  );
282
+ }
265
283
  }
266
284
  })}
267
285
  </>
268
286
  );
269
287
  }
270
288
 
289
+ export function getCheckedMenuItemAccessibilityProps(
290
+ checked: boolean | undefined
291
+ ) {
292
+ return checked === undefined
293
+ ? {}
294
+ : ({ role: "menuitemcheckbox", "aria-checked": checked } as const);
295
+ }
296
+
271
297
  const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
272
298
  T extends string,
273
299
  >(props: DropdownMenuProps<T>, forwardedRef: ForwardedRef<HTMLElement>) {
@@ -334,7 +360,7 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
334
360
  const isNativeButton =
335
361
  React.isValidElement(childContent) &&
336
362
  (childContent.type === "button" ||
337
- (childContent.type === Button &&
363
+ ((childContent.type === Button || childContent.type === IconButton) &&
338
364
  (childContent.props as ButtonProps).as !== "a"));
339
365
 
340
366
  return (
@@ -390,12 +416,14 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
390
416
  className="n-z-menu n-outline-none"
391
417
  >
392
418
  <Menu.Popup className={styles.contentStyle} style={contentStyles}>
393
- {items.length === 0 ? emptyState : null}
394
- <MenuViewport
395
- items={items}
396
- onSelect={onSelect}
397
- hasCheckedItem={hasCheckedItem}
398
- />
419
+ <MenuContentStyleContext.Provider value={contentStyle}>
420
+ {items.length === 0 ? emptyState : null}
421
+ <MenuViewport
422
+ items={items}
423
+ onSelect={onSelect}
424
+ hasCheckedItem={hasCheckedItem}
425
+ />
426
+ </MenuContentStyleContext.Provider>
399
427
  </Menu.Popup>
400
428
  </Menu.Positioner>
401
429
  </Menu.Portal>
@@ -666,6 +666,7 @@ function InputFieldRoot({
666
666
  initialFocus={false}
667
667
  sideOffset={sideOffset}
668
668
  showArrow={false}
669
+ nativeButtonTrigger={false}
669
670
  >
670
671
  {measuredWidthObject && (
671
672
  <div
@@ -12,6 +12,7 @@ export const InspectorContainer = memo(
12
12
  header,
13
13
  children,
14
14
  fallback,
15
+ footer,
15
16
  showDividers,
16
17
  id,
17
18
  className,
@@ -20,6 +21,9 @@ export const InspectorContainer = memo(
20
21
  header?: React.ReactNode;
21
22
  children?: React.ReactNode;
22
23
  fallback?: React.ReactNode;
24
+ /** Pinned to the bottom edge when content is short; scrolls after the
25
+ * content when it overflows (pushed, not sticky). */
26
+ footer?: React.ReactNode;
23
27
  showDividers?: boolean;
24
28
  id?: string;
25
29
  className?: string;
@@ -43,10 +47,21 @@ export const InspectorContainer = memo(
43
47
  {header}
44
48
  {children ? (
45
49
  <ScrollArea className="n-flex-1 n-min-h-0">
46
- <div className="n-flex n-flex-col n-relative">
50
+ <div
51
+ className="n-flex n-flex-col n-relative"
52
+ // Fill the viewport so a footer can be pushed to the bottom
53
+ // edge when the content is short.
54
+ style={footer != null ? { minHeight: "100%" } : undefined}
55
+ >
47
56
  {showDividers
48
57
  ? withSeparatorElements(children, <Divider />)
49
58
  : children}
59
+ {footer != null && (
60
+ <>
61
+ <div className="n-flex-1" />
62
+ {footer}
63
+ </>
64
+ )}
50
65
  </div>
51
66
  </ScrollArea>
52
67
  ) : fallback ? (