@noya-app/noya-designsystem 0.1.88 → 0.1.90
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 +8 -8
- package/CHANGELOG.md +18 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +3 -4
- package/dist/index.d.ts +3 -4
- package/dist/index.js +16 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +16 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/components/BaseToolbar.tsx +3 -1
- package/src/components/Dialog.tsx +4 -1
- package/src/components/InputField.tsx +9 -7
- package/src/components/__tests__/BaseToolbar.test.tsx +22 -0
- package/src/components/__tests__/DialogNavigator.test.tsx +13 -0
- package/src/components/__tests__/InputField.test.tsx +227 -3
- package/src/components/connected-users-menu/ConnectedUsersMenuLayout.tsx +0 -16
- package/src/components/workspace/DrawerWorkspaceLayout.tsx +1 -1
- package/src/components/workspace/__tests__/DrawerWorkspaceLayout.test.tsx +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noya-app/noya-designsystem",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.90",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"module": "./dist/index.mjs",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -29,10 +29,10 @@
|
|
|
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.
|
|
33
|
-
"@noya-app/noya-utils": "0.1.
|
|
34
|
-
"@noya-app/noya-geometry": "0.1.
|
|
35
|
-
"@noya-app/noya-icons": "0.1.
|
|
32
|
+
"@noya-app/noya-colorpicker": "0.1.36",
|
|
33
|
+
"@noya-app/noya-utils": "0.1.10",
|
|
34
|
+
"@noya-app/noya-geometry": "0.1.19",
|
|
35
|
+
"@noya-app/noya-icons": "0.1.20",
|
|
36
36
|
"@noya-app/noya-keymap": "0.1.4",
|
|
37
37
|
"@noya-app/noya-tailwind-config": "0.1.10",
|
|
38
38
|
"@radix-ui/react-compose-refs": "^1.0.0",
|
|
@@ -9,6 +9,7 @@ export interface BaseToolbarProps {
|
|
|
9
9
|
left?: React.ReactNode;
|
|
10
10
|
right?: React.ReactNode;
|
|
11
11
|
logo?: React.ReactNode;
|
|
12
|
+
showLogoDivider?: boolean;
|
|
12
13
|
showDivider?: boolean;
|
|
13
14
|
className?: string;
|
|
14
15
|
innerClassName?: string;
|
|
@@ -63,6 +64,7 @@ export function BaseToolbar({
|
|
|
63
64
|
left,
|
|
64
65
|
right,
|
|
65
66
|
logo,
|
|
67
|
+
showLogoDivider = true,
|
|
66
68
|
showDivider = true,
|
|
67
69
|
className,
|
|
68
70
|
innerClassName,
|
|
@@ -97,7 +99,7 @@ export function BaseToolbar({
|
|
|
97
99
|
{logo && (
|
|
98
100
|
<>
|
|
99
101
|
{logo}
|
|
100
|
-
<DividerVertical />
|
|
102
|
+
{showLogoDivider && <DividerVertical />}
|
|
101
103
|
</>
|
|
102
104
|
)}
|
|
103
105
|
<Spacer.Horizontal size={10} />
|
|
@@ -19,7 +19,10 @@ const StyledBackdrop = forwardRef<
|
|
|
19
19
|
>(({ className, ...props }, ref) => (
|
|
20
20
|
<DialogPrimitive.Backdrop
|
|
21
21
|
ref={ref}
|
|
22
|
-
className={cx(
|
|
22
|
+
className={cx(
|
|
23
|
+
`n-fixed n-inset-0 n-z-[999] n-bg-[rgba(0,0,0,0.5)]`,
|
|
24
|
+
className
|
|
25
|
+
)}
|
|
23
26
|
{...props}
|
|
24
27
|
/>
|
|
25
28
|
));
|
|
@@ -434,23 +434,25 @@ function InputFieldNumberInput(props: InputFieldNumberInputProps) {
|
|
|
434
434
|
);
|
|
435
435
|
|
|
436
436
|
const handleChange = useCallback(
|
|
437
|
-
(
|
|
438
|
-
if (
|
|
439
|
-
setInternalValue(
|
|
437
|
+
(inputValue: string) => {
|
|
438
|
+
if (inputValue === "" || inputValue === "-" || inputValue === ".") {
|
|
439
|
+
setInternalValue(inputValue);
|
|
440
440
|
if (triggersOnEmpty) {
|
|
441
441
|
onChange?.(0, { isEmpty: true });
|
|
442
442
|
}
|
|
443
443
|
return;
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
-
if (
|
|
447
|
-
setInternalValue(
|
|
446
|
+
if (inputValue.endsWith(".")) {
|
|
447
|
+
setInternalValue(inputValue);
|
|
448
448
|
return;
|
|
449
449
|
}
|
|
450
450
|
|
|
451
|
-
|
|
451
|
+
// Keep the text being edited stable if the parent rejects or normalizes
|
|
452
|
+
// an intermediate numeric value. The accepted prop value takes over on blur.
|
|
453
|
+
setInternalValue(inputValue);
|
|
452
454
|
|
|
453
|
-
const newValue = parseNumber(
|
|
455
|
+
const newValue = parseNumber(inputValue);
|
|
454
456
|
|
|
455
457
|
if (!isNaN(newValue)) {
|
|
456
458
|
onChange?.(newValue, { isEmpty: false });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { cleanup, render } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { BaseToolbar } from "../BaseToolbar";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe("BaseToolbar", () => {
|
|
9
|
+
test("shows a vertical divider after the logo by default", () => {
|
|
10
|
+
const { container } = render(<BaseToolbar logo={<div>Logo</div>} />);
|
|
11
|
+
|
|
12
|
+
expect(container.querySelector(".n-w-px")).toBeTruthy();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("can hide the vertical divider after the logo", () => {
|
|
16
|
+
const { container } = render(
|
|
17
|
+
<BaseToolbar logo={<div>Back</div>} showLogoDivider={false} />
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(container.querySelector(".n-w-px")).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -64,6 +64,19 @@ describe("DialogNavigator", () => {
|
|
|
64
64
|
expect(view.getByRole("button", { name: "Close" })).toBeTruthy();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test("stacks the backdrop above elevated page controls", () => {
|
|
68
|
+
render(<TestDialogNavigator onOpenChange={() => {}} />);
|
|
69
|
+
|
|
70
|
+
const backdrop = Array.from(document.querySelectorAll("div")).find(
|
|
71
|
+
(element) =>
|
|
72
|
+
element.classList.contains("n-bg-[rgba(0,0,0,0.5)]") &&
|
|
73
|
+
element.classList.contains("n-inset-0")
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(backdrop).toBeTruthy();
|
|
77
|
+
expect(backdrop?.classList.contains("n-z-[999]")).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
67
80
|
test("combines navigation and dialog controls in the header", async () => {
|
|
68
81
|
const onOpenChange = mock(() => {});
|
|
69
82
|
const view = render(<TestDialogNavigator onOpenChange={onOpenChange} />);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { act, cleanup, render } from "@testing-library/react";
|
|
2
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
3
|
-
import React, { useEffect, useRef } from "react";
|
|
1
|
+
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { InputField } from "../InputField";
|
|
5
5
|
|
|
6
6
|
afterEach(cleanup);
|
|
@@ -33,4 +33,228 @@ describe("InputField", () => {
|
|
|
33
33
|
|
|
34
34
|
expect(document.activeElement === input).toBe(true);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
test("keeps a bounded number edit buffered until it becomes valid", () => {
|
|
38
|
+
const attemptedValues: number[] = [];
|
|
39
|
+
|
|
40
|
+
function BoundedNumberInput() {
|
|
41
|
+
const [value, setValue] = useState(400);
|
|
42
|
+
const [, setRevision] = useState(0);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<InputField.Root>
|
|
46
|
+
<InputField.NumberInput
|
|
47
|
+
value={value}
|
|
48
|
+
onChange={(nextValue) => {
|
|
49
|
+
attemptedValues.push(nextValue);
|
|
50
|
+
if (nextValue >= 100 && nextValue <= 900) {
|
|
51
|
+
setValue(nextValue);
|
|
52
|
+
}
|
|
53
|
+
setRevision((revision) => revision + 1);
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
</InputField.Root>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { container } = render(<BoundedNumberInput />);
|
|
61
|
+
const input = container.querySelector("input")!;
|
|
62
|
+
|
|
63
|
+
fireEvent.focus(input);
|
|
64
|
+
fireEvent.change(input, { target: { value: "7" } });
|
|
65
|
+
expect(input.value).toBe("7");
|
|
66
|
+
|
|
67
|
+
fireEvent.change(input, { target: { value: `${input.value}0` } });
|
|
68
|
+
expect(input.value).toBe("70");
|
|
69
|
+
|
|
70
|
+
fireEvent.change(input, { target: { value: `${input.value}0` } });
|
|
71
|
+
expect(input.value).toBe("700");
|
|
72
|
+
expect(attemptedValues).toEqual([7, 70, 700]);
|
|
73
|
+
|
|
74
|
+
fireEvent.blur(input);
|
|
75
|
+
expect(input.value).toBe("700");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("restores the accepted number when a buffered edit remains invalid", () => {
|
|
79
|
+
function BoundedNumberInput() {
|
|
80
|
+
const [, setRevision] = useState(0);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<InputField.Root>
|
|
84
|
+
<InputField.NumberInput
|
|
85
|
+
value={400}
|
|
86
|
+
onChange={() => setRevision((value) => value + 1)}
|
|
87
|
+
/>
|
|
88
|
+
</InputField.Root>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { container } = render(<BoundedNumberInput />);
|
|
93
|
+
const input = container.querySelector("input")!;
|
|
94
|
+
|
|
95
|
+
fireEvent.focus(input);
|
|
96
|
+
fireEvent.change(input, { target: { value: "70" } });
|
|
97
|
+
expect(input.value).toBe("70");
|
|
98
|
+
|
|
99
|
+
fireEvent.blur(input);
|
|
100
|
+
expect(input.value).toBe("400");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("shows a parent-normalized number after blur", () => {
|
|
104
|
+
function RoundedNumberInput() {
|
|
105
|
+
const [value, setValue] = useState(12);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<InputField.Root>
|
|
109
|
+
<InputField.NumberInput
|
|
110
|
+
value={value}
|
|
111
|
+
onChange={(nextValue) =>
|
|
112
|
+
setValue(Math.round(nextValue * 100) / 100)
|
|
113
|
+
}
|
|
114
|
+
/>
|
|
115
|
+
</InputField.Root>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { container } = render(<RoundedNumberInput />);
|
|
120
|
+
const input = container.querySelector("input")!;
|
|
121
|
+
|
|
122
|
+
fireEvent.focus(input);
|
|
123
|
+
fireEvent.change(input, { target: { value: "12.345" } });
|
|
124
|
+
expect(input.value).toBe("12.345");
|
|
125
|
+
|
|
126
|
+
fireEvent.blur(input);
|
|
127
|
+
expect(input.value).toBe("12.35");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("buffers incomplete and invalid numeric text without notifying", () => {
|
|
131
|
+
const onChange = mock(() => {});
|
|
132
|
+
const { container } = render(
|
|
133
|
+
<InputField.Root>
|
|
134
|
+
<InputField.NumberInput value={12} onChange={onChange} />
|
|
135
|
+
</InputField.Root>
|
|
136
|
+
);
|
|
137
|
+
const input = container.querySelector("input")!;
|
|
138
|
+
|
|
139
|
+
fireEvent.focus(input);
|
|
140
|
+
fireEvent.change(input, { target: { value: "12." } });
|
|
141
|
+
expect(input.value).toBe("12.");
|
|
142
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
143
|
+
|
|
144
|
+
fireEvent.change(input, { target: { value: "not-a-number" } });
|
|
145
|
+
expect(input.value).toBe("not-a-number");
|
|
146
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
fireEvent.blur(input);
|
|
149
|
+
expect(input.value).toBe("12");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("clears a buffered edit when nudging", () => {
|
|
153
|
+
function NudgeableNumberInput() {
|
|
154
|
+
const [value, setValue] = useState(400);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<InputField.Root>
|
|
158
|
+
<InputField.NumberInput
|
|
159
|
+
value={value}
|
|
160
|
+
onChange={(nextValue) => {
|
|
161
|
+
if (nextValue >= 100) setValue(nextValue);
|
|
162
|
+
}}
|
|
163
|
+
onNudge={(amount) => setValue((current) => current + amount)}
|
|
164
|
+
/>
|
|
165
|
+
</InputField.Root>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { container } = render(<NudgeableNumberInput />);
|
|
170
|
+
const input = container.querySelector("input")!;
|
|
171
|
+
|
|
172
|
+
fireEvent.focus(input);
|
|
173
|
+
fireEvent.change(input, { target: { value: "70" } });
|
|
174
|
+
expect(input.value).toBe("70");
|
|
175
|
+
|
|
176
|
+
fireEvent.keyDown(input, { key: "ArrowUp", code: "ArrowUp" });
|
|
177
|
+
expect(input.value).toBe("401");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("supports clearing and re-entering optional numbers", () => {
|
|
181
|
+
const changes: Array<{ value: number; isEmpty: boolean }> = [];
|
|
182
|
+
|
|
183
|
+
function OptionalNumberInput() {
|
|
184
|
+
const [value, setValue] = useState<number>(42);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<InputField.Root>
|
|
188
|
+
<InputField.NumberInput
|
|
189
|
+
value={value}
|
|
190
|
+
triggersOnEmpty
|
|
191
|
+
onChange={(nextValue, options) => {
|
|
192
|
+
changes.push({ value: nextValue, isEmpty: options.isEmpty });
|
|
193
|
+
setValue(options.isEmpty ? undefined : nextValue);
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
</InputField.Root>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { container } = render(<OptionalNumberInput />);
|
|
201
|
+
const input = container.querySelector("input")!;
|
|
202
|
+
|
|
203
|
+
fireEvent.focus(input);
|
|
204
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
205
|
+
expect(input.value).toBe("");
|
|
206
|
+
|
|
207
|
+
fireEvent.change(input, { target: { value: "1" } });
|
|
208
|
+
fireEvent.change(input, { target: { value: "10" } });
|
|
209
|
+
expect(input.value).toBe("10");
|
|
210
|
+
expect(changes).toEqual([
|
|
211
|
+
{ value: 0, isEmpty: true },
|
|
212
|
+
{ value: 1, isEmpty: false },
|
|
213
|
+
{ value: 10, isEmpty: false },
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("defers external prop updates until a focused edit blurs", () => {
|
|
218
|
+
let setExternalValue: (value: number) => void = () => {};
|
|
219
|
+
|
|
220
|
+
function ExternallyUpdatedNumberInput() {
|
|
221
|
+
const [value, setValue] = useState(400);
|
|
222
|
+
setExternalValue = setValue;
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<InputField.Root>
|
|
226
|
+
<InputField.NumberInput value={value} onChange={() => {}} />
|
|
227
|
+
</InputField.Root>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const { container } = render(<ExternallyUpdatedNumberInput />);
|
|
232
|
+
const input = container.querySelector("input")!;
|
|
233
|
+
|
|
234
|
+
fireEvent.focus(input);
|
|
235
|
+
fireEvent.change(input, { target: { value: "70" } });
|
|
236
|
+
act(() => setExternalValue(500));
|
|
237
|
+
expect(input.value).toBe("70");
|
|
238
|
+
|
|
239
|
+
fireEvent.blur(input);
|
|
240
|
+
expect(input.value).toBe("500");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("keeps number submission mode separate from change buffering", () => {
|
|
244
|
+
const onSubmit = mock(() => {});
|
|
245
|
+
const { container } = render(
|
|
246
|
+
<InputField.Root>
|
|
247
|
+
<InputField.NumberInput value={400} onSubmit={onSubmit} />
|
|
248
|
+
</InputField.Root>
|
|
249
|
+
);
|
|
250
|
+
const input = container.querySelector("input")!;
|
|
251
|
+
|
|
252
|
+
fireEvent.change(input, { target: { value: "invalid" } });
|
|
253
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
254
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
255
|
+
|
|
256
|
+
fireEvent.change(input, { target: { value: "700" } });
|
|
257
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
258
|
+
expect(onSubmit).toHaveBeenCalledWith(700);
|
|
259
|
+
});
|
|
36
260
|
});
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { INPUT_HEIGHT } from "../../theme";
|
|
5
5
|
import { Avatar, AvatarProps, AvatarStack } from "../Avatar";
|
|
6
|
-
import { Button } from "../Button";
|
|
7
6
|
import { Small } from "../Text";
|
|
8
7
|
import { Tooltip } from "../Tooltip";
|
|
9
8
|
|
|
@@ -22,29 +21,14 @@ export const UserAvatar = ({ userId, name, image }: AvatarProps) => (
|
|
|
22
21
|
|
|
23
22
|
type ConnectedUsersMenuLayoutProps = {
|
|
24
23
|
renderUsers: () => React.ReactNode;
|
|
25
|
-
launchAIAssistant?: () => void;
|
|
26
|
-
isAssistantOpen?: boolean;
|
|
27
24
|
};
|
|
28
25
|
|
|
29
26
|
export const ConnectedUsersMenuLayout = ({
|
|
30
27
|
renderUsers,
|
|
31
|
-
launchAIAssistant,
|
|
32
|
-
isAssistantOpen,
|
|
33
28
|
}: ConnectedUsersMenuLayoutProps) => {
|
|
34
29
|
return (
|
|
35
30
|
<div className="n-flex n-gap-1.5">
|
|
36
31
|
<AvatarStack size={INPUT_HEIGHT}>{renderUsers()}</AvatarStack>
|
|
37
|
-
{launchAIAssistant && (
|
|
38
|
-
<Tooltip content="AI Assistant">
|
|
39
|
-
<Button
|
|
40
|
-
active={isAssistantOpen}
|
|
41
|
-
onClick={launchAIAssistant}
|
|
42
|
-
icon="ChatBubbleWithDotsIcon"
|
|
43
|
-
>
|
|
44
|
-
AI
|
|
45
|
-
</Button>
|
|
46
|
-
</Tooltip>
|
|
47
|
-
)}
|
|
48
32
|
</div>
|
|
49
33
|
);
|
|
50
34
|
};
|
|
@@ -182,7 +182,7 @@ export const DrawerWorkspaceLayout = memoGeneric(function DrawerWorkspaceLayout<
|
|
|
182
182
|
)}
|
|
183
183
|
|
|
184
184
|
{compactTabItem && (
|
|
185
|
-
<div className="n-absolute n-top-0 n-left-0 n-h-[calc(var(--n-toolbar-height)+1px)] n-w-[calc(var(--n-toolbar-height)+1px)] n-bg-sidebar-background n-border-
|
|
185
|
+
<div className="n-absolute n-top-0 n-left-0 n-h-[calc(var(--n-toolbar-height)+1px)] n-w-[calc(var(--n-toolbar-height)+1px)] n-bg-sidebar-background n-border-b n-border-divider-strong n-z-10 n-flex n-items-center n-justify-center">
|
|
186
186
|
<Button
|
|
187
187
|
aria-label={
|
|
188
188
|
typeof compactTabItem.title === "string"
|
|
@@ -27,7 +27,12 @@ describe("DrawerWorkspaceLayout", () => {
|
|
|
27
27
|
|
|
28
28
|
expect(container.textContent).toBe("Center content");
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
const navigationButton = getByRole("button", { name: "Navigation" });
|
|
31
|
+
const compactTrigger = navigationButton.parentElement;
|
|
32
|
+
expect(compactTrigger?.classList.contains("n-border-b")).toBe(true);
|
|
33
|
+
expect(compactTrigger?.classList.contains("n-border-r")).toBe(false);
|
|
34
|
+
|
|
35
|
+
fireEvent.click(navigationButton);
|
|
31
36
|
|
|
32
37
|
expect(getByText("Sidebar content")).toBeTruthy();
|
|
33
38
|
});
|