@noya-app/noya-designsystem 0.1.86 → 0.1.88

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.
@@ -1,465 +1,38 @@
1
1
  "use client";
2
2
 
3
- import { useKeyboardShortcuts } from "@noya-app/noya-keymap";
4
- import React, {
5
- ComponentProps,
6
- createContext,
7
- ReactNode,
8
- useContext,
9
- } from "react";
10
-
11
- import { memoGeneric } from "@noya-app/react-utils";
12
- import { BaseToolbar } from "./BaseToolbar";
13
- import { Button } from "./Button";
14
- import { DividerVertical } from "./Divider";
15
- import { DropdownMenu } from "./DropdownMenu";
16
- import {
17
- getKeyboardShortcutsForMenuItems,
18
- isSelectableMenuItem,
19
- KeyboardShortcut,
20
- MenuItem,
21
- PopoverMenuItem,
22
- SelectableMenuItem,
23
- SubMenuItem,
24
- } from "./internal/Menu";
25
- import { Popover } from "./Popover";
26
- import { Spacer } from "./Spacer";
27
- import { ToolbarDrawer } from "./ToolbarDrawer";
28
- import { Tooltip } from "./Tooltip";
29
-
30
- const SIDE_OFFSET = 6;
31
-
32
- export const ToolbarMenuContext = createContext<{
33
- dividerOverflow?: number;
34
- /** When true, PopoverMenuItem items show as dialogs on small screens */
35
- useDialogOnSmallScreen?: boolean;
36
- /** Function to detect if screen is small. Required when useDialogOnSmallScreen is true */
37
- isSmallScreen?: boolean;
38
- /** Function to open a dialog. Required when useDialogOnSmallScreen is true */
39
- openDialog?: (options: {
40
- title: ReactNode;
41
- className?: string;
42
- children: ReactNode | ((props: { close: () => void }) => ReactNode);
43
- }) => Promise<void> & { close: () => void };
44
- }>({});
45
-
46
- export const ToolbarMenuDropdown = memoGeneric(function ToolbarMenuDropdown<
47
- T extends string,
48
- >({
49
- item,
50
- onSelectMenuItem,
51
- onPopoverOpenChange,
52
- }: {
53
- item: SubMenuItem<T>;
54
- onSelectMenuItem?: (value: T) => void;
55
- onPopoverOpenChange?: (open: boolean) => void;
56
- }) {
57
- const [open, setOpen] = React.useState(false);
58
-
59
- const handleOpenChange = React.useCallback(
60
- (newOpen: boolean) => {
61
- setOpen(newOpen);
62
- onPopoverOpenChange?.(newOpen);
63
- },
64
- [onPopoverOpenChange]
65
- );
66
-
67
- return (
68
- <DropdownMenu
69
- open={open}
70
- onOpenChange={handleOpenChange}
71
- items={item.items}
72
- modal={false}
73
- onSelect={(value) => {
74
- if (onSelectMenuItem && value) {
75
- onSelectMenuItem(value);
76
- }
77
- }}
78
- >
79
- <Button
80
- disabled={item.disabled}
81
- active={item.checked || open}
82
- icon={item.icon}
83
- iconRight={
84
- item.icon === "DotsVerticalIcon" || item.icon === "DotsHorizontalIcon"
85
- ? undefined
86
- : "DropdownChevronIcon"
87
- }
88
- >
89
- {item.title}
90
- </Button>
91
- </DropdownMenu>
92
- );
93
- });
94
-
95
- export const ToolbarShortcut = ({
96
- shortcut,
97
- label,
98
- }: {
99
- shortcut: string;
100
- label?: ReactNode;
101
- }) => {
102
- return (
103
- <div className="n-flex n-items-center">
104
- {label}
105
- <Spacer.Horizontal size={6} />
106
- <KeyboardShortcut shortcut={shortcut} />
107
- </div>
108
- );
109
- };
110
-
111
- export const ToolbarMenuPopover = memoGeneric(function ToolbarMenuPopover({
112
- item,
113
- portalContainer,
114
- onPopoverOpenChange,
115
- }: {
116
- item: PopoverMenuItem;
117
- portalContainer?: HTMLElement | null;
118
- onPopoverOpenChange?: (open: boolean) => void;
119
- }) {
120
- const {
121
- disabled,
122
- icon,
123
- title,
124
- tooltip,
125
- content: itemContent,
126
- checked,
127
- popoverClassName,
128
- triggerProps: buttonProps,
129
- } = item;
130
- const [open, setOpen] = React.useState(false);
131
-
132
- const handleOpenChange = React.useCallback(
133
- (newOpen: boolean) => {
134
- setOpen(newOpen);
135
- onPopoverOpenChange?.(newOpen);
136
- },
137
- [onPopoverOpenChange]
138
- );
139
-
140
- const { useDialogOnSmallScreen, isSmallScreen, openDialog } =
141
- useContext(ToolbarMenuContext);
142
-
143
- const shouldUseDialog = useDialogOnSmallScreen && isSmallScreen && openDialog;
144
-
145
- const handleOpenDialog = React.useCallback(() => {
146
- if (!openDialog) return;
147
-
148
- handleOpenChange(true);
149
- const dialogContent =
150
- typeof itemContent === "function" ? (
151
- (props: { close: () => void }) => (
152
- <div className="-n-m-3">{itemContent(props)}</div>
153
- )
154
- ) : (
155
- <div className="-n-m-3">{itemContent}</div>
156
- );
157
-
158
- const dialog = openDialog({
159
- title,
160
- className: "n-w-[90vw] n-max-w-[360px]",
161
- children: dialogContent,
162
- });
163
- dialog.then(() => handleOpenChange(false));
164
- }, [openDialog, itemContent, title, handleOpenChange]);
165
-
166
- const trigger = (
167
- <Button
168
- disabled={disabled}
169
- active={checked || open}
170
- icon={icon}
171
- iconRight="DropdownChevronIcon"
172
- onClick={shouldUseDialog ? handleOpenDialog : undefined}
173
- {...buttonProps}
174
- >
175
- {title}
176
- </Button>
177
- );
178
-
179
- // On small screens with dialog mode enabled, just render the trigger button
180
- if (shouldUseDialog) {
181
- return tooltip && !open ? (
182
- <Tooltip
183
- sideOffset={SIDE_OFFSET}
184
- side="top"
185
- content={tooltip}
186
- portalContainer={portalContainer}
187
- >
188
- {trigger}
189
- </Tooltip>
190
- ) : (
191
- trigger
192
- );
193
- }
194
-
195
- const content = (
196
- <span>
197
- <Popover
198
- open={open}
199
- onOpenChange={handleOpenChange}
200
- side="top"
201
- showArrow={false}
202
- sideOffset={SIDE_OFFSET}
203
- portalContainer={portalContainer}
204
- trigger={trigger}
205
- className={popoverClassName}
206
- >
207
- {typeof itemContent === "function"
208
- ? itemContent({ close: () => handleOpenChange(false) })
209
- : itemContent}
210
- </Popover>
211
- </span>
212
- );
213
-
214
- return tooltip && !open ? (
215
- <Tooltip
216
- sideOffset={SIDE_OFFSET}
217
- side="top"
218
- content={tooltip}
219
- portalContainer={portalContainer}
220
- >
221
- {content}
222
- </Tooltip>
223
- ) : (
224
- content
225
- );
226
- });
227
-
228
- export const ToolbarMenuButton = memoGeneric(function ToolbarMenuButton<
229
- T extends string,
230
- >({
231
- item,
232
- onSelectMenuItem,
233
- as,
234
- activeValue,
235
- tooltipSide,
236
- displayFilter,
237
- }: {
238
- item: SelectableMenuItem<T>;
239
- onSelectMenuItem?: (value: T) => void;
240
- as?: ComponentProps<typeof Button>["as"];
241
- activeValue?: T;
242
- tooltipSide?: ComponentProps<typeof Tooltip>["side"];
243
- displayFilter?: DisplayFilter;
244
- }) {
245
- const isActive = item.checked || activeValue === item.value;
246
-
247
- const content = (
248
- <Button
249
- as={as}
250
- disabled={item.disabled}
251
- active={isActive}
252
- icon={displayFilter === "textOnly" ? undefined : item.icon}
253
- {...(as === "a" && { href: item.value })}
254
- onClick={() => {
255
- if (onSelectMenuItem && item.value) {
256
- onSelectMenuItem(item.value);
257
- }
258
- }}
259
- >
260
- {displayFilter === "iconOnly" ? undefined : item.title}
261
- </Button>
262
- );
263
-
264
- const tooltip =
265
- item.tooltip ??
266
- (displayFilter === "iconOnly" && item.title ? item.title : undefined);
267
-
268
- return item.drawer && isActive ? (
269
- <ToolbarDrawer trigger={content}>{item.drawer}</ToolbarDrawer>
270
- ) : tooltip ? (
271
- <Tooltip sideOffset={SIDE_OFFSET} content={tooltip} side={tooltipSide}>
272
- {content}
273
- </Tooltip>
274
- ) : (
275
- content
276
- );
277
- });
278
-
279
- export const ToolbarMenuItem = memoGeneric(function ToolbarMenuItem<
280
- T extends string,
281
- >({
282
- item,
283
- onSelectMenuItem,
284
- buttonAs,
285
- activeValue,
286
- tooltipSide,
287
- portalContainer,
288
- displayFilter,
289
- dividerOverflow: dividerOverflowProp,
290
- onPopoverOpenChange,
291
- }: {
292
- item: MenuItem<T>;
293
- onSelectMenuItem?: (value: T) => void;
294
- buttonAs?: ComponentProps<typeof Button>["as"];
295
- activeValue?: T;
296
- tooltipSide?: ComponentProps<typeof Tooltip>["side"];
297
- portalContainer?: HTMLElement | null;
298
- displayFilter?: DisplayFilter;
299
- dividerOverflow?: number;
300
- onPopoverOpenChange?: (open: boolean) => void;
301
- }) {
302
- const { dividerOverflow: contextDividerOverflow } =
303
- useContext(ToolbarMenuContext);
304
-
305
- const dividerOverflow = dividerOverflowProp ?? contextDividerOverflow ?? 4;
306
-
307
- if (item.type === "sectionHeader") return null;
308
-
309
- if (item.type === "separator") {
310
- return <DividerVertical overflow={dividerOverflow} />;
311
- }
312
-
313
- if (item.type === "popover") {
314
- return (
315
- <ToolbarMenuPopover
316
- item={item}
317
- portalContainer={portalContainer}
318
- onPopoverOpenChange={onPopoverOpenChange}
319
- />
320
- );
321
- }
3
+ import React, { forwardRef, HTMLAttributes } from "react";
4
+ import { cx } from "../utils/classNames";
5
+ import { toolbarTransparentStyle } from "./BaseToolbar";
6
+
7
+ export type ToolbarSize = "small" | "medium";
8
+
9
+ export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
10
+ /**
11
+ * Controls the spacing between toolbar controls.
12
+ * @default "medium"
13
+ */
14
+ size?: ToolbarSize;
15
+ }
322
16
 
323
- if (isSelectableMenuItem(item)) {
17
+ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(
18
+ function Toolbar(
19
+ { children, className, role = "toolbar", size = "medium", style, ...props },
20
+ ref
21
+ ) {
324
22
  return (
325
- <ToolbarMenuButton
326
- item={item}
327
- onSelectMenuItem={onSelectMenuItem}
328
- as={buttonAs}
329
- activeValue={activeValue}
330
- tooltipSide={tooltipSide}
331
- displayFilter={displayFilter}
332
- />
23
+ <div
24
+ ref={ref}
25
+ role={role}
26
+ className={cx(
27
+ "n-pointer-events-auto n-flex n-items-center n-rounded n-bg-popover-background n-shadow-popover",
28
+ size === "small" ? "n-gap-1 n-p-1" : "n-gap-toolbar-separator n-p-2",
29
+ className
30
+ )}
31
+ style={{ ...toolbarTransparentStyle, ...style }}
32
+ {...props}
33
+ >
34
+ {children}
35
+ </div>
333
36
  );
334
37
  }
335
-
336
- return (
337
- <ToolbarMenuDropdown
338
- item={item}
339
- onSelectMenuItem={onSelectMenuItem}
340
- onPopoverOpenChange={onPopoverOpenChange}
341
- />
342
- );
343
- });
344
-
345
- type DisplayFilter = "iconOnly" | "iconAndText" | "textOnly";
346
-
347
- export const ToolbarMenu = memoGeneric(function ToolbarMenu<T extends string>({
348
- items,
349
- onSelectMenuItem,
350
- buttonAs,
351
- activeValue,
352
- tooltipSide,
353
- portalContainer,
354
- displayFilter = "iconAndText",
355
- dividerOverflow,
356
- onPopoverOpenChange,
357
- }: {
358
- items: MenuItem<T>[];
359
- onSelectMenuItem?: (value: T) => void;
360
- buttonAs?: ComponentProps<typeof Button>["as"];
361
- activeValue?: T;
362
- tooltipSide?: ComponentProps<typeof Tooltip>["side"];
363
- portalContainer?: HTMLElement | null;
364
- displayFilter?: DisplayFilter;
365
- dividerOverflow?: number;
366
- onPopoverOpenChange?: (open: boolean) => void;
367
- }) {
368
- return (
369
- <>
370
- {items.map((item, i) => {
371
- return (
372
- <ToolbarMenuItem
373
- key={i}
374
- item={item}
375
- onSelectMenuItem={onSelectMenuItem}
376
- buttonAs={buttonAs}
377
- activeValue={activeValue}
378
- tooltipSide={tooltipSide}
379
- portalContainer={portalContainer}
380
- displayFilter={displayFilter}
381
- dividerOverflow={dividerOverflow}
382
- onPopoverOpenChange={onPopoverOpenChange}
383
- />
384
- );
385
- })}
386
- </>
387
- );
388
- });
389
-
390
- export interface ToolbarProps<T extends string = string> {
391
- children?: React.ReactNode;
392
- logo?: React.ReactNode;
393
- leftMenuItems?: MenuItem<T>[];
394
- rightMenuItems?: MenuItem<T>[];
395
- onSelectMenuItem?: (value: T) => void;
396
- /** Whether to bind keyboard shortcuts for menu items. @default true */
397
- shouldBindKeyboardShortcuts?: boolean;
398
- dividerOverflow?: number;
399
- }
400
-
401
- export function useBindKeyboardShortcutsForMenuItems<T extends string>(
402
- items: MenuItem<T>[],
403
- onSelectMenuItem?: (value: T) => void
404
- ) {
405
- return useKeyboardShortcuts(
406
- React.useMemo(
407
- () =>
408
- onSelectMenuItem
409
- ? getKeyboardShortcutsForMenuItems(items, onSelectMenuItem)
410
- : {},
411
- [items, onSelectMenuItem]
412
- )
413
- );
414
- }
415
-
416
- export function Toolbar<T extends string = string>({
417
- children,
418
- logo,
419
- leftMenuItems = [],
420
- rightMenuItems = [],
421
- onSelectMenuItem,
422
- shouldBindKeyboardShortcuts = true,
423
- dividerOverflow,
424
- }: ToolbarProps<T>) {
425
- // Register keyboard shortcuts for all menu items
426
- const allMenuItems = React.useMemo(
427
- () => [...leftMenuItems, ...rightMenuItems],
428
- [leftMenuItems, rightMenuItems]
429
- );
430
-
431
- useBindKeyboardShortcutsForMenuItems(
432
- allMenuItems,
433
- shouldBindKeyboardShortcuts ? onSelectMenuItem : undefined
434
- );
435
-
436
- return (
437
- <BaseToolbar
438
- logo={logo}
439
- left={
440
- leftMenuItems.length > 0 && (
441
- <div className="n-flex n-gap-2">
442
- <ToolbarMenu
443
- items={leftMenuItems}
444
- onSelectMenuItem={onSelectMenuItem}
445
- dividerOverflow={dividerOverflow}
446
- />
447
- </div>
448
- )
449
- }
450
- right={
451
- rightMenuItems.length > 0 && (
452
- <div className="n-flex n-gap-2">
453
- <ToolbarMenu
454
- items={rightMenuItems}
455
- onSelectMenuItem={onSelectMenuItem}
456
- dividerOverflow={dividerOverflow}
457
- />
458
- </div>
459
- )
460
- }
461
- >
462
- {children}
463
- </BaseToolbar>
464
- );
465
- }
38
+ );
@@ -0,0 +1,71 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import React, { createRef } from "react";
3
+
4
+ // `@radix-ui/react-icons` has a nested React 18 copy in node_modules, which
5
+ // creates legacy React elements that React 19 refuses to render under
6
+ // `bun test` (app bundlers alias react, so this only affects tests).
7
+ const actualIcons = await import("@radix-ui/react-icons");
8
+
9
+ mock.module("@radix-ui/react-icons", () => ({
10
+ ...actualIcons,
11
+ ArchiveIcon: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
12
+ ArrowUpIcon: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
13
+ }));
14
+
15
+ const { cleanup, render } = await import("@testing-library/react");
16
+ const { Card } = await import("../Card");
17
+ const { StatCard } = await import("../StatCard");
18
+
19
+ afterEach(cleanup);
20
+
21
+ describe("Card", () => {
22
+ test("renders a bordered container and forwards div props", () => {
23
+ const ref = createRef<HTMLDivElement>();
24
+ const { getByTestId } = render(
25
+ <Card ref={ref} className="custom-card" data-testid="card">
26
+ Content
27
+ </Card>
28
+ );
29
+ const card = getByTestId("card");
30
+
31
+ expect(card).toBe(ref.current);
32
+ expect(card.textContent).toBe("Content");
33
+ expect(card.className).toContain("n-border");
34
+ expect(card.className).toContain("n-p-4");
35
+ expect(card.className).toContain("custom-card");
36
+ });
37
+ });
38
+
39
+ describe("StatCard", () => {
40
+ test("renders its icon, label, value, and upward change", () => {
41
+ const { container, getByText } = render(
42
+ <StatCard
43
+ label="Conversion rate"
44
+ value="99%"
45
+ change="10%"
46
+ trend="up"
47
+ icon="ArchiveIcon"
48
+ colorScheme="error"
49
+ />
50
+ );
51
+
52
+ expect(getByText("Conversion rate")).toBeTruthy();
53
+ const value = getByText("99%");
54
+
55
+ expect(value.tagName).toBe("DIV");
56
+ expect(value.parentElement?.className).toContain("n-self-stretch");
57
+ expect(value.parentElement?.className).toContain("n-justify-center");
58
+ expect(getByText("10%")).toBeTruthy();
59
+ expect(container.querySelectorAll("svg")).toHaveLength(2);
60
+ expect(container.querySelector(".n-bg-banner-error-text")).toBeTruthy();
61
+ });
62
+
63
+ test("omits optional icon and change", () => {
64
+ const { container, queryByText } = render(
65
+ <StatCard label="Active users" value="1,024" />
66
+ );
67
+
68
+ expect(queryByText("Active users")).toBeTruthy();
69
+ expect(container.querySelector("svg")).toBeNull();
70
+ });
71
+ });
@@ -0,0 +1,88 @@
1
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, mock, test } from "bun:test";
3
+ import React from "react";
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`. Mock the header icons so everything renders with one React.
8
+ const actualIcons = await import("@radix-ui/react-icons");
9
+
10
+ mock.module("@radix-ui/react-icons", () => ({
11
+ ...actualIcons,
12
+ ArrowLeftIcon: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
13
+ Cross1Icon: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
14
+ }));
15
+
16
+ const { createDialogNavigator } = await import("../DialogNavigator");
17
+
18
+ const TestNavigator = createDialogNavigator<{
19
+ root: null;
20
+ details: null;
21
+ }>();
22
+
23
+ function RootScreen() {
24
+ const navigation = TestNavigator.useNavigator();
25
+
26
+ return <button onClick={() => navigation.push("details")}>Next</button>;
27
+ }
28
+
29
+ function TestDialogNavigator({
30
+ onOpenChange,
31
+ }: {
32
+ onOpenChange: (open: boolean) => void;
33
+ }) {
34
+ return (
35
+ <TestNavigator
36
+ title="Picker dialog"
37
+ open
38
+ onOpenChange={onOpenChange}
39
+ initialRoute="root"
40
+ >
41
+ <TestNavigator.Screen
42
+ name="root"
43
+ title="Choose an image"
44
+ showBackButton={false}
45
+ >
46
+ <RootScreen />
47
+ </TestNavigator.Screen>
48
+ <TestNavigator.Screen name="details" title="Choose a file">
49
+ <div>Details</div>
50
+ </TestNavigator.Screen>
51
+ </TestNavigator>
52
+ );
53
+ }
54
+
55
+ afterEach(cleanup);
56
+
57
+ describe("DialogNavigator", () => {
58
+ test("renders one header with no dialog padding", () => {
59
+ const view = render(<TestDialogNavigator onOpenChange={() => {}} />);
60
+
61
+ expect(view.getAllByText("Choose an image")).toHaveLength(1);
62
+ expect(view.getByRole("dialog").className).toContain("n-p-0");
63
+ expect(view.queryByRole("button", { name: "Back" })).toBeNull();
64
+ expect(view.getByRole("button", { name: "Close" })).toBeTruthy();
65
+ });
66
+
67
+ test("combines navigation and dialog controls in the header", async () => {
68
+ const onOpenChange = mock(() => {});
69
+ const view = render(<TestDialogNavigator onOpenChange={onOpenChange} />);
70
+
71
+ fireEvent.click(view.getByRole("button", { name: "Next" }));
72
+
73
+ await waitFor(() => {
74
+ expect(view.getByText("Choose a file")).toBeTruthy();
75
+ });
76
+ expect(view.getByRole("button", { name: "Back" })).toBeTruthy();
77
+ expect(view.getByRole("button", { name: "Close" })).toBeTruthy();
78
+
79
+ fireEvent.click(view.getByRole("button", { name: "Back" }));
80
+
81
+ await waitFor(() => {
82
+ expect(view.getByText("Choose an image")).toBeTruthy();
83
+ });
84
+
85
+ fireEvent.click(view.getByRole("button", { name: "Close" }));
86
+ expect(onOpenChange).toHaveBeenCalledWith(false);
87
+ });
88
+ });
@@ -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
+ });