@noya-app/noya-designsystem 0.1.39 → 0.1.40

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.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +10 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +416 -321
  5. package/dist/index.d.ts +416 -321
  6. package/dist/index.js +9558 -11324
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5694 -7465
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -4
  11. package/src/components/ActivityIndicator.tsx +13 -4
  12. package/src/components/AnimatePresence.tsx +261 -0
  13. package/src/components/Avatar.tsx +202 -80
  14. package/src/components/Button.tsx +10 -6
  15. package/src/components/Checkbox.tsx +6 -1
  16. package/src/components/Chip.tsx +88 -132
  17. package/src/components/{InputFieldWithCompletions.tsx → Combobox.tsx} +10 -3
  18. package/src/components/ContextMenu.tsx +9 -5
  19. package/src/components/Dialog.tsx +20 -23
  20. package/src/components/Divider.tsx +15 -7
  21. package/src/components/DraggableMenuButton.tsx +9 -5
  22. package/src/components/DropdownMenu.tsx +8 -5
  23. package/src/components/FillInputField.tsx +5 -1
  24. package/src/components/FillPreviewBackground.tsx +1 -5
  25. package/src/components/GridView.tsx +71 -46
  26. package/src/components/InputField.tsx +140 -75
  27. package/src/components/InspectorPrimitives.tsx +17 -11
  28. package/src/components/Label.tsx +5 -1
  29. package/src/components/ListView.tsx +28 -15
  30. package/src/components/Progress.tsx +10 -7
  31. package/src/components/SelectMenu.tsx +20 -8
  32. package/src/components/Slider.tsx +70 -8
  33. package/src/components/Sortable.tsx +3 -3
  34. package/src/components/Switch.tsx +19 -2
  35. package/src/components/Text.tsx +5 -1
  36. package/src/components/TextArea.tsx +5 -1
  37. package/src/components/TreeView.tsx +4 -10
  38. package/src/components/UserPointer.tsx +170 -0
  39. package/src/components/WorkspaceLayout.tsx +5 -0
  40. package/src/components/internal/Menu.tsx +14 -5
  41. package/src/components/internal/TextInput.tsx +13 -0
  42. package/src/contexts/DialogContext.tsx +30 -18
  43. package/src/hooks/useTheme.ts +50 -0
  44. package/src/index.css +16 -13
  45. package/src/index.tsx +14 -26
  46. package/src/utils/classNames.ts +5 -0
  47. package/src/utils/colorFromString.ts +44 -0
  48. package/tailwind.config.ts +16 -13
  49. package/src/hooks/useDarkMode.ts +0 -14
  50. package/src/utils/tailwind.ts +0 -9
@@ -1,5 +1,6 @@
1
1
  import { Size } from "@noya-app/noya-geometry";
2
2
  import { range } from "@noya-app/noya-utils";
3
+ import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
3
4
  import { composeRefs } from "@radix-ui/react-compose-refs";
