@noya-app/noya-designsystem 0.1.87 → 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.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +14 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +181 -120
  5. package/dist/index.d.ts +181 -120
  6. package/dist/index.js +2147 -1915
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1936 -1711
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/__tests__/SharedDragProvider.test.ts +39 -0
  12. package/src/components/AppToolbar.tsx +464 -0
  13. package/src/components/Card.tsx +20 -0
  14. package/src/components/Dialog.tsx +12 -5
  15. package/src/components/DialogNavigator.tsx +154 -0
  16. package/src/components/InputField.tsx +9 -7
  17. package/src/components/Navigator.tsx +2 -0
  18. package/src/components/SegmentedControl.tsx +32 -4
  19. package/src/components/SelectionToolbar.tsx +5 -10
  20. package/src/components/StatCard.tsx +82 -0
  21. package/src/components/Toolbar.tsx +32 -459
  22. package/src/components/__tests__/Card.test.tsx +71 -0
  23. package/src/components/__tests__/DialogNavigator.test.tsx +88 -0
  24. package/src/components/__tests__/InputField.test.tsx +227 -3
  25. package/src/components/__tests__/SegmentedControl.test.ts +46 -0
  26. package/src/components/__tests__/Toolbar.test.tsx +56 -0
  27. package/src/components/connected-users-menu/ConnectedUsersMenuLayout.tsx +0 -16
  28. package/src/components/sorting/SharedDragProvider.tsx +37 -1
  29. package/src/components/workspace/DrawerWorkspaceLayout.tsx +2 -2
  30. package/src/components/workspace/VerticalTabMenu.tsx +1 -1
  31. package/src/components/workspace/__tests__/DrawerWorkspaceLayout.test.tsx +2 -0
  32. package/src/index.css +6 -0
  33. package/src/index.tsx +4 -0
@@ -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
  });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { snapIndicatorEdgesToDevicePixels } from "../SegmentedControl";
