@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +20 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +167 -95
- package/dist/index.d.ts +167 -95
- package/dist/index.js +12689 -12005
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13912 -13233
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/SharedDragProvider.test.ts +72 -29
- package/src/__tests__/fuzzyScorer.test.ts +49 -1
- package/src/components/Button.tsx +1 -1
- package/src/components/Combobox.tsx +23 -3
- package/src/components/ComboboxMenu.tsx +32 -15
- package/src/components/ContextMenu.tsx +26 -7
- package/src/components/DimensionInput.tsx +12 -2
- package/src/components/DropdownMenu.tsx +37 -9
- package/src/components/InputField.tsx +1 -0
- package/src/components/InspectorContainer.tsx +16 -1
- package/src/components/List.tsx +25 -3
- package/src/components/Popover.tsx +13 -1
- package/src/components/Section.tsx +35 -11
- package/src/components/SegmentedControl.tsx +14 -4
- package/src/components/Toast.tsx +2 -2
- package/src/components/Toolbar.tsx +16 -1
- package/src/components/ToolbarItems.tsx +322 -0
- package/src/components/Tooltip.tsx +8 -1
- package/src/components/__tests__/DropdownMenu.test.ts +19 -0
- package/src/components/__tests__/Toast.test.tsx +138 -0
- package/src/components/__tests__/ToolbarItems.test.tsx +205 -0
- package/src/components/internal/Menu.tsx +4 -1
- package/src/components/internal/TextInput.tsx +43 -2
- package/src/components/internal/__tests__/TextInput.test.tsx +77 -0
- package/src/components/listView/ListViewEditableRowTitle.tsx +37 -1
- package/src/components/listView/ListViewRow.tsx +3 -0
- package/src/components/listView/__tests__/ListViewEditableRowTitle.test.tsx +69 -0
- package/src/components/sorting/Sortable.tsx +4 -1
- package/src/index.tsx +0 -1
- package/src/utils/combobox.ts +22 -5
- package/src/utils/fuzzyScorer.ts +71 -0
- package/tailwind.config.ts +7 -0
- 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)
|
|
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={
|
|
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
|
-
|
|
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";
|