@noya-app/noya-designsystem 0.1.54 → 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.54",
3
+ "version": "0.1.55",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -25,7 +25,7 @@
25
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
  );
@@ -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 */
@@ -35,6 +36,7 @@ export interface SegmentedControlProps<T extends string> {
35
36
  items: SegmentedControlItemProps<T>[];
36
37
  className?: string;
37
38
  style?: React.CSSProperties;
39
+ variant?: SegmentedControlVariant;
38
40
  }
39
41
 
40
42
  export type SegmentedControlItemProps<T extends string> = {
@@ -42,6 +44,7 @@ export type SegmentedControlItemProps<T extends string> = {
42
44
  className?: string;
43
45
  style?: React.CSSProperties;
44
46
  tooltip?: ReactNode;
47
+ variant?: SegmentedControlVariant;
45
48
  } & Pick<
46
49
  SelectableMenuItem<T>,
47
50
  "value" | "disabled" | "icon" | "role" | "title"
@@ -57,6 +60,7 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
57
60
  disabled = false,
58
61
  icon,
59
62
  className,
63
+ variant = "default",
60
64
  ...props
61
65
  }: SegmentedControlItemProps<T>,
62
66
  forwardedRef: React.ForwardedRef<HTMLButtonElement>
@@ -69,27 +73,42 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
69
73
  value={(value ?? "").toString()}
70
74
  disabled={disabled}
71
75
  className={cx(
72
- "font-sans text-button 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",
73
- colorScheme === "secondary"
74
- ? "focus:ring-2 focus:ring-secondary focus:ring-offset-1"
75
- : "focus:ring-2 focus:ring-primary focus:ring-offset-1",
76
- colorScheme === "secondary"
77
- ? "aria-checked:bg-secondary aria-checked:text-white"
78
- : colorScheme === "primary"
79
- ? "aria-checked:bg-primary aria-checked:text-white"
80
- : "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",
81
92
  "focus:z-interactable",
82
- "aria-checked:shadow-segment",
83
- "whitespace-pre px-1",
84
- "border-r border-divider last:border-r-0",
85
- "aria-checked:border-r-transparent",
86
- "[&:has(+[aria-checked=true])]:border-r-transparent",
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",
87
98
  className
88
99
  )}
89
100
  {...props}
90
101
  >
91
- {icon && renderIcon(icon)}
92
- {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>
93
112
  </ToggleGroupPrimitive.Item>
94
113
  );
95
114
 
@@ -109,6 +128,7 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
109
128
  colorScheme,
110
129
  allowEmpty,
111
130
  items,
131
+ variant = "default",
112
132
  className,
113
133
  style,
114
134
  }: SegmentedControlProps<T>) {
@@ -131,7 +151,10 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
131
151
  value={value}
132
152
  onValueChange={handleValueChange}
133
153
  className={cx(
134
- `grid appearance-none relative outline-none min-h-input-height rounded bg-input-background py-0.5 px-0.5`,
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]",
135
158
  className
136
159
  )}
137
160
  style={{
@@ -140,7 +163,7 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
140
163
  }}
141
164
  >
142
165
  {items.map((item, index) => (
143
- <SegmentedControlItem key={index} {...item} />
166
+ <SegmentedControlItem key={index} {...item} variant={variant} />
144
167
  ))}
145
168
  </ToggleGroupPrimitive.Root>
146
169
  </SegmentedControlContext.Provider>
@@ -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
  }
@@ -1,4 +1,3 @@
1
- import { DropdownChevronIcon } from "@noya-app/noya-icons";
2
1
  import { useKeyboardShortcuts } from "@noya-app/noya-keymap";
3
2
  import React from "react";
4
3
 
@@ -7,7 +6,6 @@ import { BaseToolbar } from "./BaseToolbar";
7
6
  import { Button } from "./Button";
8
7
  import { DividerVertical } from "./Divider";
9
8
  import { DropdownMenu } from "./DropdownMenu";
