@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
@@ -0,0 +1,138 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import React from "react";
3
+
4
+ const actualIcons = await import("@radix-ui/react-icons");
5
+
6
+ mock.module("@radix-ui/react-icons", () => ({
7
+ ...actualIcons,
8
+ Cross1Icon: (props: React.SVGProps<SVGSVGElement>) => (
9
+ <svg {...props} data-testid="cross-icon" />
10
+ ),
11
+ }));
12
+
13
+ const { act, cleanup, fireEvent, render, waitFor } = await import(
14
+ "@testing-library/react"
15
+ );
16
+ const { ToastProvider, useToast } = await import("../Toast");
17
+
18
+ afterEach(cleanup);
19
+
20
+ function ToastFixture({ onAction }: { onAction?: () => void }) {
21
+ const { showToast } = useToast();
22
+
23
+ return (
24
+ <button
25
+ onClick={() =>
26
+ showToast({
27
+ title: "Saved",
28
+ content: "Your changes were saved.",
29
+ action: onAction ? (
30
+ <button onClick={onAction}>Undo</button>
31
+ ) : undefined,
32
+ timeout: 60_000,
33
+ })
34
+ }
35
+ >
36
+ Show toast
37
+ </button>
38
+ );
39
+ }
40
+
41
+ function renderToast(onAction?: () => void) {
42
+ const view = render(
43
+ <ToastProvider>
44
+ <ToastFixture onAction={onAction} />
45
+ </ToastProvider>
46
+ );
47
+
48
+ fireEvent.click(view.getByRole("button", { name: "Show toast" }));
49
+
50
+ return view;
51
+ }
52
+
53
+ describe("ToastProvider pointer events", () => {
54
+ test("keeps the viewport pointer-transparent when no toast exists", () => {
55
+ const view = render(
56
+ <ToastProvider>
57
+ <button>Underlying control</button>
58
+ </ToastProvider>
59
+ );
60
+ const viewport = view.getByRole("region", { name: "Notifications" });
61
+
62
+ expect(viewport.classList.contains("n-pointer-events-none")).toBe(true);
63
+ expect(view.queryByRole("dialog")).toBeNull();
64
+ });
65
+
66
+ test("makes toast roots interactive inside the transparent viewport", () => {
67
+ const view = renderToast();
68
+ const viewport = view.getByRole("region", { name: "Notifications" });
69
+ const toast = view.getByRole("dialog");
70
+
71
+ expect(viewport.classList.contains("n-pointer-events-none")).toBe(true);
72
+ expect(toast.classList.contains("n-pointer-events-auto")).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe("ToastProvider interactions", () => {
77
+ test("preserves action and close button interactions", async () => {
78
+ const onAction = mock(() => {});
79
+ const view = renderToast(onAction);
80
+
81
+ fireEvent.click(view.getByRole("button", { name: "Undo" }));
82
+ expect(onAction).toHaveBeenCalledTimes(1);
83
+
84
+ fireEvent.click(view.getByLabelText("Close"));
85
+ await waitFor(() => {
86
+ expect(view.queryByText("Your changes were saved.")).toBeNull();
87
+ });
88
+ });
89
+
90
+ test("preserves keyboard dismissal", async () => {
91
+ const view = renderToast();
92
+ const toast = view.getByRole("dialog");
93
+
94
+ act(() => toast.focus());
95
+ fireEvent.keyDown(toast, { key: "Escape" });
96
+
97
+ await waitFor(() => {
98
+ expect(view.queryByText("Your changes were saved.")).toBeNull();
99
+ });
100
+ });
101
+
102
+ test("preserves swipe dismissal", async () => {
103
+ const view = renderToast();
104
+ const toast = view.getByRole("dialog");
105
+
106
+ fireEvent.pointerDown(toast, {
107
+ button: 0,
108
+ clientX: 0,
109
+ clientY: 0,
110
+ pointerId: 1,
111
+ pointerType: "touch",
112
+ });
113
+ fireEvent.pointerMove(toast, {
114
+ clientX: 1,
115
+ clientY: 0,
116
+ pointerId: 1,
117
+ pointerType: "touch",
118
+ });
119
+ fireEvent.pointerMove(toast, {
120
+ clientX: 60,
121
+ clientY: 0,
122
+ movementX: 59,
123
+ movementY: 0,
124
+ pointerId: 1,
125
+ pointerType: "touch",
126
+ });
127
+ fireEvent.pointerUp(toast, {
128
+ clientX: 60,
129
+ clientY: 0,
130
+ pointerId: 1,
131
+ pointerType: "touch",
132
+ });
133
+
134
+ await waitFor(() => {
135
+ expect(view.queryByText("Your changes were saved.")).toBeNull();
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import React from "react";
3
+ import type { ToolbarItem } from "../ToolbarItems";
4
+
5
+ // `@radix-ui/react-icons` has a nested React 18 copy in node_modules, which
6
+ // creates legacy React elements that React 19 refuses to render under
7
+ // `bun test` (app bundlers alias react, so this only affects tests).
8
+ const actualIcons = await import("@radix-ui/react-icons");
9
+
10
+ mock.module("@radix-ui/react-icons", () => ({
11
+ ...Object.fromEntries(
12
+ Object.keys(actualIcons).map((name) => [
13
+ name,
14
+ (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
15
+ ])
16
+ ),
17
+ }));
18
+
19
+ const { cleanup, fireEvent, render } = await import("@testing-library/react");
20
+ const { Toolbar } = await import("../Toolbar");
21
+ const { ToolbarItems } = await import("../ToolbarItems");
22
+
23
+ afterEach(cleanup);
24
+
25
+ type Tool = "select" | "hand" | "rectangle" | "oval" | "text";
26
+
27
+ const makeItems = (checked?: Tool): ToolbarItem<Tool>[] => [
28
+ {
29
+ type: "group",
30
+ id: "move",
31
+ items: [
32
+ {
33
+ title: "Move",
34
+ value: "select",
35
+ icon: "CursorArrowIcon",
36
+ shortcut: "V",
37
+ checked: checked === "select",
38
+ },
39
+ {
40
+ title: "Hand",
41
+ value: "hand",
42
+ icon: "HandIcon2",
43
+ shortcut: "H",
44
+ checked: checked === "hand",
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ type: "group",
50
+ id: "shape",
51
+ items: [
52
+ {
53
+ title: "Rectangle",
54
+ value: "rectangle",
55
+ icon: "SquareIcon",
56
+ shortcut: "R",
57
+ checked: checked === "rectangle",
58
+ },
59
+ {
60
+ title: "Ellipse",
61
+ value: "oval",
62
+ icon: "CircleIcon",
63
+ checked: checked === "oval",
64
+ },
65
+ ],
66
+ },
67
+ { type: "separator" },
68
+ {
69
+ title: "Text",
70
+ value: "text",
71
+ icon: "TextIcon",
72
+ shortcut: "T",
73
+ checked: checked === "text",
74
+ },
75
+ ];
76
+
77
+ describe("ToolbarItems", () => {
78
+ test("renders selectable items, groups, and separators", () => {
79
+ const { getByRole, container } = render(
80
+ <Toolbar aria-label="Tools" size="small">
81
+ <Toolbar.Items items={makeItems()} onSelect={() => {}} />
82
+ </Toolbar>
83
+ );
84
+
85
+ expect(getByRole("button", { name: "Text" })).toBeTruthy();
86
+ // Group slots show the representative (first child) plus a chevron
87
+ expect(getByRole("button", { name: "Move" })).toBeTruthy();
88
+ expect(getByRole("button", { name: "Move tools" })).toBeTruthy();
89
+ expect(getByRole("button", { name: "Rectangle" })).toBeTruthy();
90
+ // Separator renders the vertical divider
91
+ expect(container.querySelectorAll(".n-w-px.n-bg-divider").length).toBe(1);
92
+ });
93
+
94
+ test("checked item gets the primary active treatment", () => {
95
+ const { getByRole } = render(
96
+ <ToolbarItems items={makeItems("text")} onSelect={() => {}} />
97
+ );
98
+
99
+ const text = getByRole("button", { name: "Text" });
100
+ expect(text.getAttribute("aria-pressed")).toBe("true");
101
+ expect(text.classList.contains("n-bg-primary")).toBe(true);
102
+ expect(text.classList.contains("n-text-white")).toBe(true);
103
+
104
+ const move = getByRole("button", { name: "Move" });
105
+ expect(move.getAttribute("aria-pressed")).toBe("false");
106
+ expect(move.classList.contains("n-bg-primary")).toBe(false);
107
+ });
108
+
109
+ test("checked child becomes the group representative and marks the group active", () => {
110
+ const { getByRole, queryByRole } = render(
111
+ <ToolbarItems items={makeItems("hand")} onSelect={() => {}} />
112
+ );
113
+
114
+ const hand = getByRole("button", { name: "Hand" });
115
+ expect(hand.getAttribute("aria-pressed")).toBe("true");
116
+ expect(hand.classList.contains("n-bg-primary")).toBe(true);
117
+ // The unchecked sibling no longer occupies the slot
118
+ expect(queryByRole("button", { name: "Move" })).toBeNull();
119
+ });
120
+
121
+ test("group remembers the last-used child when the check moves elsewhere", () => {
122
+ const { getByRole, queryByRole, rerender } = render(
123
+ <ToolbarItems items={makeItems("oval")} onSelect={() => {}} />
124
+ );
125
+
126
+ expect(getByRole("button", { name: "Ellipse" })).toBeTruthy();
127
+
128
+ // Check moves to another group: the shape slot keeps showing Ellipse,
129
+ // now inactive, instead of resetting to Rectangle.
130
+ rerender(<ToolbarItems items={makeItems("select")} onSelect={() => {}} />);
131
+
132
+ const ellipse = getByRole("button", { name: "Ellipse" });
133
+ expect(ellipse.getAttribute("aria-pressed")).toBe("false");
134
+ expect(ellipse.classList.contains("n-bg-primary")).toBe(false);
135
+ expect(queryByRole("button", { name: "Rectangle" })).toBeNull();
136
+ });
137
+
138
+ test("selecting the group representative fires onSelect with its value", () => {
139
+ const onSelect = mock((_value: Tool) => {});
140
+ const { getByRole } = render(
141
+ <ToolbarItems items={makeItems()} onSelect={onSelect} />
142
+ );
143
+
144
+ fireEvent.click(getByRole("button", { name: "Rectangle" }));
145
+ expect(onSelect).toHaveBeenCalledWith("rectangle");
146
+
147
+ fireEvent.click(getByRole("button", { name: "Text" }));
148
+ expect(onSelect).toHaveBeenCalledWith("text");
149
+ });
150
+
151
+ test("disabled items render disabled and don't fire onSelect", () => {
152
+ const onSelect = mock((_value: Tool) => {});
153
+ const items: ToolbarItem<Tool>[] = [
154
+ {
155
+ title: "Text",
156
+ value: "text",
157
+ icon: "TextIcon",
158
+ disabled: true,
159
+ },
160
+ ];
161
+ const { getByRole } = render(
162
+ <ToolbarItems items={items} onSelect={onSelect} />
163
+ );
164
+
165
+ const text = getByRole("button", { name: "Text" }) as HTMLButtonElement;
166
+ expect(text.disabled).toBe(true);
167
+ expect(text.classList.contains("n-opacity-30")).toBe(true);
168
+ fireEvent.click(text);
169
+ expect(onSelect).not.toHaveBeenCalled();
170
+ });
171
+
172
+ test("fully disabled group disables its chevron", () => {
173
+ const items: ToolbarItem<Tool>[] = [
174
+ {
175
+ type: "group",
176
+ id: "shape",
177
+ items: [
178
+ {
179
+ title: "Rectangle",
180
+ value: "rectangle",
181
+ icon: "SquareIcon",
182
+ disabled: true,
183
+ },
184
+ {
185
+ title: "Ellipse",
186
+ value: "oval",
187
+ icon: "CircleIcon",
188
+ disabled: true,
189
+ },
190
+ ],
191
+ },
192
+ ];
193
+ const { getByRole } = render(
194
+ <ToolbarItems items={items} onSelect={() => {}} />
195
+ );
196
+
197
+ expect(
198
+ (getByRole("button", { name: "Rectangle" }) as HTMLButtonElement).disabled
199
+ ).toBe(true);
200
+ expect(
201
+ (getByRole("button", { name: "Rectangle tools" }) as HTMLButtonElement)
202
+ .disabled
203
+ ).toBe(true);
204
+ });
205
+ });
@@ -207,8 +207,11 @@ export const styles = {
207
207
  marginRight: -CHECKBOX_RIGHT_INSET,
208
208
  },
209
209
  },
210
+ // n-outline-none: the popup itself can take focus (e.g. a right-click
211
+ // context menu) and the browser's default focus outline reads as a stray
212
+ // black border on the light surface; keyboard focus is shown on items.
210
213
  contentStyle:
211
- "n-rounded n-bg-popover-background n-text-text n-shadow-popover n-z-menu n-py-1",
214
+ "n-rounded n-bg-popover-background n-text-text n-shadow-popover n-z-menu n-py-1 n-outline-none",
212
215
  };
213
216
 
214
217
  function getKeyboardShortcuts<T extends string>(
@@ -173,7 +173,14 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
173
173
 
174
174
  isSubmitTriggeredByEscapeKey.current = false;
175
175
 
176
- if (submissionValue === value && !allowSubmittingWithSameValue) return;
176
+ if (submissionValue === value && !allowSubmittingWithSameValue) {
177
+ // Nothing to commit — revert any in-progress edit so the displayed
178
+ // text matches the committed value (Escape-then-blur must not leave
179
+ // the abandoned text in the field, where the next blur would commit it).
180
+ userDidEdit.current = false;
181
+ setInternalValue(latestValue.current);
182
+ return;
183
+ }
177
184
 
178
185
  onSubmit(submissionValue);
179
186
 
@@ -200,12 +207,20 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
200
207
  } else if (event.key === "Escape") {
201
208
  isSubmitTriggeredByEscapeKey.current = true;
202
209
 
210
+ // An in-progress edit consumes the Escape: it only reverts the field.
211
+ // A second Escape (no pending edit) is left to outer handlers
212
+ // (popover close, canvas deselect...).
213
+ if (internalValue !== value) {
214
+ event.preventDefault();
215
+ event.stopPropagation();
216
+ }
217
+
203
218
  ref.current?.blur();
204
219
  } else {
205
220
  onKeyDown?.(event);
206
221
  }
207
222
  },
208
- [onKeyDown, handleSubmit]
223
+ [onKeyDown, handleSubmit, internalValue, value]
209
224
  );
210
225
 
211
226
  const handleChange = useCallback(
@@ -230,15 +245,39 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
230
245
  [onBlur, onFocusChange, handleSubmit, submitOnBlur]
231
246
  );
232
247
 
248
+ // Select the whole value on pointer-initiated focus so typing replaces it
249
+ // (Figma numeric-field behavior; Tab focus already selects natively). The
250
+ // select() runs in the focus handler, and the focusing click's mouseup gets
251
+ // a one-shot preventDefault so caret placement doesn't collapse the
252
+ // selection. Clicks in an already-focused field keep normal caret behavior.
253
+ const isFocusingFromPointer = useRef(false);
254
+
255
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
256
+ if (document.activeElement !== event.currentTarget) {
257
+ isFocusingFromPointer.current = true;
258
+ }
259
+ }, []);
260
+
233
261
  const handleFocusCapture = useCallback(
234
262
  (event: React.FocusEvent<HTMLInputElement>) => {
235
263
  onFocusCapture?.(event);
236
264
 
237
265
  onFocusChange?.(true);
266
+
267
+ if (isFocusingFromPointer.current) {
268
+ event.currentTarget.select();
269
+ }
238
270
  },
239
271
  [onFocusCapture, onFocusChange]
240
272
  );
241
273
 
274
+ const handleMouseUp = useCallback((event: React.MouseEvent) => {
275
+ if (!isFocusingFromPointer.current) return;
276
+
277
+ isFocusingFromPointer.current = false;
278
+ event.preventDefault();
279
+ }, []);
280
+
242
281
  const autoSubmitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
243
282
  null
244
283
  );
@@ -276,6 +315,8 @@ const SubmittableTextInput = forwardRef(function SubmittableTextInput(
276
315
  onChange={handleChange}
277
316
  onBlur={handleBlur}
278
317
  onFocusCapture={handleFocusCapture}
318
+ onMouseDown={handleMouseDown}
319
+ onMouseUp={handleMouseUp}
279
320
  />
280
321
  );
281
322
  });
@@ -142,4 +142,81 @@ describe("submittable text input", () => {
142
142
 
143
143
  expect(handleSubmit).toBeCalledTimes(3);
144
144
  });
145
+
146
+ test("escape reverts an in-progress edit without submitting", () => {
147
+ const handleSubmit = mock(() => {});
148
+
149
+ const { container } = render(
150
+ <TextInput value="90" onSubmit={handleSubmit} />
151
+ );
152
+
153
+ const input = container.querySelector("input")!;
154
+ fireEvent.change(input, { target: { value: "555" } });
155
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
156
+ // Escape blurs the field; the blur must not commit the abandoned edit.
157
+ fireEvent.blur(input);
158
+
159
+ expect(handleSubmit).not.toBeCalled();
160
+ expect(input.value).toEqual("90");
161
+ });
162
+
163
+ test("blur after an escape-revert does not resurrect the abandoned edit", () => {
164
+ const handleSubmit = mock(() => {});
165
+
166
+ const { container } = render(
167
+ <TextInput value="90" onSubmit={handleSubmit} />
168
+ );
169
+
170
+ const input = container.querySelector("input")!;
171
+ fireEvent.change(input, { target: { value: "555" } });
172
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
173
+ fireEvent.blur(input);
174
+ // A later focus + blur cycle with no new edit stays a no-op.
175
+ fireEvent.focus(input);
176
+ fireEvent.blur(input);
177
+
178
+ expect(handleSubmit).not.toBeCalled();
179
+ expect(input.value).toEqual("90");
180
+ });
181
+
182
+ test("escape with an in-progress edit consumes the event; a clean escape propagates", () => {
183
+ const outerKeyDown = mock(() => {});
184
+
185
+ const { container } = render(
186
+ <div onKeyDown={outerKeyDown}>
187
+ <TextInput value="90" onSubmit={() => {}} />
188
+ </div>
189
+ );
190
+
191
+ const input = container.querySelector("input")!;
192
+
193
+ // No edit in progress: Escape is left to outer handlers (deselect,
194
+ // popover close...).
195
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
196
+ expect(outerKeyDown).toBeCalledTimes(1);
197
+
198
+ // Edit in progress: Escape only reverts the field.
199
+ fireEvent.change(input, { target: { value: "555" } });
200
+ fireEvent.keyDown(input, { key: "Escape", code: "Escape" });
201
+ expect(outerKeyDown).toBeCalledTimes(1);
202
+ });
203
+
204
+ test("pointer-initiated focus selects the whole value", () => {
205
+ const { container } = render(
206
+ <TextInput value="1440" onSubmit={() => {}} />
207
+ );
208
+
209
+ const input = container.querySelector("input")!;
210
+ fireEvent.mouseDown(input);
211
+ fireEvent.focus(input);
212
+
213
+ expect(input.selectionStart).toEqual(0);
214
+ expect(input.selectionEnd).toEqual(4);
215
+
216
+ // The mouseup that completes the focusing click must not collapse the
217
+ // selection (the handler preventDefaults exactly once).
218
+ fireEvent.mouseUp(input);
219
+ expect(input.selectionStart).toEqual(0);
220
+ expect(input.selectionEnd).toEqual(4);
221
+ });
145
222
  });
@@ -29,6 +29,42 @@ export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
29
29
  selectBeforeDot = false,
30
30
  }: EditableRowTitleProps) {
31
31
  const inputRef = useRef<HTMLInputElement | null>(null);
32
+ const submittedRef = useRef(false);
33
+
34
+ const handleSubmitEditing = (nextValue: string) => {
35
+ if (submittedRef.current) return;
36
+ submittedRef.current = true;
37
+ onSubmitEditing(nextValue);
38
+ };
39
+ const handleSubmitEditingRef = useRef(handleSubmitEditing);
40
+ handleSubmitEditingRef.current = handleSubmitEditing;
41
+
42
+ // Commit and close on any pointerdown outside the editor. Blur alone isn't
43
+ // enough: interactions that call preventDefault() on pointerdown (e.g. a
44
+ // canvas) never move focus, which would strand an open rename editor.
45
+ useLayoutEffect(() => {
46
+ const handlePointerDown = (event: PointerEvent) => {
47
+ const element = inputRef.current;
48
+ if (!element) return;
49
+ if (
50
+ event.target instanceof Node &&
51
+ (element === event.target || element.contains(event.target))
52
+ ) {
53
+ return;
54
+ }
55
+ handleSubmitEditingRef.current(element.value);
56
+ };
57
+
58
+ document.addEventListener("pointerdown", handlePointerDown, {
59
+ capture: true,
60
+ });
61
+
62
+ return () => {
63
+ document.removeEventListener("pointerdown", handlePointerDown, {
64
+ capture: true,
65
+ });
66
+ };
67
+ }, []);
32
68
 
33
69
  useLayoutEffect(() => {
34
70
  const element = inputRef.current;
@@ -65,7 +101,7 @@ export const ListViewEditableRowTitle = memo(function ListViewEditableRowTitle({
65
101
  ref={inputRef}
66
102
  value={value}
67
103
  placeholder={placeholder}
68
- onSubmit={onSubmitEditing}
104
+ onSubmit={handleSubmitEditing}
69
105
  allowSubmittingWithSameValue
70
106
  className={cx(
71
107
  "-n-mx-input-padding-x -n-my-1 n-bg-listview-editing-background",
@@ -105,6 +105,9 @@ const RowContainer = forwardRef<
105
105
  $clickable ? "n-cursor-pointer" : "n-cursor-default",
106
106
  $isSectionHeader && "n-bg-listview-raised-background",
107
107
  "n-flex n-flex-[0_0_auto] n-select-none n-items-center n-relative",
108
+ // Rows own their focus styling: the standard primary ring for
109
+ // keyboard focus instead of the UA default black outline.
110
+ "n-outline-none focus-visible:n-ring-1 focus-visible:n-ring-primary focus-visible:n-ring-inset",
108
111
  className
109
112
  )}
110
113
  style={{
@@ -0,0 +1,69 @@
1
+ import { cleanup, fireEvent, render } from "@testing-library/react";
2
+ import { afterEach, describe, expect, test } from "bun:test";
3
+ import React from "react";
4
+ import { ListViewEditableRowTitle } from "../ListViewEditableRowTitle";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("ListViewEditableRowTitle", () => {
9
+ test("commits and closes on pointerdown outside the editor", () => {
10
+ // Regression: interactions that preventDefault() their pointerdown (e.g.
11
+ // a canvas) never blur the input, which used to strand an open rename
12
+ // editor across later interactions.
13
+ const submissions: string[] = [];
14
+
15
+ const { container } = render(
16
+ <div>
17
+ <ListViewEditableRowTitle
18
+ value="Rectangle"
19
+ autoFocus={false}
20
+ onSubmitEditing={(value) => submissions.push(value)}
21
+ />
22
+ <button>outside</button>
23
+ </div>
24
+ );
25
+
26
+ const outside = container.querySelector("button")!;
27
+ fireEvent.pointerDown(outside);
28
+
29
+ expect(submissions).toEqual(["Rectangle"]);
30
+ });
31
+
32
+ test("pointerdown inside the editor does not submit", () => {
33
+ const submissions: string[] = [];
34
+
35
+ const { container } = render(
36
+ <ListViewEditableRowTitle
37
+ value="Rectangle"
38
+ autoFocus={false}
39
+ onSubmitEditing={(value) => submissions.push(value)}
40
+ />
41
+ );
42
+
43
+ const input = container.querySelector("input")!;
44
+ fireEvent.pointerDown(input);
45
+
46
+ expect(submissions).toEqual([]);
47
+ });
48
+
49
+ test("submits at most once", () => {
50
+ const submissions: string[] = [];
51
+
52
+ const { container } = render(
53
+ <div>
54
+ <ListViewEditableRowTitle
55
+ value="Rectangle"
56
+ autoFocus={false}
57
+ onSubmitEditing={(value) => submissions.push(value)}
58
+ />
59
+ <button>outside</button>
60
+ </div>
61
+ );
62
+
63
+ const outside = container.querySelector("button")!;
64
+ fireEvent.pointerDown(outside);
65
+ fireEvent.pointerDown(outside);
66
+
67
+ expect(submissions).toEqual(["Rectangle"]);
68
+ });
69
+ });
@@ -186,7 +186,10 @@ function SortableItem<TElement extends HTMLElement>({
186
186
  ref,
187
187
  ...attributes,
188
188
  ...listeners,
189
- style: { opacity: isDragging ? 0.5 : 1 },
189
+ // Only set an inline opacity while dragging — an always-on inline
190
+ // `opacity: 1` would override class-based opacity styling on rows
191
+ // (e.g. dimmed hidden layers).
192
+ ...(isDragging ? { style: { opacity: 0.5 } } : {}),
190
193
  }),
191
194
  [ref, attributes, listeners, isDragging]
192
195
  );
package/src/index.tsx CHANGED
@@ -128,7 +128,6 @@ export { default as withSeparatorElements } from "./utils/withSeparatorElements"
128
128
 
129
129
  // Avoid dependency cycles
130
130
  export * from "./components/DimensionInput";
131
- export * as InspectorPrimitives from "./components/InspectorPrimitives";
132
131
 
133
132
  export * from "./components/BaseToolbar";
134
133
  export * from "./components/AppToolbar";