3
+
4
+ describe("snapIndicatorEdgesToDevicePixels", () => {
5
+ test("snaps both rounded indicator edges at 1x density", () => {
6
+ expect(
7
+ snapIndicatorEdgesToDevicePixels({
8
+ containerLeft: 548,
9
+ itemLeft: 623.59375,
10
+ itemRight: 678.1875,
11
+ devicePixelRatio: 1,
12
+ })
13
+ ).toEqual({
14
+ left: 76,
15
+ width: 54,
16
+ });
17
+ });
18
+
19
+ test("preserves half-pixel edges at 2x density", () => {
20
+ expect(
21
+ snapIndicatorEdgesToDevicePixels({
22
+ containerLeft: 10.25,
23
+ itemLeft: 20.3,
24
+ itemRight: 40.8,
25
+ devicePixelRatio: 2,
26
+ })
27
+ ).toEqual({
28
+ left: 10.25,
29
+ width: 20.5,
30
+ });
31
+ });
32
+
33
+ test("falls back to 1x density for an invalid scale", () => {
34
+ expect(
35
+ snapIndicatorEdgesToDevicePixels({
36
+ containerLeft: 0,
37
+ itemLeft: 1.4,
38
+ itemRight: 3.6,
39
+ devicePixelRatio: 0,
40
+ })
41
+ ).toEqual({
42
+ left: 1,
43
+ width: 3,
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { cleanup, render } from "@testing-library/react";
3
+ import React from "react";
4
+ import { cssVarNames } from "../../theme";
5
+ import { AppToolbar } from "../AppToolbar";
6
+ import { Toolbar } from "../Toolbar";
7
+
8
+ afterEach(cleanup);
9
+
10
+ describe("Toolbar", () => {
11
+ test("renders the floating toolbar surface with transparent controls", () => {
12
+ const { getByRole } = render(
13
+ <Toolbar aria-label="Canvas tools">Controls</Toolbar>
14
+ );
15
+ const toolbar = getByRole("toolbar", { name: "Canvas tools" });
16
+
17
+ expect(toolbar.classList.contains("n-bg-popover-background")).toBe(true);
18
+ expect(toolbar.classList.contains("n-shadow-popover")).toBe(true);
19
+ expect(toolbar.classList.contains("n-gap-toolbar-separator")).toBe(true);
20
+ expect(toolbar.classList.contains("n-p-2")).toBe(true);
21
+ expect(
22
+ toolbar.style.getPropertyValue(cssVarNames.colors.buttonBackground)
23
+ ).toBe("transparent");
24
+ expect(
25
+ toolbar.style.getPropertyValue(cssVarNames.colors.inputBackground)
26
+ ).toBe("transparent");
27
+ });
28
+
29
+ test("supports compact spacing and consumer style overrides", () => {
30
+ const { getByRole } = render(
31
+ <Toolbar
32
+ aria-label="Selection tools"
33
+ size="small"
34
+ style={{ backgroundColor: "transparent" }}
35
+ />
36
+ );
37
+ const toolbar = getByRole("toolbar", { name: "Selection tools" });
38
+
39
+ expect(toolbar.classList.contains("n-gap-1")).toBe(true);
40
+ expect(toolbar.classList.contains("n-p-1")).toBe(true);
41
+ expect(toolbar.style.backgroundColor).toBe("transparent");
42
+ });
43
+ });
44
+
45
+ describe("AppToolbar", () => {
46
+ test("renders menu-driven application actions", () => {
47
+ const { getByRole } = render(
48
+ <AppToolbar
49
+ leftMenuItems={[{ title: "Undo", value: "undo" }]}
50
+ shouldBindKeyboardShortcuts={false}
51
+ />
52
+ );
53
+
54
+ expect(getByRole("button", { name: "Undo" })).toBeTruthy();
55
+ });
56
+ });
@@ -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
  };
@@ -12,6 +12,7 @@ import {
12
12
  DragStartEvent,
13
13
  Over,
14
14
  PointerSensor,
15
+ PointerSensorProps,
15
16
  pointerWithin,
16
17
  useSensor,
17
18
  useSensors,
@@ -54,6 +55,41 @@ export type SharedDragProviderProps = {
54
55
  itemHitSlop?: Insets;
55
56
  };
56
57
 
58
+ export class ContinuousPointerSensor extends PointerSensor {
59
+ constructor(props: PointerSensorProps) {
60
+ const ownerDocument =
61
+ props.event.target instanceof Node
62
+ ? (props.event.target.ownerDocument ?? document)
63
+ : document;
64
+ let latestCoordinates: Point | null = null;
65
+
66
+ const trackPointer = (event: PointerEvent) => {
67
+ latestCoordinates = { x: event.clientX, y: event.clientY };
68
+ };
69
+ const cleanup = () => {
70
+ ownerDocument.removeEventListener("pointermove", trackPointer, true);
71
+ ownerDocument.removeEventListener("pointerup", cleanup, true);
72
+ ownerDocument.removeEventListener("pointercancel", cleanup, true);
73
+ };
74
+
75
+ ownerDocument.addEventListener("pointermove", trackPointer, true);
76
+ ownerDocument.addEventListener("pointerup", cleanup, true);
77
+ ownerDocument.addEventListener("pointercancel", cleanup, true);
78
+
79
+ super({
80
+ ...props,
81
+ onStart(coordinates) {
82
+ cleanup();
83
+ props.onStart(coordinates);
84
+
85
+ if (latestCoordinates) {
86
+ props.onMove(latestCoordinates);
87
+ }
88
+ },
89
+ });
90
+ }
91
+ }
92
+
57
93
  export function SharedDragProvider({
58
94
  children,
59
95
  acceptsDrop,
@@ -65,7 +101,7 @@ export function SharedDragProvider({
65
101
  itemHitSlop,
66
102
  }: SharedDragProviderProps) {
67
103
  const sensors = useSensors(
68
- useSensor(PointerSensor, {
104
+ useSensor(ContinuousPointerSensor, {
69
105
  activationConstraint: {
70
106
  distance: 4,
71
107
  },
@@ -6,7 +6,7 @@ import { cssVarNames } from "../../theme";
6
6
  import { Button } from "../Button";
7
7
  import { Divider } from "../Divider";
8
8
  import { isSelectableMenuItem, MenuItem } from "../internal/Menu";
9
- import { ToolbarMenu } from "../Toolbar";
9
+ import { ToolbarMenu } from "../AppToolbar";
10
10
  import { EDITOR_PANEL_GROUP_ID } from "./constants";
11
11
  import { LayoutProps, PanelLayoutState } from "./types";
12
12
 
@@ -216,7 +216,7 @@ export const DrawerWorkspaceLayout = memoGeneric(function DrawerWorkspaceLayout<
216
216
  )}
217
217
 
218
218
  {/* Center content */}
219
- <div className="n-flex-1 n-flex n-relative n-min-h-0">
219
+ <div className="n-z-0 n-flex-1 n-flex n-relative n-min-h-0">
220
220
  {centerPanel}
221
221
  </div>
222
222
  </div>
@@ -2,7 +2,7 @@ import { memoGeneric } from "@noya-app/react-utils";
2
2
  import React, { ComponentProps, useMemo } from "react";
3
3
  import { cssVarNames } from "../../theme";
4
4
  import { MenuItem } from "../internal/Menu";
5
- import { ToolbarMenu } from "../Toolbar";
5
+ import { ToolbarMenu } from "../AppToolbar";
6
6
  import { Tooltip } from "../Tooltip";
7
7
 
8
8
  type VerticalTabMenuProps<TabValue extends string> = {
@@ -83,12 +83,14 @@ describe("DrawerWorkspaceLayout", () => {
83
83
  fireEvent.click(getByRole("button", { name: "Navigation" }));
84
84
  const action = getByRole("button", { name: "Drawer action" });
85
85
  const drawer = getByText("Drawer action").parentElement?.parentElement;
86
+ const center = getByText("Center content").parentElement;
86
87
 
87
88
  fireEvent.pointerDown(action, { pointerType: "touch" });
88
89
  fireEvent.pointerUp(action, { pointerType: "touch" });
89
90
  fireEvent.click(action);
90
91
 
91
92
  expect(drawer?.classList.contains("n-z-20")).toBe(true);
93
+ expect(center?.classList.contains("n-z-0")).toBe(true);
92
94
  expect(queryByRole("button", { name: "Close drawer" })).toBeNull();
93
95
  expect(touchCount).toBe(1);
94
96
  expect(leftSidebarRef.current?.isExpanded()).toBe(true);
package/src/index.css CHANGED
@@ -185,6 +185,12 @@
185
185
  }
186
186
  }
187
187
 
188
+ @media (max-width: 639px) {
189
+ :root {
190
+ --n-dialog-padding: 16px;
191
+ }
192
+ }
193
+
188
194
  [data-theme="dark"] {
189
195
  --n-background: #111111;
190
196
  --n-input-background: rgb(45 43 54);
package/src/index.tsx CHANGED
@@ -10,6 +10,7 @@ export * from "./components/Banner";
10
10
  export * from "./components/blocks/ImageBlockComponent";
11
11
  export * from "./components/Breadcrumbs";
12
12
  export * from "./components/Button";
13
+ export * from "./components/Card";
13
14
  export * from "./components/Checkbox";
14
15
  export * from "./components/Chip";
15
16
  export * from "./components/Collection";
@@ -21,6 +22,7 @@ export * from "./components/CommandPalette";
21
22
  export * from "./components/connected-users-menu/ConnectedUsersMenuLayout";
22
23
  export * from "./components/ContextMenu";
23
24
  export * from "./components/Dialog";
25
+ export * from "./components/DialogNavigator";
24
26
  export * from "./components/Divider";
25
27
  export * from "./components/DraggableMenuButton";
26
28
  export * from "./components/Drawer";
@@ -75,6 +77,7 @@ export * from "./components/sorting/Sortable";
75
77
  export * from "./components/sorting/sorting";
76
78
  export * from "./components/Spacer";
77
79
  export * from "./components/StackNavigator";
80
+ export * from "./components/StatCard";
78
81
  export * from "./components/Stepper";
79
82
  export * from "./components/Switch";
80
83
  export * from "./components/Tabs";
@@ -128,6 +131,7 @@ export * from "./components/DimensionInput";
128
131
  export * as InspectorPrimitives from "./components/InspectorPrimitives";
129
132
 
130
133
  export * from "./components/BaseToolbar";
134
+ export * from "./components/AppToolbar";
131
135
  export * from "./components/OverlayToolbar";
132
136
  export * from "./components/Toolbar";
133
137
  export * from "./components/ToolbarDrawer";