10
- import { renderIcon } from "./Icons";
11
9
  import {
12
10
  getKeyboardShortcutsForMenuItems,
13
11
  isSelectableMenuItem,
@@ -15,7 +13,6 @@ import {
15
13
  SelectableMenuItem,
16
14
  SubMenuItem,
17
15
  } from "./internal/Menu";
18
- import { Spacer } from "./Spacer";
19
16
  import { Tooltip } from "./Tooltip";
20
17
 
21
18
  const iconButtonStyle = {
@@ -46,12 +43,13 @@ export const ToolbarMenuDropdown = memoGeneric(function ToolbarMenuDropdown<
46
43
  }
47
44
  }}
48
45
  >
49
- <Button disabled={item.disabled} active={item.checked || open}>
50
- {item.icon && renderIcon(item.icon)}
51
- {item.title && item.icon && <Spacer.Horizontal inline size={6} />}
46
+ <Button
47
+ disabled={item.disabled}
48
+ active={item.checked || open}
49
+ icon={item.icon}
50
+ iconRight="DropdownChevronIcon"
51
+ >
52
52
  {item.title}
53
- <Spacer.Horizontal inline size={6} />
54
- <DropdownChevronIcon />
55
53
  </Button>
56
54
  </DropdownMenu>
57
55
  );
