@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -0,0 +1,39 @@
1
+ import type { PointerSensorProps } from "@dnd-kit/core";
2
+ import { describe, expect, test } from "bun:test";
3
+ import { ContinuousPointerSensor } from "../components/sorting/SharedDragProvider";
4
+
5
+ describe("ContinuousPointerSensor", () => {
6
+ test("forwards the movement that activates a distance-constrained drag", () => {
7
+ const target = document.createElement("div");
8
+ document.body.appendChild(target);
9
+ const pointerDown = new PointerEvent("pointerdown", {
10
+ clientX: 10,
11
+ clientY: 20,
12
+ });
13
+ target.dispatchEvent(pointerDown);
14
+
15
+ const events: string[] = [];
16
+ const props = {
17
+ active: "item",
18
+ activeNode: {},
19
+ context: { current: {} },
20
+ event: pointerDown,
21
+ options: { activationConstraint: { distance: 4 } },
22
+ onAbort: () => {},
23
+ onPending: () => {},
24
+ onStart: () => events.push("start"),
25
+ onMove: ({ x, y }) => events.push(`move:${x},${y}`),
26
+ onEnd: () => events.push("end"),
27
+ onCancel: () => {},
28
+ } as PointerSensorProps;
29
+
30
+ new ContinuousPointerSensor(props);
31
+ document.dispatchEvent(
32
+ new PointerEvent("pointermove", { clientX: 30, clientY: 40 })
33
+ );
34
+ document.dispatchEvent(new PointerEvent("pointerup"));
35
+
36
+ expect(events).toEqual(["start", "move:30,40", "end"]);
37
+ target.remove();
38
+ });
39
+ });
@@ -0,0 +1,464 @@
1
+ "use client";
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
+ children: dialogContent,
161
+ });
162
+ dialog.then(() => handleOpenChange(false));
163
+ }, [openDialog, itemContent, title, handleOpenChange]);
164
+
165
+ const trigger = (
166
+ <Button
167
+ disabled={disabled}
168
+ active={checked || open}
169
+ icon={icon}
170
+ iconRight="DropdownChevronIcon"
171
+ onClick={shouldUseDialog ? handleOpenDialog : undefined}
172
+ {...buttonProps}
173
+ >
174
+ {title}
175
+ </Button>
176
+ );
177
+
178
+ // On small screens with dialog mode enabled, just render the trigger button
179
+ if (shouldUseDialog) {
180
+ return tooltip && !open ? (
181
+ <Tooltip
182
+ sideOffset={SIDE_OFFSET}
183
+ side="top"
184
+ content={tooltip}
185
+ portalContainer={portalContainer}
186
+ >
187
+ {trigger}
188
+ </Tooltip>
189
+ ) : (
190
+ trigger
191
+ );
192
+ }
193
+
194
+ const content = (
195
+ <span>
196
+ <Popover
197
+ open={open}
198
+ onOpenChange={handleOpenChange}
199
+ side="top"
200
+ showArrow={false}
201
+ sideOffset={SIDE_OFFSET}
202
+ portalContainer={portalContainer}
203
+ trigger={trigger}
204
+ className={popoverClassName}
205
+ >
206
+ {typeof itemContent === "function"
207
+ ? itemContent({ close: () => handleOpenChange(false) })
208
+ : itemContent}
209
+ </Popover>
210
+ </span>
211
+ );
212
+
213
+ return tooltip && !open ? (
214
+ <Tooltip
215
+ sideOffset={SIDE_OFFSET}
216
+ side="top"
217
+ content={tooltip}
218
+ portalContainer={portalContainer}
219
+ >
220
+ {content}
221
+ </Tooltip>
222
+ ) : (
223
+ content
224
+ );
225
+ });
226
+
227
+ export const ToolbarMenuButton = memoGeneric(function ToolbarMenuButton<
228
+ T extends string,
229
+ >({
230
+ item,
231
+ onSelectMenuItem,
232
+ as,
233
+ activeValue,
234
+ tooltipSide,
235
+ displayFilter,
236
+ }: {
237
+ item: SelectableMenuItem<T>;
238
+ onSelectMenuItem?: (value: T) => void;
239
+ as?: ComponentProps<typeof Button>["as"];
240
+ activeValue?: T;
241
+ tooltipSide?: ComponentProps<typeof Tooltip>["side"];
242
+ displayFilter?: DisplayFilter;
243
+ }) {
244
+ const isActive = item.checked || activeValue === item.value;
245
+
246
+ const content = (
247
+ <Button
248
+ as={as}
249
+ disabled={item.disabled}
250
+ active={isActive}
251
+ icon={displayFilter === "textOnly" ? undefined : item.icon}
252
+ {...(as === "a" && { href: item.value })}
253
+ onClick={() => {
254
+ if (onSelectMenuItem && item.value) {
255
+ onSelectMenuItem(item.value);
256
+ }
257
+ }}
258
+ >
259
+ {displayFilter === "iconOnly" ? undefined : item.title}
260
+ </Button>
261
+ );
262
+
263
+ const tooltip =
264
+ item.tooltip ??
265
+ (displayFilter === "iconOnly" && item.title ? item.title : undefined);
266
+
267
+ return item.drawer && isActive ? (
268
+ <ToolbarDrawer trigger={content}>{item.drawer}</ToolbarDrawer>
269
+ ) : tooltip ? (
270
+ <Tooltip sideOffset={SIDE_OFFSET} content={tooltip} side={tooltipSide}>
271
+ {content}
272
+ </Tooltip>
273
+ ) : (
274
+ content
275
+ );
276
+ });
277
+
278
+ export const ToolbarMenuItem = memoGeneric(function ToolbarMenuItem<
279
+ T extends string,
280
+ >({
281
+ item,
282
+ onSelectMenuItem,
283
+ buttonAs,
284
+ activeValue,
285
+ tooltipSide,
286
+ portalContainer,
287
+ displayFilter,
288
+ dividerOverflow: dividerOverflowProp,
289
+ onPopoverOpenChange,
290
+ }: {
291
+ item: MenuItem<T>;
292
+ onSelectMenuItem?: (value: T) => void;
293
+ buttonAs?: ComponentProps<typeof Button>["as"];
294
+ activeValue?: T;
295
+ tooltipSide?: ComponentProps<typeof Tooltip>["side"];
296
+ portalContainer?: HTMLElement | null;
297
+ displayFilter?: DisplayFilter;
298
+ dividerOverflow?: number;
299
+ onPopoverOpenChange?: (open: boolean) => void;
300
+ }) {
301
+ const { dividerOverflow: contextDividerOverflow } =
302
+ useContext(ToolbarMenuContext);
303
+
304
+ const dividerOverflow = dividerOverflowProp ?? contextDividerOverflow ?? 4;
305
+
306
+ if (item.type === "sectionHeader") return null;
307
+
308
+ if (item.type === "separator") {
309
+ return <DividerVertical overflow={dividerOverflow} />;
310
+ }
311
+
312
+ if (item.type === "popover") {
313
+ return (
314
+ <ToolbarMenuPopover
315
+ item={item}
316
+ portalContainer={portalContainer}
317
+ onPopoverOpenChange={onPopoverOpenChange}
318
+ />
319
+ );
320
+ }
321
+
322
+ if (isSelectableMenuItem(item)) {
323
+ return (
324
+ <ToolbarMenuButton
325
+ item={item}
326
+ onSelectMenuItem={onSelectMenuItem}
327
+ as={buttonAs}
328
+ activeValue={activeValue}
329
+ tooltipSide={tooltipSide}
330
+ displayFilter={displayFilter}
331
+ />
332
+ );
333
+ }
334
+
335
+ return (
336
+ <ToolbarMenuDropdown
337
+ item={item}
338
+ onSelectMenuItem={onSelectMenuItem}
339
+ onPopoverOpenChange={onPopoverOpenChange}
340
+ />
341
+ );
342
+ });
343
+
344
+ type DisplayFilter = "iconOnly" | "iconAndText" | "textOnly";
345
+
346
+ export const ToolbarMenu = memoGeneric(function ToolbarMenu<T extends string>({
347
+ items,
348
+ onSelectMenuItem,
349
+ buttonAs,
350
+ activeValue,
351
+ tooltipSide,
352
+ portalContainer,
353
+ displayFilter = "iconAndText",
354
+ dividerOverflow,
355
+ onPopoverOpenChange,
356
+ }: {
357
+ items: MenuItem<T>[];
358
+ onSelectMenuItem?: (value: T) => void;
359
+ buttonAs?: ComponentProps<typeof Button>["as"];
360
+ activeValue?: T;
361
+ tooltipSide?: ComponentProps<typeof Tooltip>["side"];
362
+ portalContainer?: HTMLElement | null;
363
+ displayFilter?: DisplayFilter;
364
+ dividerOverflow?: number;
365
+ onPopoverOpenChange?: (open: boolean) => void;
366
+ }) {
367
+ return (
368
+ <>
369
+ {items.map((item, i) => {
370
+ return (
371
+ <ToolbarMenuItem
372
+ key={i}
373
+ item={item}
374
+ onSelectMenuItem={onSelectMenuItem}
375
+ buttonAs={buttonAs}
376
+ activeValue={activeValue}
377
+ tooltipSide={tooltipSide}
378
+ portalContainer={portalContainer}
379
+ displayFilter={displayFilter}
380
+ dividerOverflow={dividerOverflow}
381
+ onPopoverOpenChange={onPopoverOpenChange}
382
+ />
383
+ );
384
+ })}
385
+ </>
386
+ );
387
+ });
388
+
389
+ export interface AppToolbarProps<T extends string = string> {
390
+ children?: React.ReactNode;
391
+ logo?: React.ReactNode;
392
+ leftMenuItems?: MenuItem<T>[];
393
+ rightMenuItems?: MenuItem<T>[];
394
+ onSelectMenuItem?: (value: T) => void;
395
+ /** Whether to bind keyboard shortcuts for menu items. @default true */
396
+ shouldBindKeyboardShortcuts?: boolean;
397
+ dividerOverflow?: number;
398
+ }
399
+
400
+ export function useBindKeyboardShortcutsForMenuItems<T extends string>(
401
+ items: MenuItem<T>[],
402
+ onSelectMenuItem?: (value: T) => void
403
+ ) {
404
+ return useKeyboardShortcuts(
405
+ React.useMemo(
406
+ () =>
407
+ onSelectMenuItem
408
+ ? getKeyboardShortcutsForMenuItems(items, onSelectMenuItem)
409
+ : {},
410
+ [items, onSelectMenuItem]
411
+ )
412
+ );
413
+ }
414
+
415
+ export function AppToolbar<T extends string = string>({
416
+ children,
417
+ logo,
418
+ leftMenuItems = [],
419
+ rightMenuItems = [],
420
+ onSelectMenuItem,
421
+ shouldBindKeyboardShortcuts = true,
422
+ dividerOverflow,
423
+ }: AppToolbarProps<T>) {
424
+ // Register keyboard shortcuts for all menu items
425
+ const allMenuItems = React.useMemo(
426
+ () => [...leftMenuItems, ...rightMenuItems],
427
+ [leftMenuItems, rightMenuItems]
428
+ );
429
+
430
+ useBindKeyboardShortcutsForMenuItems(
431
+ allMenuItems,
432
+ shouldBindKeyboardShortcuts ? onSelectMenuItem : undefined
433
+ );
434
+
435
+ return (
436
+ <BaseToolbar
437
+ logo={logo}
438
+ left={
439
+ leftMenuItems.length > 0 && (
440
+ <div className="n-flex n-gap-2">
441
+ <ToolbarMenu
442
+ items={leftMenuItems}
443
+ onSelectMenuItem={onSelectMenuItem}
444
+ dividerOverflow={dividerOverflow}
445
+ />
446
+ </div>
447
+ )
448
+ }
449
+ right={
450
+ rightMenuItems.length > 0 && (
451
+ <div className="n-flex n-gap-2">
452
+ <ToolbarMenu
453
+ items={rightMenuItems}
454
+ onSelectMenuItem={onSelectMenuItem}
455
+ dividerOverflow={dividerOverflow}
456
+ />
457
+ </div>
458
+ )
459
+ }
460
+ >
461
+ {children}
462
+ </BaseToolbar>
463
+ );
464
+ }
@@ -0,0 +1,20 @@
1
+ import React, { forwardRef } from "react";
2
+ import { cx } from "../utils/classNames";
3
+
4
+ export type CardProps = React.HTMLAttributes<HTMLDivElement>;
5
+
6
+ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
7
+ { className, ...props },
8
+ forwardedRef
9
+ ) {
10
+ return (
11
+ <div
12
+ ref={forwardedRef}
13
+ className={cx(
14
+ "n-box-border n-rounded-lg n-border n-border-divider-subtle n-bg-canvas-background n-p-4",
15
+ className
16
+ )}
17
+ {...props}
18
+ />
19
+ );
20
+ });
@@ -32,7 +32,7 @@ const StyledPopup = forwardRef<
32
32
  <DialogPrimitive.Popup
