@noya-app/noya-designsystem 0.1.88 → 0.1.89
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 +8 -0
- package/dist/index.d.mts +1 -3
- package/dist/index.d.ts +1 -3
- package/dist/index.js +9 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/InputField.tsx +9 -7
- package/src/components/__tests__/InputField.test.tsx +227 -3
- package/src/components/connected-users-menu/ConnectedUsersMenuLayout.tsx +0 -16
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.89",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"module": "./dist/index.mjs",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@noya-app/noya-colorpicker": "0.1.35",
|
|
33
33
|
"@noya-app/noya-utils": "0.1.9",
|
|
34
34
|
"@noya-app/noya-geometry": "0.1.18",
|
|
35
|
-
"@noya-app/noya-icons": "0.1.
|
|
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",
|
|
@@ -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 });
|
|
@@ -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
|
};
|