@@ -70,6 +68,7 @@ export const ToolbarMenuButton = memoGeneric(function ToolbarMenuButton<
70
68
  <Button
71
69
  disabled={item.disabled}
72
70
  active={item.checked}
71
+ icon={item.icon}
73
72
  style={item.icon && !item.title ? iconButtonStyle : undefined}
74
73
  onClick={() => {
75
74
  if (onSelectMenuItem && item.value) {
@@ -77,8 +76,6 @@ export const ToolbarMenuButton = memoGeneric(function ToolbarMenuButton<
77
76
  }
78
77
  }}
79
78
  >
80
- {item.icon && renderIcon(item.icon)}
81
- {item.title && item.icon && <Spacer.Horizontal inline size={6} />}
82
79
  {item.title}
83
80
  </Button>
84
81
  );
@@ -1,12 +1,7 @@
1
- import { cssVars } from "../../theme";
2
-
3
- import { ChatBubbleWithDotsIcon } from "@noya-app/noya-icons";
4
1
  import React from "react";
5
2
  import { INPUT_HEIGHT } from "../../theme";
6
- import { colorFromString } from "../../utils/colorFromString";
7
3
  import { Avatar, AvatarProps, AvatarStack } from "../Avatar";
8
4
  import Button from "../Button";
9
- import { Spacer } from "../Spacer";
10
5
  import { Small } from "../Text";
11
6
  import { Tooltip } from "../Tooltip";
12
7
 
@@ -25,14 +20,12 @@ export const UserAvatar = ({ userId, name, image }: AvatarProps) => (
25
20
 
26
21
  type ConnectedUsersMenuLayoutProps = {
27
22
  renderUsers: () => React.ReactNode;
28
- currentUserId?: string;
29
23
  launchAIAssistant?: () => void;
30
24
  isAssistantOpen?: boolean;
31
25
  };
32
26
 
33
27
  export const ConnectedUsersMenuLayout = ({
34
28
  renderUsers,
35
- currentUserId,
36
29
  launchAIAssistant,
37
30
  isAssistantOpen,
38
31
  }: ConnectedUsersMenuLayoutProps) => {
@@ -41,29 +34,13 @@ export const ConnectedUsersMenuLayout = ({
41
34
  <AvatarStack size={INPUT_HEIGHT} className="mr-1">
42
35
  {renderUsers()}
43
36
  </AvatarStack>
44
-
45
- {/* AI Assistant Avatar */}
46
37
  {launchAIAssistant && (
47
- <Tooltip
48
- content={
49
- <div className="flex flex-col">
50
- <Small>AI Assistant</Small>
51
- </div>
52
- }
53
- >
38
+ <Tooltip content="AI Assistant">
54
39
  <Button
55
- variant={isAssistantOpen ? "secondary" : undefined}
40
+ active={isAssistantOpen}
56
41
  onClick={launchAIAssistant}
57
- style={{
58
- backgroundColor: isAssistantOpen
59
- ? colorFromString(currentUserId ?? "")
60
- : undefined,
61
- }}
42
+ icon="ChatBubbleWithDotsIcon"
62
43
  >
63
- <ChatBubbleWithDotsIcon
64
- color={isAssistantOpen ? undefined : cssVars.colors.icon}
65
- />
66
- <Spacer.Horizontal inline size={6} />
67
44
  AI
68
45
  </Button>
69
46
  </Tooltip>
@@ -3,6 +3,7 @@ import * as React from "react";
3
3
  import { ToastProvider } from "../components/Toast";
4
4
  import { DialogProvider } from "./DialogContext";
5
5
  import { FloatingWindowProvider } from "./FloatingWindowContext";
6
+ import { OpenPortalsProvider } from "./OpenPortalsContext";
6
7
 
7
8
  export type DesignSystemConfigurationContextValue = {
8
9
  platform: PlatformName;
@@ -39,11 +40,13 @@ export const DesignSystemConfigurationProvider = React.memo(
39
40
 
40
41
  return (
41
42
  <DesignSystemConfigurationContext.Provider value={contextValue}>
42
- <DialogProvider>
43
- <ToastProvider>
44
- <FloatingWindowProvider>{children}</FloatingWindowProvider>
45
- </ToastProvider>
46
- </DialogProvider>
43
+ <OpenPortalsProvider>
44
+ <DialogProvider>
45
+ <ToastProvider>
46
+ <FloatingWindowProvider>{children}</FloatingWindowProvider>
47
+ </ToastProvider>
48
+ </DialogProvider>
49
+ </OpenPortalsProvider>
47
50
  </DesignSystemConfigurationContext.Provider>
48
51
  );
49
52
  }
@@ -0,0 +1,67 @@
1
+ import * as React from "react";
2
+
3
+ export type OpenPortalsContextValue = {
4
+ hasOpenPortals: boolean;
5
+ };
6
+
7
+ export type OpenPortalControlsContextValue = {
8
+ addOpenPortal: (portal: string) => void;
9
+ removeOpenPortal: (portal: string) => void;
10
+ };
11
+
12
+ /**
13
+ * This context is used to track the open portals in the design system.
14
+ *
15
+ * We currently use this so that we can cover up iframes when there are open portals.
16
+ * This lets us intercept click events that would otherwise be swallowed by the iframe.
17
+ */
18
+ const OpenPortalsContext = React.createContext<OpenPortalsContextValue>({
19
+ hasOpenPortals: false,
20
+ });
21
+
22
+ const OpenPortalControlsContext =
23
+ React.createContext<OpenPortalControlsContextValue>({
24
+ addOpenPortal: () => {},
25
+ removeOpenPortal: () => {},
26
+ });
27
+
28
+ export const OpenPortalsProvider = React.memo(function OpenPortalsProvider({
29
+ children,
30
+ }: {
31
+ children: React.ReactNode;
32
+ }) {
33
+ const [openPortals, setOpenPortals] = React.useState<string[]>([]);
34
+
35
+ const value = React.useMemo(
36
+ () => ({ hasOpenPortals: openPortals.length > 0 }),
37
+ [openPortals]
38
+ );
39
+
40
+ const controlsValue = React.useMemo(
41
+ () => ({
42
+ addOpenPortal: (portal: string) => {
43
+ setOpenPortals((prev) => [...prev, portal]);
44
+ },
45
+ removeOpenPortal: (portal: string) => {
46
+ setOpenPortals((prev) => prev.filter((p) => p !== portal));
47
+ },
48
+ }),
49
+ []
50
+ );
51
+
52
+ return (
53
+ <OpenPortalsContext.Provider value={value}>
54
+ <OpenPortalControlsContext.Provider value={controlsValue}>
55
+ {children}
56
+ </OpenPortalControlsContext.Provider>
57
+ </OpenPortalsContext.Provider>
58
+ );
59
+ });
60
+
61
+ export function useHasOpenPortals(): boolean {
62
+ return React.useContext(OpenPortalsContext).hasOpenPortals;
63
+ }
64
+
65
+ export function useOpenPortalsControls(): OpenPortalControlsContextValue {
66
+ return React.useContext(OpenPortalControlsContext);
67
+ }