@noya-app/noya-designsystem 0.1.94 → 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 (40) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +10 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +158 -93
  5. package/dist/index.d.ts +158 -93
  6. package/dist/index.js +1307 -1034
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1231 -962
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -4
  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 +23 -8
  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/Toolbar.tsx +16 -1
  26. package/src/components/ToolbarItems.tsx +322 -0
  27. package/src/components/Tooltip.tsx +8 -1
  28. package/src/components/__tests__/ToolbarItems.test.tsx +205 -0
  29. package/src/components/internal/Menu.tsx +4 -1
  30. package/src/components/internal/TextInput.tsx +43 -2
  31. package/src/components/internal/__tests__/TextInput.test.tsx +77 -0
  32. package/src/components/listView/ListViewEditableRowTitle.tsx +37 -1
  33. package/src/components/listView/ListViewRow.tsx +3 -0
  34. package/src/components/listView/__tests__/ListViewEditableRowTitle.test.tsx +69 -0
  35. package/src/components/sorting/Sortable.tsx +4 -1
  36. package/src/index.tsx +0 -1
  37. package/src/utils/combobox.ts +22 -5
  38. package/src/utils/fuzzyScorer.ts +71 -0
  39. package/tailwind.config.ts +7 -0
  40. 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.94",
3
+ "version": "0.1.95",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -29,11 +29,11 @@
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}
@@ -347,7 +360,7 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
347
360
  const isNativeButton =
348
361
  React.isValidElement(childContent) &&
349
362
  (childContent.type === "button" ||
350
- (childContent.type === Button &&
363
+ ((childContent.type === Button || childContent.type === IconButton) &&
351
364
  (childContent.props as ButtonProps).as !== "a"));
352
365
 
353
366
  return (
@@ -403,12 +416,14 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
403
416
  className="n-z-menu n-outline-none"
404
417
  >
405
418
  <Menu.Popup className={styles.contentStyle} style={contentStyles}>
406
- {items.length === 0 ? emptyState : null}
407
- <MenuViewport
408
- items={items}
409
- onSelect={onSelect}
410
- hasCheckedItem={hasCheckedItem}
411
- />
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>
412
427
  </Menu.Popup>
413
428
  </Menu.Positioner>
414
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 ? (
@@ -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 ? (