4
5
  import React, {
5
6
  Children,
@@ -25,6 +26,7 @@ import { ListChildComponentProps, VariableSizeList } from "react-window";
25
26
  import { mergeEventHandlers } from "../hooks/mergeEventHandlers";
26
27
  import { useHover } from "../hooks/useHover";
27
28
  import { cssVars } from "../theme";
29
+ import { cx } from "../utils/classNames";
28
30
  import { ContextMenu } from "./ContextMenu";
29
31
  import { InputField, InputFieldInputProps } from "./InputField";
30
32
  import { MenuItem } from "./internal/Menu";
@@ -105,7 +107,10 @@ const ListViewRowTitle = ({
105
107
  children: React.ReactNode;
106
108
  } & HTMLAttributes<HTMLSpanElement>) => (
107
109
  <span
108
- className={`flex-1 overflow-hidden text-ellipsis whitespace-pre ${className ?? ""}`}
110
+ className={cx(
111
+ `flex-1 overflow-hidden text-ellipsis whitespace-pre `,
112
+ className
113
+ )}
109
114
  {...props}
110
115
  >
111
116
  {children}
@@ -123,7 +128,7 @@ const ListViewEditableRowTitleElement = forwardRef(
123
128
  ) => (
124
129
  <InputField.Input
125
130
  ref={forwardedRef}
126
- className={`bg-listview-editing-background ${className ?? ""}`}
131
+ className={cx(`bg-listview-editing-background `, className)}
127
132
  {...props}
128
133
  />
129
134
  )
@@ -226,11 +231,14 @@ const RowContainer = forwardRef<
226
231
  return (
227
232
  <div
228
233
  ref={ref}
229
- className={`${
230
- $isSectionHeader && $sectionHeaderVariant === "label"
231
- ? "font-sans text-label font-medium"
232
- : "font-sans text-heading5 font-normal"
233
- } ${className ?? ""}`}
234
+ className={cx(
235
+ `${
236
+ $isSectionHeader && $sectionHeaderVariant === "label"
237
+ ? "font-sans text-label font-medium"
238
+ : "font-sans text-heading5 font-normal"
239
+ } `,
240
+ className
241
+ )}
234
242
  style={{
235
243
  ...($isSectionHeader && { fontWeight: 500 }),
236
244
  gap: $gap,
@@ -418,7 +426,7 @@ interface ListViewRowProps<MenuItemType extends string = string> {
418
426
  }) => CSSProperties);
419
427
  }
420
428
 
421
- const ListViewRow = forwardRef(function ListViewRow<
429
+ const ListViewRow = forwardRefGeneric(function ListViewRow<
422
430
  MenuItemType extends string,
423
431
  >(
424
432
  {
@@ -634,7 +642,7 @@ export interface IVirtualizedList {
634
642
  scrollToIndex(index: number): void;
635
643
  }
636
644
 
637
- const VirtualizedListInner = forwardRef(function VirtualizedListInner<T>(
645
+ const VirtualizedListInner = forwardRefGeneric(function VirtualizedListInner<T>(
638
646
  {
639
647
  size,
640
648
  scrollElement,
@@ -781,7 +789,7 @@ type ListViewRootProps = {
781
789
  colorScheme?: ListColorScheme;
782
790
  };
783
791
 
784
- const ListViewRootInner = forwardRef(function ListViewRootInner<T>(
792
+ const ListViewRootInner = forwardRefGeneric(function ListViewRootInner<T>(
785
793
  {
786
794
  id,
787
795
  className,
@@ -1009,7 +1017,10 @@ const ListViewRootInner = forwardRef(function ListViewRootInner<T>(
1009
1017
  <ListViewDraggingContext.Provider value={draggingContextValue}>
1010
1018
  <div
1011
1019
  id={id}
1012
- className={`flex flex-col text-text-muted ${scrollable ? "flex-1" : "flex-none"} ${className ?? ""}`}
1020
+ className={cx(
1021
+ `flex flex-col text-text-muted ${scrollable ? "flex-1" : "flex-none"} `,
1022
+ className
1023
+ )}
1013
1024
  style={{ ...gapStyle, ...style }}
1014
1025
  {...{
1015
1026
  [pressEventName]: handleClick,
@@ -1037,7 +1048,7 @@ const ListViewRootInner = forwardRef(function ListViewRootInner<T>(
1037
1048
  );
1038
1049
  });
1039
1050
 
1040
- const ListViewRoot = memo(ListViewRootInner) as typeof ListViewRootInner;
1051
+ const ListViewRoot = memoGeneric(ListViewRootInner) as typeof ListViewRootInner;
1041
1052
 
1042
1053
  const ChildrenListViewInner = forwardRef(function ChildrenListViewInner(
1043
1054
  { children, ...rest }: ChildrenProps & ListViewRootProps,
@@ -1068,7 +1079,9 @@ const ChildrenListViewInner = forwardRef(function ChildrenListViewInner(
1068
1079
 
1069
1080
  const ChildrenListView = memo(ChildrenListViewInner);
1070
1081
 
1071
- const SimpleListViewInner = forwardRef(function SimpleListViewInner<T = any>(
1082
+ const SimpleListViewInner = forwardRefGeneric(function SimpleListViewInner<
1083
+ T = any,
1084
+ >(
1072
1085
  props: (ChildrenProps | RenderProps<T>) & ListViewRootProps,
1073
1086
  forwardedRef: ForwardedRef<IVirtualizedList>
1074
1087
  ) {
@@ -1082,12 +1095,12 @@ const SimpleListViewInner = forwardRef(function SimpleListViewInner<T = any>(
1082
1095
  /**
1083
1096
  * A ListView can be created either with `children` or render props
1084
1097
  */
1085
- const SimpleListView = memo(SimpleListViewInner);
1098
+ const SimpleListView = memoGeneric(SimpleListViewInner);
1086
1099
 
1087
1100
  export namespace ListView {
1088
1101
  export const RowTitle = memo(ListViewRowTitle);
1089
1102
  export const EditableRowTitle = memo(ListViewEditableRowTitle);
1090
- export const Row = memo(ListViewRow);
1103
+ export const Row = memoGeneric(ListViewRow);
1091
1104
  export const Root = SimpleListView;
1092
1105
  export const RowContext = ListRowContext;
1093
1106
  export type ClickInfo = ListViewClickInfo;
@@ -1,7 +1,7 @@
1
1
  import { clamp } from "@noya-app/noya-utils";
2
2
  import * as ProgressPrimitive from "@radix-ui/react-progress";
3
3
  import React, { useMemo } from "react";
4
- import { cn } from "../utils/tailwind";
4
+ import { cx } from "../utils/classNames";
5
5
 
6
6
  type ProgressVariant = "normal" | "warning" | "primary" | "secondary";
7
7
 
@@ -15,20 +15,23 @@ export function Progress({
15
15
  className?: string;
16
16
  }) {
17
17
  const clampedValue = clamp(value, 0, 100);
18
- const transformStyles = useMemo(() => ({
19
- transform: `translateX(-${100 - clampedValue}%)`,
20
- }), [clampedValue]);
18
+ const transformStyles = useMemo(
19
+ () => ({
20
+ transform: `translateX(-${100 - clampedValue}%)`,
21
+ }),
22
+ [clampedValue]
23
+ );
21
24
 
22
25
  return (
23
26
  <ProgressPrimitive.Root
24
- className={`relative hidden bg-input-background h-[5px] ${className ?? ""}`}
27
+ className={cx(`relative hidden bg-input-background h-[5px] `, className)}
25
28
  style={{ transform: "translateZ(0)" }}
26
29
  value={clampedValue}
27
30
  >
28
31
  <ProgressPrimitive.Indicator
29
- className={cn(variant === 'primary' ? "bg-primary" : variant === 'secondary' ? "bg-secondary" : variant === 'warning' ? "bg-warning" : "bg-text", "transition-[transform_660ms_cubic-bezier(0.65,0,0.35,1)", "w-full h-full")}
32
+ className={`${variant === "primary" ? "bg-primary" : variant === "secondary" ? "bg-secondary" : variant === "warning" ? "bg-warning" : "bg-text"} transition-[transform_660ms_cubic-bezier(0.65,0,0.35,1) w-full h-full`}
30
33
  style={transformStyles}
31
34
  />
32
35
  </ProgressPrimitive.Root>
33
36
  );
34
- }
37
+ }
@@ -3,9 +3,11 @@ import {
3
3
  ChevronUpIcon,
4
4
  DropdownChevronIcon,
5
5
  } from "@noya-app/noya-icons";
6
+ import { memoGeneric } from "@noya-app/react-utils";
6
7
  import * as Select from "@radix-ui/react-select";
7
8
  import { type SelectProps } from "@radix-ui/react-select";
8
- import React, { CSSProperties, memo, useMemo } from "react";
9
+ import React, { CSSProperties, FocusEventHandler, useMemo } from "react";
10
+ import { cx } from "../utils/classNames";
9
11
  import { Button } from "./Button";
10
12
  import { renderIcon } from "./Icons";
11
13
  import { MenuItem, RegularMenuItem, styles } from "./internal/Menu";
@@ -22,6 +24,8 @@ type Props<T extends string> = {
22
24
  disabled?: boolean;
23
25
  readOnly?: boolean;
24
26
  label?: React.ReactNode;
27
+ onFocus?: FocusEventHandler;
28
+ onBlur?: FocusEventHandler;
25
29
  } & Pick<SelectProps, "open">;
26
30
 
27
31
  const readOnlyStyle: CSSProperties = {
@@ -29,7 +33,7 @@ const readOnlyStyle: CSSProperties = {
29
33
  textAlign: "left",
30
34
  };
31
35
 
32
- const labelStyles = `font-sans text-label uppercase text-text-disabled leading-[19px] font-bold text-[60%] pointer-events-none flex items-center mr-1.5 z-label`;
36
+ const labelStyles = `font-sans text-label uppercase text-text-disabled leading-[19px] font-bold text-[60%] pointer-events-none flex items-center absolute top-0 bottom-0 right-[1.3rem] z-label`;
33
37
 
34
38
  const scrollButtonStyles = `
35
39
  flex
@@ -41,7 +45,9 @@ const scrollButtonStyles = `
41
45
  hover:text-text
42
46
  `;
43
47
 
44
- export const SelectMenu = memo(function SelectMenu<T extends string = string>({
48
+ export const SelectMenu = memoGeneric(function SelectMenu<
49
+ T extends string = string,
50
+ >({
45
51
  id,
46
52
  style,
47
53
  className,
@@ -53,6 +59,8 @@ export const SelectMenu = memo(function SelectMenu<T extends string = string>({
53
59
  readOnly,
54
60
  label,
55
61
  open,
62
+ onFocus,
63
+ onBlur,
56
64
  }: Props<T>) {
57
65
  const selectedItem = menuItems.find(
58
66
  (item): item is RegularMenuItem<T> =>
@@ -66,7 +74,7 @@ export const SelectMenu = memo(function SelectMenu<T extends string = string>({
66
74
  id={id}
67
75
  style={style}
68
76
  contentStyle={readOnlyStyle}
69
- className={`${className ?? ""} flex-1 focus:z-interactable`}
77
+ className={cx(`${className} flex-1 focus:z-interactable`)}
70
78
  disabled={disabled}
71
79
  >
72
80
  {icon && (
@@ -83,11 +91,11 @@ export const SelectMenu = memo(function SelectMenu<T extends string = string>({
83
91
 
84
92
  const trigger = useMemo(
85
93
  () => (
86
- <Select.SelectTrigger asChild>
94
+ <Select.SelectTrigger asChild onFocus={onFocus} onBlur={onBlur}>
87
95
  <Button
88
96
  id={id}
89
97
  style={style}
90
- className={`${className ?? "w-full"} flex-1 focus:z-interactable`}
98
+ className={`${className ?? "w-full"} flex-1 focus:z-interactable relative`}
91
99
  disabled={disabled}
92
100
  >
93
101
  {icon && (
@@ -99,12 +107,16 @@ export const SelectMenu = memo(function SelectMenu<T extends string = string>({
99
107
  <span className="flex flex-1 mr-1.5">
100
108
  <Select.Value placeholder={placeholder} />
101
109
  </span>
102
- {label && <span className={labelStyles}>{label}</span>}
110
+ {label && (
111
+ <label htmlFor={id} className={labelStyles}>
112
+ {label}
113
+ </label>
114
+ )}
103
115
  <DropdownChevronIcon />
104
116
  </Button>
105
117
  </Select.SelectTrigger>
106
118
  ),
107
- [id, style, className, disabled, icon, placeholder, label]
119
+ [onFocus, onBlur, id, style, className, disabled, icon, placeholder, label]
108
120
  );
109
121
 
110
122
  if (readOnly) {
@@ -1,31 +1,53 @@
1
- import * as RadixSlider from '@radix-ui/react-slider';
2
- import React, { useCallback, useMemo } from 'react';
1
+ import * as RadixSlider from "@radix-ui/react-slider";
2
+ import React, { FocusEventHandler, useCallback, useMemo } from "react";
3
+ import { cx } from "../utils/classNames";
4
+
5
+ type ColorScheme = "primary" | "secondary";
3
6
 
4
7
  interface Props {
5
8
  id?: string;
9
+ style?: React.CSSProperties;
10
+ className?: string;
6
11
  value: number;
7
12
  onValueChange: (value: number) => void;
8
13
  min: number;
9
14
  max: number;
15
+ colorScheme?: ColorScheme;
16
+ label?: React.ReactNode;
17
+ onFocus?: FocusEventHandler;
18
+ onBlur?: FocusEventHandler;
10
19
  }
11
20
 
21
+ const labelStyles = `font-sans text-label uppercase text-text-disabled leading-[19px] font-bold text-[60%] pointer-events-none flex items-center z-label`;
22
+
12
23
  export const Slider = function Slider({
13
24
  id,
25
+ style,
26
+ className,
14
27
  value,
15
28
  onValueChange,
16
29
  min,
17
30
  max,
31
+ colorScheme,
32
+ label,
33
+ onFocus,
34
+ onBlur,
18
35
  }: Props) {
19
36
  const arrayValue = useMemo(
20
37
  () => [Math.min(Math.max(value, min), max)],
21
- [value, min, max],
38
+ [value, min, max]
22
39
  );
23
40
 
41
+ const percentage = useMemo(() => {
42
+ const percent = ((arrayValue[0] - min) / (max - min)) * 100;
43
+ return `${percent}%`;
44
+ }, [arrayValue, min, max]);
45
+
24
46
  const handleValueChange = useCallback(
25
47
  (arrayValue: number[]) => {
26
48
  onValueChange(arrayValue[0]);
27
49
  },
28
- [onValueChange],
50
+ [onValueChange]
29
51
  );
30
52
 
31
53
  return (
@@ -35,12 +57,52 @@ export const Slider = function Slider({
35
57
  id={id}
36
58
  value={arrayValue}
37
59
  onValueChange={handleValueChange}
38
- className="flex-1 flex relative items-center select-none touch-none h-4"
60
+ className={cx(
61
+ "flex relative items-center select-none touch-none h-[27px]",
62
+ className
63
+ )}
64
+ style={style}
65
+ onFocus={onFocus}
66
+ onBlur={onBlur}
39
67
  >
40
- <RadixSlider.Track className="bg-divider relative flex-grow h-[2px]">
41
- <RadixSlider.Range className="absolute bg-primary rounded-full h-full" />
68
+ <RadixSlider.Track className="bg-divider relative flex-grow h-full rounded overflow-hidden">
69
+ {label && (
70
+ <label
71
+ htmlFor={id}
72
+ className={cx(labelStyles, "absolute top-0 bottom-0 right-2")}
73
+ >
74
+ {label}
75
+ </label>
76
+ )}
77
+ <RadixSlider.Range
78
+ className={cx(
79
+ "absolute h-full rounded",
80
+ colorScheme === undefined && "bg-slider-border",
81
+ colorScheme === "primary" && "bg-primary",
82
+ colorScheme === "secondary" && "bg-secondary"
83
+ )}
84
+ />
42
85
  </RadixSlider.Track>
43
- <RadixSlider.Thumb className="block w-3 h-3 bg-slider-background border border-solid border-slider-border rounded-[20px] focus:outline-none" />
86
+ <RadixSlider.Thumb
87
+ className={cx(
88
+ "block w-2 h-[27px] rounded border border-solid bg-slider-background focus:outline-none cursor-ew-resize",
89
+ colorScheme === undefined && "border-slider-border",
90
+ colorScheme === "primary" && "border-primary",
91
+ colorScheme === "secondary" && "border-secondary"
92
+ )}
93
+ />
94
+ {label && (
95
+ <label
96
+ htmlFor={id}
97
+ className={cx(
98
+ labelStyles,
99
+ "absolute top-0 bottom-0 left-2 right-2 text-white overflow-hidden flex justify-end"
100
+ )}
101
+ style={{ clipPath: `inset(0 ${100 - parseFloat(percentage)}% 0 0)` }}
102
+ >
103
+ {label}
104
+ </label>
105
+ )}
44
106
  </RadixSlider.Root>
45
107
  );
46
108
  };
@@ -15,9 +15,9 @@ import {
15
15
  useSortable,
16
16
  verticalListSortingStrategy,
17
17
  } from "@dnd-kit/sortable";
18
+ import { memoGeneric } from "@noya-app/react-utils";
18
19
  import React, {
19
20
  createContext,
20
- memo,
21
21
  ReactNode,
22
22
  Ref,
23
23
  useCallback,
@@ -308,6 +308,6 @@ function SortableRoot({
308
308
  }
309
309
 
310
310
  export namespace Sortable {
311
- export const Item = memo(SortableItem);
312
- export const Root = memo(SortableRoot);
311
+ export const Item = memoGeneric(SortableItem);
312
+ export const Root = memoGeneric(SortableRoot);
313
313
  }
@@ -1,5 +1,6 @@
1
1
  import * as SwitchPrimitive from "@radix-ui/react-switch";
2
- import React from "react";
2
+ import React, { CSSProperties, FocusEventHandler } from "react";
3
+ import { cx } from "../utils/classNames";
3
4
 
4
5
  type SwitchColorScheme = "normal" | "primary" | "secondary";
5
6
 
@@ -10,6 +11,10 @@ interface Props {
10
11
  /** @default normal */
11
12
  colorScheme?: SwitchColorScheme;
12
13
  disabled?: boolean;
14
+ onFocus?: FocusEventHandler;
15
+ onBlur?: FocusEventHandler;
16
+ className?: string;
17
+ style?: CSSProperties;
13
18
  }
14
19
 
15
20
  export const Switch = function Switch({
@@ -18,16 +23,28 @@ export const Switch = function Switch({
18
23
  onChange,
19
24
  colorScheme = "normal",
20
25
  disabled,
26
+ onFocus,
27
+ onBlur,
28
+ style,
29
+ className,
21
30
  }: Props) {
22
31
  return (
23
32
  <SwitchPrimitive.Root
24
33
  id={id}
34
+ style={style}
25
35
  checked={value}
26
36
  disabled={disabled}
27
37
  onCheckedChange={(newValue) => {
28
38
  onChange(newValue);
29
39
  }}
30
- className={`all-unset w-8 h-[19px] bg-active-background rounded-full relative cursor-pointer [webkit-tap-highlight-color:rgba(0,0,0,0)] focus:ring-2 focus:ring-primary focus:ring-offset-[1px] outline-none data-[state=checked]:bg-primary ${colorScheme === "secondary" && "data-[state=checked]:bg-secondary"} focus:z-interactable`}
40
+ className={cx(
41
+ "all-unset w-8 h-[19px] bg-active-background rounded-full relative cursor-pointer [webkit-tap-highlight-color:rgba(0,0,0,0)] focus:ring-2 focus:ring-primary focus:ring-offset-[1px] outline-none data-[state=checked]:bg-primary",
42
+ colorScheme === "secondary" && "data-[state=checked]:bg-secondary",
43
+ "focus:z-interactable",
44
+ className
45
+ )}
46
+ onFocus={onFocus}
47
+ onBlur={onBlur}
31
48
  >
32
49
  <SwitchPrimitive.Thumb className="block w-[15px] h-[15px] bg-white rounded-full transition-transform translate-x-[2px] data-[state=checked]:translate-x-[15px]" />
33
50
  </SwitchPrimitive.Root>
@@ -1,6 +1,7 @@
1
1
  import React, { ForwardedRef, forwardRef, ReactHTML, ReactNode } from "react";
2
2
  import { ThemeColor } from "../theme";
3
3
  import { camelToKebabCase, KebabToCamelCase } from "../theme/themeUtils";
4
+ import { cx } from "../utils/classNames";
4
5
 
5
6
  type Variant =
6
7
  | "title"
@@ -82,7 +83,10 @@ export const Text = forwardRef(function Text(
82
83
  <Component
83
84
  // @ts-expect-error expected to be many semantic html elements
84
85
  ref={forwardedRef}
85
- className={`${textStyles[variant]} text-${camelToKebabCase(String(color)) ?? "text"} ${className ?? ""}`}
86
+ className={cx(
87
+ `${textStyles[variant]} text-${camelToKebabCase(String(color)) ?? "text"} `,
88
+ className
89
+ )}
86
90
  style={style}
87
91
  tabIndex={tabIndex}
88
92
  onClick={onClick}
@@ -1,5 +1,6 @@
1
1
  import { assignRef } from "@noya-app/react-utils";
2
2
  import React, { forwardRef, memo, useCallback, useEffect, useRef } from "react";
3
+ import { cx } from "../utils/classNames";
3
4
 
4
5
  export const useAutoResize = (value: string) => {
5
6
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -45,7 +46,10 @@ export const AutoResizingTextArea = memo(
45
46
 
46
47
  return (
47
48
  <textarea
48
- className={`font-sans text-heading5 font-normal text-text bg-input-background w-0 flex-1 py-1 px-1.5 border-none outline-none h-[100px] rounded-4 resize-none placeholder:text-text-disabled focus:ring-2 focus:ring-primary read-only:text-text-disabled focus:z-interactable ${className ?? ""}`}
49
+ className={cx(
50
+ `font-sans text-heading5 font-normal text-text bg-input-background w-0 flex-1 py-1 px-1.5 border-none outline-none h-[100px] rounded-4 resize-none placeholder:text-text-disabled focus:ring-2 focus:ring-primary read-only:text-text-disabled focus:z-interactable `,
51
+ className
52
+ )}
49
53
  ref={handleRef}
50
54
  {...rest}
51
55
  onChange={handleChange}
@@ -1,11 +1,5 @@
1
- import React, {
2
- ForwardedRef,
3
- forwardRef,
4
- memo,
5
- ReactNode,
6
- useCallback,
7
- useContext,
8
- } from "react";
1
+ import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
2
+ import React, { ForwardedRef, ReactNode, useCallback, useContext } from "react";
9
3
  import { IconButton } from "./IconButton";
10
4
  import { IconName, renderIcon } from "./Icons";
11
5
  import { ListView } from "./ListView";
@@ -24,7 +18,7 @@ type TreeRowBaseProps = {
24
18
  type TreeViewRowProps<MenuItemType extends string> =
25
19
  ListView.RowProps<MenuItemType> & TreeRowBaseProps;
26
20
 
27
- const TreeRow = forwardRef(function TreeRow<MenuItemType extends string>(
21
+ const TreeRow = forwardRefGeneric(function TreeRow<MenuItemType extends string>(
28
22
  {
29
23
  icon,
30
24
  expanded,
@@ -79,7 +73,7 @@ export namespace TreeView {
79
73
  export const Root = ListView.Root;
80
74
  export const RowTitle = ListView.RowTitle;
81
75
  export const EditableRowTitle = ListView.EditableRowTitle;
82
- export const Row = memo(TreeRow);
76
+ export const Row = memoGeneric(TreeRow);
83
77
  export type ClickInfo = ListView.ClickInfo;
84
78
  export type RowProps<MenuItemType extends string> =
85
79
  TreeViewRowProps<MenuItemType>;
@@ -0,0 +1,170 @@
1
+ import React, { memo, useMemo } from "react";
2
+ import { cx } from "../utils/classNames";
3
+ import { colorFromString } from "../utils/colorFromString";
4
+
5
+ type Point = { x: number; y: number };
6
+
7
+ const UserPointerContainer = memo(function UserPointerContainer({
8
+ point,
9
+ visible,
10
+ children,
11
+ className,
12
+ style,
13
+ }: {
14
+ point: Point;
15
+ visible?: boolean;
16
+ children: React.ReactNode;
17
+ className?: string;
18
+ style?: React.CSSProperties;
19
+ }) {
20
+ return (
21
+ <div
22
+ style={{
23
+ transform: `translate(${Math.round(point.x)}px, ${Math.round(point.y)}px)`,
24
+ transition: "transform 0.075s, opacity 0.2s",
25
+ ...style,
26
+ }}
27
+ className={cx(
28
+ "pointer-events-none",
29
+ visible ? "opacity-100" : "opacity-0",
30
+ className
31
+ )}
32
+ >
33
+ {children}
34
+ </div>
35
+ );
36
+ });
37
+
38
+ const UserNameTag = memo(function UserNameTag({
39
+ backgroundColor,
40
+ className,
41
+ style,
42
+ children,
43
+ }: {
44
+ backgroundColor: string;
45
+ className?: string;
46
+ style?: React.CSSProperties;
47
+ children: React.ReactNode;
48
+ }) {
49
+ return (
50
+ <span
51
+ className={cx(
52
+ "relative inline-block rounded-full text-xs font-medium leading-none text-white shadow-sm px-2 py-1.5 border border-background",
53
+ className
54
+ )}
55
+ style={{
56
+ backgroundColor,
57
+ letterSpacing: "-0.3px",
58
+ ...style,
59
+ }}
60
+ >
61
+ {children}
62
+ </span>
63
+ );
64
+ });
65
+
66
+ const UserPointerIcon = memo(function PointerIcon({
67
+ size,
68
+ color = "black",
69
+ className,
70
+ style,
71
+ }: {
72
+ size: number;
73
+ color?: string;
74
+ className?: string;
75
+ style?: React.CSSProperties;
76
+ }) {
77
+ // Simple triangle shape: left point, top right, bottom right
78
+ const points = "0,5 10,0 10,10";
79
+
80
+ return (
81
+ <svg
82
+ width={size}
83
+ height={size}
84
+ className={className}
85
+ viewBox="0 0 10 10"
86
+ fill="none"
87
+ xmlns="http://www.w3.org/2000/svg"
88
+ style={{
89
+ transform: `translate(0, -${size / 2}px) rotate(45deg)`,
90
+ transformOrigin: "left center",
91
+ ...style,
92
+ }}
93
+ >
94
+ <polygon
95
+ points={points}
96
+ fill={color}
97
+ stroke="var(--background)"
98
+ strokeWidth="0.5"
99
+ />
100
+ </svg>
101
+ );
102
+ });
103
+
104
+ export type UserPointerProps = {
105
+ userId?: string;
106
+ name?: string;
107
+ /** label to display in the tooltip next to the Pointer */
108
+ visible?: boolean;
109
+ point: Point;
110
+ /** defaults to a random color based on the key, but can optionally be overridden */
111
+ backgroundColor?: string;
112
+ style?: React.CSSProperties;
113
+ className?: string;
114
+ };
115
+
116
+ const POINTER_SIZE = 12;
117
+ const POINTER_OVERLAP = 6;
118
+
119
+ export const UserPointer = memo(function UserPointer({
120
+ userId,
121
+ name,
122
+ visible = true,
123
+ point,
124
+ backgroundColor,
125
+ style,
126
+ className,
127
+ }: UserPointerProps) {
128
+ const userBackgroundColor = useMemo(
129
+ () => backgroundColor ?? colorFromString(userId ?? name ?? ""),
130
+ [backgroundColor, userId, name]
131
+ );
132
+
133
+ const pointerOverlapStyle = useMemo(
134
+ () => ({
135
+ marginRight: `-${POINTER_OVERLAP}px`,
136
+ marginBottom: `-${POINTER_OVERLAP}px`,
137
+ }),
138
+ []
139
+ );
140
+
141
+ const nameTagOverlapStyle = useMemo(
142
+ () => ({ marginLeft: `${POINTER_SIZE - POINTER_OVERLAP + 1}px` }),
143
+ []
144
+ );
145
+
146
+ return (
147
+ <UserPointerContainer
148
+ point={point}
149
+ visible={visible}
150
+ className={className}
151
+ style={style}
152
+ >
153
+ {name && (
154
+ <div className="relative">
155
+ <UserPointerIcon
156
+ size={POINTER_SIZE}
157
+ color={userBackgroundColor}
158
+ style={pointerOverlapStyle}
159
+ />
160
+ <UserNameTag
161
+ backgroundColor={userBackgroundColor}
162
+ style={nameTagOverlapStyle}
163
+ >
164
+ {name}
165
+ </UserNameTag>
166
+ </div>
167
+ )}
168
+ </UserPointerContainer>
169
+ );
170
+ });