@noya-app/noya-designsystem 0.1.81 → 0.1.82

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.81",
3
+ "version": "0.1.82",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -28,7 +28,8 @@
28
28
  "@base-ui/react": "1.0.0",
29
29
  "@dnd-kit/core": "6.3.1",
30
30
  "@dnd-kit/sortable": "10.0.0",
31
- "@noya-app/noya-colorpicker": "0.1.33",
31
+ "@floating-ui/react": "^0.27.0",
32
+ "@noya-app/noya-colorpicker": "0.1.34",
32
33
  "@noya-app/noya-utils": "0.1.9",
33
34
  "@noya-app/noya-geometry": "0.1.17",
34
35
  "@noya-app/noya-icons": "0.1.18",
@@ -37,8 +38,6 @@
37
38
  "@radix-ui/react-compose-refs": "^1.0.0",
38
39
  "@radix-ui/primitive": "^1.0.1",
39
40
  "@radix-ui/react-utils": "^0.0.5",
40
- "@radix-ui/react-popper": "1.2.3",
41
- "@radix-ui/utils": "0.0.3",
42
41
  "gemoji": "8.1.0",
43
42
  "immer": "9.0.5",
44
43
  "kiwi.js": "^1.1.3",
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Dialog } from "@base-ui/react/dialog";
4
- import { useControlledOrUncontrolled } from "@noya-app/react-utils";
4
+ import { composeRefs, useControlledOrUncontrolled } from "@noya-app/react-utils";
5
5
  import * as React from "react";
6
6
  import { useImperativeHandle } from "react";
7
7
  import { useTriggerToggle } from "../hooks/useTriggerToggle";
@@ -123,10 +123,13 @@ export const Drawer = React.memo(
123
123
  childProps
124
124
  );
125
125
 
126
- // Track anchor element for click detection
127
- mergedProps.ref = (node: Element | null) => {
128
- if (node) setAnchorElement(node);
129
- };
126
+ // Compose anchor tracking ref with existing refs
127
+ mergedProps.ref = composeRefs(
128
+ mergedProps.ref as React.Ref<Element> | undefined,
129
+ (node: Element | null) => {
130
+ if (node) setAnchorElement(node);
131
+ }
132
+ );
130
133
 
