@noya-app/noya-designsystem 0.1.53 → 0.1.55

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.53",
3
+ "version": "0.1.55",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -22,10 +22,10 @@
22
22
  "dependencies": {
23
23
  "@dnd-kit/core": "6.3.1",
24
24
  "@dnd-kit/sortable": "10.0.0",
25
- "@noya-app/noya-colorpicker": "0.1.20",
25
+ "@noya-app/noya-colorpicker": "0.1.21",
26
26
  "@noya-app/noya-utils": "0.1.5",
27
27
  "@noya-app/noya-geometry": "0.1.11",
28
- "@noya-app/noya-icons": "0.1.8",
28
+ "@noya-app/noya-icons": "0.1.9",
29
29
  "@noya-app/noya-keymap": "0.1.3",
30
30
  "@noya-app/noya-tailwind-config": "0.1.3",
31
31
  "@radix-ui/primitive": "^1.0.0",
@@ -7,7 +7,10 @@ import React, {
7
7
  useMemo,
8
8
  } from "react";
9
9
  import { cx, mergeConflictingClassNames } from "../utils/classNames";
10
+ import { renderIcon } from "./Icons";
11
+ import { Spacer } from "./Spacer";
10
12
  import { Tooltip } from "./Tooltip";
13
+ import type { MenuItemIcon } from "./internal/Menu";
11
14
 
12
15
  type ButtonVariant =
13
16
  | "normal"
@@ -48,6 +51,8 @@ export interface ButtonRootProps {
48
51
  className?: string;
49
52
  style?: CSSProperties;
50
53
  tabIndex?: number;
54
+ icon?: MenuItemIcon;
55
+ iconRight?: MenuItemIcon;
51
56
  children: ReactNode;
52
57
  active?: boolean;
53
58
  disabled?: boolean;
@@ -71,6 +76,8 @@ export const Button = forwardRef(function Button(
71
76
  className,
72
77
  style,
73
78
  tabIndex,
79
+ icon,
80
+ iconRight,
74
81
  tooltip,
75
82
  active = false,
76
83
  disabled = false,
@@ -128,7 +135,13 @@ export const Button = forwardRef(function Button(
128
135
  className="min-h-[19px] flex items-center flex-1 justify-center leading-[15px]"
129
136
  style={contentStyle}
130
137
  >
138
+ {icon && renderIcon(icon)}
139
+ {icon && children && <Spacer.Horizontal inline size={6} />}
131
140
  {children}
141
+ {iconRight && (icon || children) && (
142
+ <Spacer.Horizontal inline size={6} />
143
+ )}
144
+ {iconRight && renderIcon(iconRight)}
132
145
  </span>
133
146
  </Component>
134
147
  );
@@ -1,4 +1,5 @@
1
1
  import React, { forwardRef } from "react";
2
+ import { cssVars } from "../theme";
2
3
 
3
4
  export type ColorSwatchSize = "xsmall" | "small" | "medium" | "large";
4
5
 
@@ -31,10 +32,12 @@ export const ColorSwatch = forwardRef(function ColorSwatch(
31
32
  height: sizePx,
32
33
  borderRadius: "4px",
33
34
  outline: checked
34
- ? "2px solid rgba(0,0,0,0.2)"
35
+ ? `2px solid ${cssVars.colors.primary}`
35
36
  : "1px solid rgba(0,0,0,0.1)",
36
37
  outlineOffset: -1,
37
- boxShadow: checked ? `inset 0 0 0 2px rgba(0,0,0,0.2)` : undefined,
38
+ boxShadow: checked
39
+ ? `inset 0 0 0 2px ${cssVars.colors.background}`
40
+ : undefined,
38
41
  ...style,
39
42
  }}
40
43
  />
@@ -30,9 +30,8 @@ const StyledContent = forwardRef<
30
30
  <DialogPrimitive.Content
31
31
  ref={ref}
32
32
  className={cx(
33
- `
34
- fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
35
- w-[90vw] max-w-[450px] max-h-[85vh] p-dialog-padding rounded-[2px]
33
+ `fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
34
+ w-[90vw] max-w-[550px] max-h-[85vh] p-dialog-padding rounded-[2px]
36
35
  font-sans text-heading5 font-normal leading-[19px] text-text-muted
37
36
  bg-popover-background pointer-events-all z-[1000] focus:outline-none
38
37
  shadow-dialog-shadow
@@ -54,7 +53,7 @@ const StyledTitle = forwardRef<
54
53
  <DialogPrimitive.Title
55
54
  ref={ref}
56
55
  className={cx(
57
- `m-0 font-sans text-base font-normal leading-[1.4] text-text `,
56
+ `m-0 font-sans text-heading3 font-medium leading-[1.4] text-text `,
58
57
  className
59
58
  )}
60
59
  {...props}
@@ -152,7 +151,6 @@ export const Dialog = forwardRef(function Dialog(
152
151
  )}
153
152
  {description && (
154
153
  <>
155
- <Spacer.Vertical size={10} />
156
154
  <StyledDescription>{description}</StyledDescription>
157
155
  <Spacer.Vertical size={20} />
158
156
  </>
@@ -235,7 +235,9 @@ export const Grid = memoGeneric(
235
235
  <span
236
236
  {...props}
237
237
  className={cx(
238
- isSelected && "text-selected-list-item-text",
238
+ isSelected
239
+ ? "text-selected-list-item-text"
240
+ : "text-text",
239
241
  "truncate select-none"
240
242
  )}
241
243
  />
@@ -194,6 +194,7 @@ const GridViewItem = forwardRefGeneric(function GridViewItem<
194
194
  <>
195
195
  <Spacer.Vertical size={8} />
196
196
  <ItemTitle>{title || " "}</ItemTitle>
197
+ <Spacer.Vertical size={2} />
197
198
  <ItemDescription>{subtitle || " "}</ItemDescription>
198
199
  </>
199
200
  )}
@@ -239,7 +239,7 @@ const InputElement = forwardRef(
239
239
  : size === "large"
240
240
  ? "py-2.5 text-heading5"
241
241
  : size === "medium"
242
- ? "py-1 text-heading5"
242
+ ? "py-1 text-heading5"
243
243
  : $variant === "bare" && "py-1 -m-1",
244
244
  className
245
245
  );
@@ -488,7 +488,9 @@ interface InputFieldRootProps {
488
488
  id?: string;
489
489
  children?: ReactNode;
490
490
  start?: ReactNode;
491
+ startClassName?: string;
491
492
  end?: ReactNode;
493
+ endClassName?: string;
492
494
  label?: ReactNode;
493
495
  width?: number;
494
496
  startWidth?: number;
@@ -520,6 +522,8 @@ function InputFieldRoot({
520
522
  className,
521
523
  start,
522
524
  end,
525
+ startClassName,
526
+ endClassName,
523
527
  label: labelProp,
524
528
  }: InputFieldRootProps) {
525
529
  const randomId = useId();
@@ -577,12 +581,15 @@ function InputFieldRoot({
577
581
  <RootContainer $width={width} style={style} className={className}>
578
582
  {children}
579
583
  {start && (
580
- <span ref={startRef} className={startBaseStyles}>
584
+ <span ref={startRef} className={cx(startBaseStyles, startClassName)}>
581
585
  {start}
582
586
  </span>
583
587
  )}
584
588
  {(end || label) && (
585
- <span ref={endRef} className={cx(endStyles, label && end && "gap-1.5")}>
589
+ <span
590
+ ref={endRef}
591
+ className={cx(endStyles, label && end && "gap-1.5", endClassName)}
592
+ >
586
593
  {labelProp
587
594
  ? insetLabel
588
595
  : label && labelPosition === "inset" && insetLabel}
@@ -1,5 +1,7 @@
1
+ import { useControlledOrUncontrolled } from "@noya-app/react-utils";
1
2
  import * as PopoverPrimitive from "@radix-ui/react-popover";
2
- import React, { ComponentProps } from "react";
3
+ import React, { ComponentProps, useEffect, useId, useRef } from "react";
4
+ import { useOpenPortalsControls } from "../contexts/OpenPortalsContext";
3
5
  import {
4
6
  portalScopeProps,
5
7
  usePortalScopeId,
@@ -34,6 +36,7 @@ interface Props
34
36
  onClickClose?: () => void;
35
37
  showArrow?: boolean;
36
38
  className?: string;
39
+ portalContainer?: HTMLElement | null;
37
40
  }
38
41
 
39
42
  export const popoverStyle = {
@@ -59,15 +62,36 @@ export function Popover({
59
62
  onFocusOutside,
60
63
  onClickClose,
61
64
  className,
65
+ portalContainer,
62
66
  }: Props) {
67
+ const defaultId = useId();
63
68
  const portalScopeId = usePortalScopeId();
69
+ const contentRef = useRef<HTMLDivElement>(null);
70
+ const [isOpen, setIsOpen] = useControlledOrUncontrolled({
71
+ value: open,
72
+ onChange: onOpenChange,
73
+ defaultValue: false,
74
+ });
75
+
76
+ const { addOpenPortal, removeOpenPortal } = useOpenPortalsControls();
77
+
78
+ useEffect(() => {
79
+ if (!isOpen) return;
80
+
81
+ addOpenPortal(portalScopeId || defaultId);
82
+
83
+ return () => {
84
+ removeOpenPortal(portalScopeId || defaultId);
85
+ };
86
+ }, [addOpenPortal, isOpen, portalScopeId, removeOpenPortal, defaultId]);
64
87
 
65
88
  return (
66
- <PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
89
+ <PopoverPrimitive.Root open={isOpen} onOpenChange={setIsOpen}>
67
90
  <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
68
- <PopoverPrimitive.Portal>
91
+ <PopoverPrimitive.Portal container={portalContainer}>
69
92
  <PopoverPrimitive.Content
70
93
  {...portalScopeProps(portalScopeId)}
94
+ ref={contentRef}
71
95
  className={cx(
72
96
  popoverStyle.base,
73
97
  variant === "large" && popoverStyle.large,
@@ -81,7 +105,18 @@ export function Popover({
81
105
  onCloseAutoFocus={onCloseAutoFocus}
82
106
  onInteractOutside={onInteractOutside}
83
107
  onFocusOutside={onFocusOutside}
84
- onPointerDownOutside={onPointerDownOutside}
108
+ onPointerDownOutside={(event) => {
109
+ if (
110
+ isEventInsideElement(
111
+ event.detail.originalEvent,
112
+ contentRef.current
113
+ )
114
+ ) {
115
+ event.preventDefault();
116
+ }
117
+
118
+ onPointerDownOutside?.(event);
119
+ }}
85
120
  collisionPadding={8}
86
121
  arrowPadding={2}
87
122
  // Don't propagate pointer down events to the canvas
@@ -109,3 +144,32 @@ export function Popover({
109
144
  </PopoverPrimitive.Root>
110
145
  );
111
146
  }
147
+
148
+ /**
149
+ * Radix ignores pointer-events on the body when a popover is open.
150
+ * This function checks if an event is inside an element by temporarily
151
+ * setting pointer-events to auto and then restoring it afterward.
152
+ */
153
+ function isEventInsideElement(
154
+ event: PointerEvent,
155
+ element: HTMLElement | null
156
+ ) {
157
+ if (!element) return false;
158
+
159
+ const body = document.body;
160
+ const originalPointerEvents = body.style.pointerEvents;
161
+ body.style.pointerEvents = "auto";
162
+
163
+ // Get all elements at the point
164
+ const elementsAtPoint = document.elementsFromPoint(
165
+ event.clientX,
166
+ event.clientY
167
+ );
168
+
169
+ // Restore original pointer-events
170
+ body.style.pointerEvents = originalPointerEvents;
171
+
172
+ const target = elementsAtPoint.at(0);
173
+
174
+ return target && element.contains(target);
175
+ }
@@ -15,6 +15,7 @@ import { SelectableMenuItem } from "./internal/Menu";
15
15
  import { Tooltip } from "./Tooltip";
16
16
 
17
17
  type SegmentedControlColorScheme = "primary" | "secondary";
18
+ type SegmentedControlVariant = "default" | "tabs";
18
19
 
19
20
  type SegmentedControlContextValue = {
20
21
  /** @default primary */
@@ -32,10 +33,10 @@ export interface SegmentedControlProps<T extends string> {
32
33
  /** @default primary */
33
34
  colorScheme?: SegmentedControlColorScheme;
34
35
  allowEmpty?: boolean;
35
- separator?: boolean;
36
36
  items: SegmentedControlItemProps<T>[];
37
37
  className?: string;
38
38
  style?: React.CSSProperties;
39
+ variant?: SegmentedControlVariant;
39
40
  }
40
41
 
41
42
  export type SegmentedControlItemProps<T extends string> = {
@@ -43,6 +44,7 @@ export type SegmentedControlItemProps<T extends string> = {
43
44
  className?: string;
44
45
  style?: React.CSSProperties;
45
46
  tooltip?: ReactNode;
47
+ variant?: SegmentedControlVariant;
46
48
  } & Pick<
47
49
  SelectableMenuItem<T>,
48
50
  "value" | "disabled" | "icon" | "role" | "title"
@@ -58,6 +60,7 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
58
60
  disabled = false,
59
61
  icon,
60
62
  className,
63
+ variant = "default",
61
64
  ...props
62
65
  }: SegmentedControlItemProps<T>,
63
66
  forwardedRef: React.ForwardedRef<HTMLButtonElement>
@@ -70,24 +73,42 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
70
73
  value={(value ?? "").toString()}
71
74
  disabled={disabled}
72
75
  className={cx(
73
- "font-sans text-heading5 font-normal relative flex-1 appearance-none text-segmented-control-item rounded inline-flex gap-1.5 items-center justify-center align-middle text-center focus:outline-none transition-colors",
74
- colorScheme === "secondary"
75
- ? "focus:ring-2 focus:ring-secondary focus:ring-offset-1"
76
- : "focus:ring-2 focus:ring-primary focus:ring-offset-1",
77
- colorScheme === "secondary"
78
- ? "aria-checked:bg-secondary aria-checked:text-white"
79
- : colorScheme === "primary"
80
- ? "aria-checked:bg-primary aria-checked:text-white"
81
- : "aria-checked:bg-background aria-checked:text-text",
76
+ "group",
77
+ "flex items-center justify-center",
78
+ "font-sans text-button relative flex-1 appearance-none text-segmented-control-item focus:outline-none transition-colors",
79
+ variant === "tabs" ? "font-semibold" : "font-normal",
80
+ variant === "tabs"
81
+ ? ""
82
+ : colorScheme === "secondary"
83
+ ? "focus:ring-2 focus:ring-secondary focus:ring-offset-1"
84
+ : "focus:ring-2 focus:ring-primary focus:ring-offset-1",
85
+ variant === "tabs"
86
+ ? "aria-checked:text-primary"
87
+ : colorScheme === "secondary"
88
+ ? "aria-checked:bg-secondary aria-checked:text-white"
89
+ : colorScheme === "primary"
90
+ ? "aria-checked:bg-primary aria-checked:text-white"
91
+ : "aria-checked:bg-background aria-checked:text-text",
82
92
  "focus:z-interactable",
83
- "aria-checked:shadow-segment",
84
- "whitespace-pre",
93
+ variant === "default"
94
+ ? "px-1 rounded aria-checked:shadow-segment"
95
+ : "px-1.5 border-y-2 border-y-transparent aria-checked:border-b-primary",
96
+ variant === "default" &&
97
+ "border-r border-divider last:border-r-0 aria-checked:border-r-transparent [&:has(+[aria-checked=true])]:border-r-transparent",
85
98
  className
86
99
  )}
87
100
  {...props}
88
101
  >
89
- {icon && renderIcon(icon)}
90
- {title}
102
+ <span
103
+ className={cx(
104
+ "inline-flex whitespace-pre flex-nowrap gap-1.5 items-center justify-center align-middle text-center",
105
+ variant === "tabs" &&
106
+ "group-focus:ring-2 group-focus:ring-primary-pastel group-focus:ring-offset-2"
107
+ )}
108
+ >
109
+ {icon && renderIcon(icon)}
110
+ {title}
111
+ </span>
91
112
  </ToggleGroupPrimitive.Item>
92
113
  );
93
114
 
@@ -98,15 +119,6 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
98
119
  );
99
120
  });
100
121
 
101
- const Separator = ({ transparent }: { transparent: boolean }) => (
102
- <div
103
- className={cx(
104
- "w-[1px] my-1 self-stretch bg-divider",
105
- transparent && "bg-transparent"
106
- )}
107
- />
108
- );
109
-
110
122
  export const SegmentedControl = memoGeneric(function SegmentedControl<
111
123
  T extends string,
112
124
  >({
@@ -116,7 +128,7 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
116
128
  colorScheme,
117
129
  allowEmpty,
118
130
  items,
119
- separator = true,
131
+ variant = "default",
120
132
  className,
121
133
  style,
122
134
  }: SegmentedControlProps<T>) {
@@ -131,8 +143,6 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
131
143
  [allowEmpty, onValueChange]
132
144
  );
133
145
 
134
- const selectedIndex = items.findIndex((item) => item?.value === value);
135
-
136
146
  return (
137
147
  <SegmentedControlContext.Provider value={contextValue}>
138
148
  <ToggleGroupPrimitive.Root
@@ -141,36 +151,20 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
141
151
  value={value}
142
152
  onValueChange={handleValueChange}
143
153
  className={cx(
144
- `flex items-stretch flex-auto appearance-none relative outline-none min-h-input-height rounded bg-input-background py-[2px] px-[2px]`,
154
+ `appearance-none relative outline-none`,
155
+ variant === "default" &&
156
+ "grid min-h-input-height bg-input-background rounded py-0.5 px-0.5",
157
+ variant === "tabs" && "flex gap-1.5 min-h-[33px]",
145
158
  className
146
159
  )}
147
- style={style}
160
+ style={{
161
+ ...style,
162
+ gridTemplateColumns: `repeat(${items.length}, 1fr)`,
163
+ }}
148
164
  >
149
- {separator
150
- ? items.map((item, index) => {
151
- const isLastItem = index === items.length - 1;
152
- const currentValue = item?.value;
153
-
154
- // Show separator if:
155
- // 1. Current item is not selected AND
156
- // 2. Current item is not immediately before the selected item (except when selected is 0)
157
- // 3. Current item is not immediately after the selected item
158
- const showSeparator =
159
- currentValue !== value &&
160
- (selectedIndex === 0 && selectedIndex !== index
161
- ? true
162
- : index !== selectedIndex - 1);
163
-
164
- return (
165
- <React.Fragment key={index}>
166
- <SegmentedControlItem key={index} {...item} />
167
- {!isLastItem && <Separator transparent={!showSeparator} />}
168
- </React.Fragment>
169
- );
170
- })
171
- : items.map((item, index) => (
172
- <SegmentedControlItem key={index} {...item} />
173
- ))}
165
+ {items.map((item, index) => (
166
+ <SegmentedControlItem key={index} {...item} variant={variant} />
167
+ ))}
174
168
  </ToggleGroupPrimitive.Root>
175
169
  </SegmentedControlContext.Provider>
176
170
  );
@@ -7,6 +7,7 @@ import { useSize } from "@noya-app/react-utils";
7
7
  import * as PopperPrimitive from "@radix-ui/react-popper";
8
8
  import type { MeasurableElement } from "@radix-ui/utils";
9
9
  import React, { CSSProperties, memo, useMemo } from "react";
10
+ import { createPortal } from "react-dom";
10
11
 
11
12
  // Create a Measurable from a Rect
12
13
  const createVirtualElement = (rect: Rect): MeasurableElement => ({
@@ -26,12 +27,14 @@ const createVirtualElement = (rect: Rect): MeasurableElement => ({
26
27
  type SelectionToolbarContainerProps = {
27
28
  rect: Rect;
28
29
  children: React.ReactNode;
30
+ portalContainer?: HTMLElement | null;
29
31
  };
30
32
 
31
33
  export const SelectionToolbarContainer = memo(
32
34
  function SelectionToolbarContainer({
33
35
  rect,
34
36
  children,
37
+ portalContainer,
35
38
  }: SelectionToolbarContainerProps) {
36
39
  const portalScopeId = usePortalScopeId();
37
40
  const containerRef = React.useRef<HTMLDivElement>(null);
@@ -45,31 +48,41 @@ export const SelectionToolbarContainer = memo(
45
48
  [rect]
46
49
  );
47
50
 
51
+ const popperContent = (
52
+ <PopperPrimitive.Content
53
+ {...portalScopeProps(portalScopeId)}
54
+ side="top"
55
+ sideOffset={10}
56
+ align="center"
57
+ avoidCollisions
58
+ collisionPadding={10}
59
+ contentEditable={false}
60
+ style={{
61
+ zIndex: 1000,
62
+ opacity: size ? 1 : 0,
63
+ transition: "opacity 0.2s ease-in-out",
64
+ ["--n-input-background" as keyof CSSProperties]: "transparent",
65
+ }}
66
+ >
67
+ <div
68
+ ref={containerRef}
69
+ className="flex gap-1 bg-popover-background rounded shadow-popover p-1"
70
+ onClick={(e) => e.stopPropagation()}
71
+ onMouseDown={(e) => e.stopPropagation()}
72
+ onPointerDown={(e) => e.stopPropagation()}
73
+ onWheel={(e) => e.stopPropagation()}
74
+ >
75
+ {children}
76
+ </div>
77
+ </PopperPrimitive.Content>
78
+ );
79
+
48
80
  return (
49
81
  <PopperPrimitive.Root>
50
82
  <PopperPrimitive.Anchor virtualRef={virtualRef} />
51
- <PopperPrimitive.Content
52
- {...portalScopeProps(portalScopeId)}
53
- side="top"
54
- sideOffset={10}
55
- align="center"
56
- avoidCollisions
57
- collisionPadding={10}
58
- contentEditable={false}
59
- style={{
60
- zIndex: 1000,
61
- opacity: size ? 1 : 0,
62
- transition: "opacity 0.2s ease-in-out",
63
- ["--n-input-background" as keyof CSSProperties]: "transparent",
64
- }}
65
- >
66
- <div
67
- ref={containerRef}
68
- className="flex gap-1 bg-popover-background rounded shadow-popover p-1"
69
- >
70
- {children}
71
- </div>
72
- </PopperPrimitive.Content>
83
+ {portalContainer
84
+ ? createPortal(popperContent, portalContainer)
85
+ : popperContent}
73
86
  </PopperPrimitive.Root>
74
87
  );
75
88
  }
@@ -117,7 +117,7 @@ export const Slider = memo(function Slider({
117
117
  <div
118
118
  style={trackFillStyle}
119
119
  className={cx(
120
- "absolute inset-0 w-full h-full rounded overflow-hidden",
120
+ "absolute inset-0 w-full h-full rounded overflow-hidden bg-primary-pastel",
121
121
  colorScheme === "primary" && "bg-primary",
122
122
  colorScheme === "secondary" && "bg-secondary"
123
123
  )}