33
33
  ref={ref}
34
34
  className={cx(
35
- `n-fixed n-top-1/2 n-left-1/2 -n-translate-x-1/2 -n-translate-y-1/2 n-w-[90vw] n-max-w-[550px] n-max-h-[85vh] n-p-dialog-padding n-rounded-[2px] n-font-sans n-text-heading5 n-font-normal n-leading-[19px] n-text-text-muted n-bg-popover-background n-pointer-events-all n-z-[1000] focus:n-outline-none n-shadow-dialog-shadow n-flex n-flex-col`,
35
+ `n-fixed n-top-1/2 n-left-1/2 -n-translate-x-1/2 -n-translate-y-1/2 n-w-[calc(100vw-16px)] sm:n-w-[90vw] n-max-w-[550px] n-max-h-[85vh] n-p-dialog-padding n-rounded-[2px] n-font-sans n-text-heading5 n-font-normal n-leading-[19px] n-text-text-muted n-bg-popover-background n-pointer-events-all n-z-[1000] focus:n-outline-none n-shadow-dialog-shadow n-flex n-flex-col`,
36
36
  className
37
37
  )}
38
38
  {...props}
@@ -74,8 +74,9 @@ export interface IDialog {
74
74
  containsElement: (element: HTMLElement) => boolean;
75
75
  }
