@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.88",
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.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",
@@ -434,23 +434,25 @@ function InputFieldNumberInput(props: InputFieldNumberInputProps) {
434
434
  );
435
435
 
436
436
  const handleChange = useCallback(
437
- (value: string) => {
438
- if (value === "" || value === "-" || value === ".") {
439
- setInternalValue(value);
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 (value.endsWith(".")) {
447
- setInternalValue(value);
446
+ if (inputValue.endsWith(".")) {
447
+ setInternalValue(inputValue);
448
448
  return;
449
449
  }
450
450
 
451
- setInternalValue(undefined);
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(value);
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
  };