131
134
  return React.cloneElement(
132
135
  element as React.ReactElement<any>,
@@ -90,6 +90,7 @@ const SECTION_HEADER_HEIGHT = 24;
90
90
  const HEADER_HEIGHT = 80; // Search + category tabs
91
91
  const FOOTER_HEIGHT = 40;
92
92
  const HORIZONTAL_PADDING = 8; // n-px-2 = 8px each side
93
+ const SEGMENTED_CONTROL_ITEM_WIDTH = 31;
93
94
 
94
95
  // Category icons mapping
95
96
  const categoryIcons: Record<
@@ -531,6 +532,17 @@ export const EmojiPicker = memo(function EmojiPicker({
531
532
  [frequentlyUsed.length]
532
533
  );
533
534
 
535
+ // Calculate ideal gap for segmented control to fit container
536
+ // Subtract n-px-2 padding (8px each side = 16px total) from available width
537
+ const segmentedControlGap = useMemo(() => {
538
+ const numItems = categoryItems.length;
539
+ const availableWidth = size.width - HORIZONTAL_PADDING * 2;
540
+ const totalItemsWidth = numItems * SEGMENTED_CONTROL_ITEM_WIDTH;
541
+ const remainingSpace = availableWidth - totalItemsWidth;
542
+ const numGaps = numItems - 1;
543
+ return numGaps > 0 ? Math.max(0, remainingSpace / numGaps) : 0;
544
+ }, [categoryItems.length, size.width]);
545
+
534
546
  // Show hovered emoji, or selected emoji if nothing hovered
535
547
  const displayEmoji = hoveredEmoji ?? selectedEmoji;
536
548
  const footerText = displayEmoji
@@ -567,7 +579,7 @@ export const EmojiPicker = memo(function EmojiPicker({
567
579
  onValueChange={handleCategoryChange}
568
580
  allowEmpty
569
581
  className="n-flex-nowrap n-px-2"
570
- // className="n-overflow-x-auto n-flex-nowrap n-px-2"
582
+ style={{ gap: segmentedControlGap }}
571
583
  />
572
584
  <Divider />
573
585
  {/* Header: Search + Category Tabs */}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
4
- import { useControlledOrUncontrolled } from "@noya-app/react-utils";
4
+ import { composeRefs, useControlledOrUncontrolled } from "@noya-app/react-utils";
5
5
  import React, { useCallback, useEffect, useId } from "react";
6
6
  import { useOpenPortalsControls } from "../contexts/OpenPortalsContext";
7
7
  import {
@@ -99,11 +99,13 @@ export function Popover({
99
99
  childProps
100
100
  );
101
101
 
102
- // Custom ref handling for anchor tracking
103
- mergedProps.ref = (node: Element | null) => {
104
- // Only update state when we get a valid node to prevent anchor loss during rerenders
105
- if (node) setAnchorElement(node);
106
- };
102
+ // Compose anchor tracking ref with existing refs
103
+ mergedProps.ref = composeRefs(
104
+ mergedProps.ref as React.Ref<Element> | undefined,
105
+ (node: Element | null) => {
106
+ if (node) setAnchorElement(node);
107
+ }
108
+ );
107
109
 
108
110
  return React.cloneElement(
109
111
  element as React.ReactElement<any>,
@@ -209,6 +209,9 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
209
209
  width: number;
210
210
  } | null>(null);
211
211
 
212
+ // Trigger recalculation when container resizes
213
+ const [resizeTrigger, setResizeTrigger] = useState(0);
214
+
212
215
  const selectedIndex = useMemo(() => {
213
216
  if (!value) return -1;
214
217
  return items.findIndex((item) => item.value === value);
@@ -238,7 +241,20 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
238
241
 
239
242
  // Enable animation after first measurement
240
243
  hasInitialized.current = true;
241
- }, [selectedIndex, variant, items.length]);
244
+ }, [selectedIndex, variant, items.length, resizeTrigger]);
245
+
246
+ // Watch for container resize to recalculate indicator position
247
+ useLayoutEffect(() => {
248
+ const container = containerRef.current;
249
+ if (!container || variant !== "default") return;
250
+
251
+ const observer = new ResizeObserver(() => {
252
+ setResizeTrigger((prev) => prev + 1);
253
+ });
254
+ observer.observe(container);
255
+
256
+ return () => observer.disconnect();
257
+ }, [variant]);
242
258
 
243
259
  const handleSelect = useCallback(
244
260
  (newValue: string) => {
@@ -8,47 +8,44 @@ import {
8
8
  } from "@noya-app/noya-designsystem";
9
9
  import { Rect } from "@noya-app/noya-geometry";
10
10
  import { useSize } from "@noya-app/react-utils";
11
- import * as PopperPrimitive from "@radix-ui/react-popper";
12
- import type { MeasurableElement } from "@radix-ui/utils";
13
- import React, { ComponentProps, memo, useMemo } from "react";
14
- import { createPortal } from "react-dom";
15
-
16
- // Create a Measurable from a Rect
17
- const createVirtualElement = (rect: Rect): MeasurableElement => ({
18
- getBoundingClientRect: () => ({
19
- width: rect.width,
20
- height: rect.height,
21
- x: rect.x,
22
- y: rect.y,
23
- top: rect.y,
24
- right: rect.x + rect.width,
25
- bottom: rect.y + rect.height,
26
- left: rect.x,
27
- toJSON: () => null,
28
- }),
29
- });
11
+ import {
12
+ useFloating,
13
+ offset,
14
+ flip,
15
+ shift,
16
+ FloatingPortal,
17
+ type Placement,
18
+ type VirtualElement,
19
+ } from "@floating-ui/react";
20
+ import React, { memo, useCallback, useEffect, useMemo, useState } from "react";
30
21
 
31
- type PopperPrimitiveContentProps = ComponentProps<
32
- typeof PopperPrimitive.Content
33
- >;
22
+ type Side = "top" | "right" | "bottom" | "left";
23
+ type Align = "start" | "center" | "end";
34
24
 
35
- type SelectionToolbarContainerProps = Pick<
36
- PopperPrimitiveContentProps,
37
- "side" | "sideOffset" | "align" | "avoidCollisions"
38
- > & {
25
+ type SelectionToolbarContainerProps = {
26
+ side?: Side;
27
+ sideOffset?: number;
28
+ align?: Align;
29
+ avoidCollisions?: boolean;
39
30
  rect: Rect;
40
31
  children: React.ReactNode;
41
- portalContainer?: HTMLElement | null;
42
32
  containerClassName?: string;
43
33
  containerStyle?: React.CSSProperties;
44
34
  transitionDuration?: number;
45
35
  };
46
36
 
37
+ // Map side + align to floating-ui placement
38
+ function getPlacement(side: Side, align: Align): Placement {
39
+ if (align === "center") {
40
+ return side;
41
+ }
42
+ return `${side}-${align}` as Placement;
43
+ }
44
+
47
45
  export const SelectionToolbarContainer = memo(
48
46
  function SelectionToolbarContainer({
49
47
  rect,
50
48
  children,
51
- portalContainer,
52
49
  side = "top",
53
50
  sideOffset = 10,
54
51
  align = "center",
@@ -58,58 +55,78 @@ export const SelectionToolbarContainer = memo(
58
55
  transitionDuration = 0.2,
59
56
  }: SelectionToolbarContainerProps) {
60
57
  const portalScopeId = usePortalScopeId();
61
- const containerRef = React.useRef<HTMLDivElement>(null);
62
- const size = useSize(containerRef, "width");
58
+ const [containerElement, setContainerElement] =
59
+ useState<HTMLDivElement | null>(null);
60
+ const containerRef = useCallback((el: HTMLDivElement | null) => {
61
+ setContainerElement(el);
62
+ }, []);
63
+ const containerRefObject = useMemo(
64
+ () => ({ current: containerElement }),
65
+ [containerElement]
66
+ );
67
+ const size = useSize(containerRefObject, "width");
63
68
 
64
69
  // Create a virtual reference element from the rect
65
- const virtualRef = useMemo(
66
- () => ({
67
- current: createVirtualElement(rect),
70
+ const virtualElement = useMemo(
71
+ (): VirtualElement => ({
72
+ getBoundingClientRect: () => ({
73
+ width: rect.width,
74
+ height: rect.height,
75
+ x: rect.x,
76
+ y: rect.y,
77
+ top: rect.y,
78
+ right: rect.x + rect.width,
79
+ bottom: rect.y + rect.height,
80
+ left: rect.x,
81
+ }),
68
82
  }),
69
83
  [rect]
70
84
  );
71
85
 
72
- const popperContent = (
73
- <PopperPrimitive.Content
74
- {...portalScopeProps(portalScopeId)}
75
- side={side}
76
- sideOffset={sideOffset}
77
- align={align}
78
- avoidCollisions={avoidCollisions}
79
- collisionPadding={10}
80
- contentEditable={false}
81
- style={{
82
- zIndex: 10,
83
- opacity: size ? 1 : 0,
84
- transition: `opacity ${transitionDuration}s ease-in-out`,
85
- [cssVarNames.colors.inputBackground]: "transparent",
86
- [cssVarNames.colors.buttonBackground]: "transparent",
87
- }}
88
- >
86
+ const placement = getPlacement(side, align);
87
+
88
+ const { refs, floatingStyles } = useFloating({
89
+ placement,
90
+ middleware: avoidCollisions
91
+ ? [offset(sideOffset), flip(), shift({ padding: 10 })]
92
+ : [offset(sideOffset)],
93
+ });
94
+
95
+ useEffect(() => {
96
+ refs.setPositionReference(virtualElement);
97
+ }, [refs, virtualElement]);
98
+
99
+ return (
100
+ <FloatingPortal>
89
101
  <div
90
- ref={containerRef}
91
- className={cx(
92
- "n-flex n-gap-1 n-bg-popover-background n-rounded n-shadow-popover n-p-1",
93
- containerClassName
94
- )}
95
- style={containerStyle}
96
- onClick={(e) => e.stopPropagation()}
97
- onMouseDown={(e) => e.stopPropagation()}
98
- onPointerDown={(e) => e.stopPropagation()}
99
- onWheel={(e) => e.stopPropagation()}
102
+ ref={refs.setFloating}
103
+ {...portalScopeProps(portalScopeId)}
104
+ contentEditable={false}
105
+ style={{
106
+ ...floatingStyles,
107
+ opacity: size ? 1 : 0,
108
+ transition: `opacity ${transitionDuration}s ease-in-out`,
109
+ [cssVarNames.colors.inputBackground]: "transparent",
110
+ [cssVarNames.colors.buttonBackground]: "transparent",
111
+ }}
112
+ className="n-z-menu"
100
113
  >
101
- {children}
114
+ <div
115
+ ref={containerRef}
116
+ className={cx(
117
+ "n-flex n-gap-1 n-bg-popover-background n-rounded n-shadow-popover n-p-1",
118
+ containerClassName
119
+ )}
120
+ style={containerStyle}
121
+ onClick={(e) => e.stopPropagation()}
122
+ onMouseDown={(e) => e.stopPropagation()}
123
+ onPointerDown={(e) => e.stopPropagation()}
124
+ onWheel={(e) => e.stopPropagation()}
125
+ >
126
+ {children}
127
+ </div>
102
128
  </div>
103
- </PopperPrimitive.Content>
104
- );
105
-
106
- return (
107
- <PopperPrimitive.Root>
108
- <PopperPrimitive.Anchor virtualRef={virtualRef} />
109
- {portalContainer
110
- ? createPortal(popperContent, portalContainer)
111
- : popperContent}
112
- </PopperPrimitive.Root>
129
+ </FloatingPortal>
113
130
  );
114
131
  }
115
132
  );
@@ -1,3 +1,4 @@
1
+ import { assignRef } from "@noya-app/react-utils";
1
2
  import React from "react";
2
3
 
3
4
  /**
@@ -31,6 +32,43 @@ export function mergePropsWithEventComposition(
31
32
  }
32
33
  }
33
34
 
35
+ // Compose refs so both trigger and child refs get called
36
+ if (triggerProps.ref || childProps.ref) {
37
+ const triggerRef = triggerProps.ref as React.Ref<unknown> | undefined;
38
+ const childRef = childProps.ref as React.Ref<unknown> | undefined;
39
+ if (triggerRef && childRef) {
40
+ mergedProps.ref = (value: unknown) => {
41
+ assignRef(triggerRef as React.ForwardedRef<unknown>, value);
42
+ assignRef(childRef as React.ForwardedRef<unknown>, value);
43
+ };
44
+ } else if (childRef) {
45
+ mergedProps.ref = childRef;
46
+ }
47
+ // If only triggerRef exists, it's already in mergedProps from the spread
48
+ }
49
+
50
+ // Merge style objects (child styles take precedence)
51
+ if (
52
+ typeof triggerProps.style === "object" &&
53
+ triggerProps.style !== null &&
54
+ typeof childProps.style === "object" &&
55
+ childProps.style !== null
56
+ ) {
57
+ mergedProps.style = { ...triggerProps.style, ...childProps.style };
58
+ } else if (childProps.style !== undefined) {
59
+ mergedProps.style = childProps.style;
60
+ }
61
+
62
+ // Merge className strings
63
+ if (triggerProps.className || childProps.className) {
64
+ const classes = [triggerProps.className, childProps.className]
65
+ .filter(Boolean)
66
+ .join(" ");
67
+ if (classes) {
68
+ mergedProps.className = classes;
69
+ }
70
+ }
71
+
34
72
  return mergedProps;
35
73
  }
36
74