76
76
 
77
- interface Props {
77
+ export interface DialogProps {
78
78
  title?: ReactNode;
79
+ titleVisibility?: "visible" | "hidden";
79
80
  description?: ReactNode;
80
81
  children?: ReactNode;
81
82
  style?: React.CSSProperties;
@@ -90,6 +91,7 @@ export const Dialog = forwardRef(function Dialog(
90
91
  {
91
92
  children,
92
93
  title,
94
+ titleVisibility = "visible",
93
95
  description,
94
96
  open,
95
97
  style,
@@ -97,7 +99,7 @@ export const Dialog = forwardRef(function Dialog(
97
99
  closeOnInteractOutside: _closeOnInteractOutside = true,
98
100
  className,
99
101
  showCloseButton = true,
100
- }: Props,
102
+ }: DialogProps,
101
103
  forwardedRef: ForwardedRef<IDialog>
102
104
  ) {
103
105
  const contentRef = useRef<HTMLDivElement>(null);
@@ -142,12 +144,17 @@ export const Dialog = forwardRef(function Dialog(
142
144
  }
143
145
  />
144
146
  )}
145
- {title && (
147
+ {title && titleVisibility === "visible" && (
146
148
  <>
147
149
  <StyledTitle>{title}</StyledTitle>
148
150
  <Spacer.Vertical size={description ? 10 : 20} />
149
151
  </>
150
152
  )}
153
+ {title && titleVisibility === "hidden" && (
154
+ <VisuallyHidden>
155
+ <StyledTitle>{title}</StyledTitle>
156
+ </VisuallyHidden>
157
+ )}
151
158
  {!title && (
152
159
  <VisuallyHidden>
153
160
  <StyledTitle>Dialog</StyledTitle>
@@ -167,7 +174,7 @@ export const Dialog = forwardRef(function Dialog(
167
174
  });
168
175
 
169
176
  export const FullscreenDialog = forwardRef(function FullscreenDialog(
170
- { style, ...rest }: Props,
177
+ { style, ...rest }: DialogProps,
171
178
  forwardedRef: ForwardedRef<IDialog>
172
179
  ) {
173
180
  return (