@noya-app/noya-designsystem 0.1.87 → 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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +6 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +180 -117
- package/dist/index.d.ts +180 -117
- package/dist/index.js +2111 -1869
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1930 -1695
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/SharedDragProvider.test.ts +39 -0
- package/src/components/AppToolbar.tsx +464 -0
- package/src/components/Card.tsx +20 -0
- package/src/components/Dialog.tsx +12 -5
- package/src/components/DialogNavigator.tsx +154 -0
- package/src/components/Navigator.tsx +2 -0
- package/src/components/SegmentedControl.tsx +32 -4
- package/src/components/SelectionToolbar.tsx +5 -10
- package/src/components/StatCard.tsx +82 -0
- package/src/components/Toolbar.tsx +32 -459
- package/src/components/__tests__/Card.test.tsx +71 -0
- package/src/components/__tests__/DialogNavigator.test.tsx +88 -0
- package/src/components/__tests__/SegmentedControl.test.ts +46 -0
- package/src/components/__tests__/Toolbar.test.tsx +56 -0
- package/src/components/sorting/SharedDragProvider.tsx +37 -1
- package/src/components/workspace/DrawerWorkspaceLayout.tsx +2 -2
- package/src/components/workspace/VerticalTabMenu.tsx +1 -1
- package/src/components/workspace/__tests__/DrawerWorkspaceLayout.test.tsx +2 -0
- package/src/index.css +6 -0
- package/src/index.tsx +4 -0
|
@@ -1,465 +1,38 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
+
});
|