@noya-app/noya-designsystem 0.1.81 → 0.1.83

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.83",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -28,17 +28,16 @@
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
- "@noya-app/noya-icons": "0.1.18",
35
+ "@noya-app/noya-icons": "0.1.19",
35
36
  "@noya-app/noya-keymap": "0.1.4",
36
37
  "@noya-app/noya-tailwind-config": "0.1.10",
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>,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { Toggle } from "@base-ui/react/toggle";
4
4
  import { ToggleGroup } from "@base-ui/react/toggle-group";
5
- import { memoGeneric } from "@noya-app/react-utils";
5
+ import { memoGeneric, useSize } from "@noya-app/react-utils";
6
6
  import React, {
7
7
  createContext,
8
8
  forwardRef,
@@ -20,6 +20,16 @@ import { renderIcon } from "./Icons";
20
20
  import { SelectableMenuItem } from "./internal/Menu";
21
21
  import { Tooltip } from "./Tooltip";
22
22
 
23
+ // Layout constants — the Tailwind classes and px values must stay in sync.
24
+ const CONTAINER_PADDING_X = "n-px-0.5";
25
+ const CONTAINER_PADDING_X_PX = 4; // 0.5 * 4px = 2px each side = 4px total
26
+ const ITEM_PADDING = "n-px-input-padding-x";
27
+ const ITEM_PADDING_PX = 16; // n-px-input-padding-x * 2
28
+ // Extra width required before switching from compact back to expanded mode
29
+ const EXPANSION_THRESHOLD_PX = 20;
30
+ // Debounce delay (ms) before re-enabling indicator transition after resize stops
31
+ const RESIZE_SETTLE_MS = 150;
32
+
23
33
  type SegmentedControlColorScheme = "primary" | "secondary";
24
34
  type SegmentedControlVariant = "default" | "tabs" | "buttons";
25
35
 
@@ -28,6 +38,8 @@ type SegmentedControlContextValue = {
28
38
  colorScheme?: SegmentedControlColorScheme;
29
39
  onSelect?: (value: string) => void;
30
40
  itemWidth?: SegmentedControlItemWidth;
41
+ /** When true, items with icons hide their titles */
42
+ isCompact?: boolean;
31
43
  };
32
44
 
33
45
  const SegmentedControlContext = createContext<SegmentedControlContextValue>({
@@ -55,6 +67,13 @@ export interface SegmentedControlProps<T extends string> {
55
67
  * @default "auto"
56
68
  */
57
69
  itemWidth?: SegmentedControlItemWidth;
70
+ /**
71
+ * When enabled, automatically hides item titles and shows only icons
72
+ * when the control doesn't fit in its parent container.
73
+ * Requires all items to have icons.
74
+ * @default false
75
+ */
76
+ enableCompactMode?: boolean;
58
77
  }
59
78
 
60
79
  export type SegmentedControlItemProps<T extends string> = {
@@ -83,8 +102,17 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
83
102
  }: SegmentedControlItemProps<T>,
84
103
  forwardedRef: React.ForwardedRef<HTMLButtonElement>
85
104
  ) {
86
- const { colorScheme, onSelect, itemWidth = "auto" } =
87
- useContext(SegmentedControlContext);
105
+ const {
106
+ colorScheme,
107
+ onSelect,
108
+ itemWidth = "auto",
109
+ isCompact,
110
+ } = useContext(SegmentedControlContext);
111
+
112
+ // In compact mode, hide title if we have an icon
113
+ const showTitle = !(isCompact && icon);
114
+ // Use title as tooltip when hidden (if no explicit tooltip)
115
+ const effectiveTooltip = !showTitle && !tooltip ? title : tooltip;
88
116
 
89
117
  const handlePointerDown = useCallback(
90
118
  (event: React.PointerEvent) => {
@@ -133,11 +161,9 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
133
161
  ? "active:n-text-button-text-active active:n-bg-button-background-active data-[pressed]:n-text-button-text-active data-[pressed]:n-bg-button-background-active"
134
162
  : variant === "default"
135
163
  ? // default variant: only text color, background handled by sliding indicator
136
- colorScheme === "secondary"
164
+ colorScheme === "secondary" || colorScheme === "primary"
137
165
  ? "data-[pressed]:n-text-white"
138
- : colorScheme === "primary"
139
- ? "data-[pressed]:n-text-white"
140
- : "data-[pressed]:n-text-segmented-control-item-active-text"
166
+ : "data-[pressed]:n-text-segmented-control-item-active-text"
141
167
  : colorScheme === "secondary"
142
168
  ? "data-[pressed]:n-bg-secondary data-[pressed]:n-text-white"
143
169
  : colorScheme === "primary"
@@ -150,14 +176,16 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
150
176
  // hover
151
177
  variant === "buttons" && "hover:n-bg-button-background-hover",
152
178
  // spacing and borders
153
- "n-px-input-padding-x",
179
+ ITEM_PADDING,
154
180
  variant === "default"
155
181
  ? "n-rounded after:n-content-[''] after:n-absolute after:n-right-0 after:n-top-0.5 after:n-bottom-0.5 after:n-w-px after:n-bg-divider last:after:n-hidden"
156
182
  : variant === "buttons"
157
183
  ? "n-rounded-sm n-max-w-fit"
158
184
  : "n-border-y-2 n-border-y-transparent data-[pressed]:n-border-b-primary",
159
- // icon only buttons
185
+ // icon only buttons (explicit or due to compact mode)
160
186
  variant === "buttons" && title === "" && icon && "n-min-w-input-height",
187
+ // icon only in compact mode
188
+ isCompact && icon && "n-min-w-input-height",
161
189
  className
162
190
  )}
163
191
  {...props}
@@ -170,51 +198,48 @@ const SegmentedControlItem = forwardRef(function SegmentedControlItem<
170
198
  )}
171
199
  >
172
200
  {icon && renderIcon(icon)}
173
- {title}
201
+ {showTitle && title}
174
202
  </span>
175
203
  </Toggle>
176
204
  );
177
205
 
178
- return tooltip ? (
179
- <Tooltip content={tooltip}>{itemElement}</Tooltip>
206
+ return effectiveTooltip ? (
207
+ <Tooltip content={effectiveTooltip}>{itemElement}</Tooltip>
180
208
  ) : (
181
209
  itemElement
182
210
  );
183
211
  });
184
212
 
185
- export const SegmentedControl = memoGeneric(function SegmentedControl<
186
- T extends string,
187
- >({
188
- id: idProp,
189
- value,
190
- onValueChange,
191
- colorScheme,
192
- allowEmpty,
193
- items,
194
- variant = "default",
195
- className,
196
- style,
197
- itemWidth = "auto",
198
- }: SegmentedControlProps<T>) {
199
- const { fieldId: id } = useLabel({ fieldId: idProp });
200
-
201
- // Track if we should animate (skip initial mount)
213
+ function useIndicatorPosition({
214
+ containerRef,
215
+ itemRefs,
216
+ selectedIndex,
217
+ variant,
218
+ itemCount,
219
+ isCompact,
220
+ }: {
221
+ containerRef: React.RefObject<HTMLDivElement | null>;
222
+ itemRefs: React.RefObject<(HTMLButtonElement | null)[]>;
223
+ selectedIndex: number;
224
+ variant: SegmentedControlVariant;
225
+ itemCount: number;
226
+ /** Triggers re-measurement when compact mode changes item widths */
227
+ isCompact: boolean;
228
+ }) {
202
229
  const hasInitialized = useRef(false);
203
- const containerRef = useRef<HTMLDivElement>(null);
204
- const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
230
+ const isResizingRef = useRef(false);
231
+ const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
205
232
 
206
- // Indicator position based on measured item dimensions
207
233
  const [indicatorStyle, setIndicatorStyle] = useState<{
208
234
  left: number;
209
235
  width: number;
210
236
  } | null>(null);
211
237
 
212
- const selectedIndex = useMemo(() => {
213
- if (!value) return -1;
214
- return items.findIndex((item) => item.value === value);
215
- }, [value, items]);
238
+ const measureIndicator = useCallback(() => {
239
+ // Reference these to trigger re-measurement when items change size
240
+ void itemCount;
241
+ void isCompact;
216
242
 
217
- useLayoutEffect(() => {
218
243
  if (variant !== "default" || selectedIndex < 0) {
219
244
  setIndicatorStyle(null);
220
245
  return;
@@ -236,9 +261,178 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
236
261
  width: itemRect.width,
237
262
  });
238
263
 
239
- // Enable animation after first measurement
240
264
  hasInitialized.current = true;
241
- }, [selectedIndex, variant, items.length]);
265
+ }, [containerRef, itemRefs, selectedIndex, variant, itemCount, isCompact]);
266
+
267
+ useLayoutEffect(measureIndicator, [measureIndicator]);
268
+
269
+ // Watch for container resize to recalculate indicator position
270
+ useLayoutEffect(() => {
271
+ const container = containerRef.current;
272
+ if (!container || variant !== "default") return;
273
+
274
+ const observer = new ResizeObserver(() => {
275
+ isResizingRef.current = true;
276
+ clearTimeout(resizeTimeoutRef.current);
277
+ resizeTimeoutRef.current = setTimeout(() => {
278
+ isResizingRef.current = false;
279
+ }, RESIZE_SETTLE_MS);
280
+ measureIndicator();
281
+ });
282
+ observer.observe(container);
283
+
284
+ return () => {
285
+ observer.disconnect();
286
+ clearTimeout(resizeTimeoutRef.current);
287
+ };
288
+ }, [containerRef, variant, measureIndicator]);
289
+
290
+ return { indicatorStyle, hasInitialized, isResizingRef };
291
+ }
292
+
293
+ function useCompactMode({
294
+ containerRef,
295
+ itemRefs,
296
+ variant,
297
+ items,
298
+ enabled,
299
+ }: {
300
+ containerRef: React.RefObject<HTMLDivElement | null>;
301
+ itemRefs: React.RefObject<(HTMLButtonElement | null)[]>;
302
+ variant: SegmentedControlVariant;
303
+ items: SegmentedControlItemProps<string>[];
304
+ enabled: boolean;
305
+ }) {
306
+ const containerSize = useSize(containerRef, "width");
307
+ const expandedWidthRef = useRef<number | null>(null);
308
+ const isCompactRef = useRef(false);
309
+ const [isCompact, setIsCompact] = useState(false);
310
+
311
+ const allItemsHaveIcons = useMemo(
312
+ () => items.every((item) => item.icon),
313
+ [items]
314
+ );
315
+
316
+ const itemsKey = useMemo(
317
+ () =>
318
+ items.map((item) => `${item.value}-${item.icon}-${item.title}`).join("|"),
319
+ [items]
320
+ );
321
+
322
+ // Reset expanded width measurement when items change
323
+ useLayoutEffect(() => {
324
+ expandedWidthRef.current = null;
325
+ isCompactRef.current = false;
326
+ setIsCompact(false);
327
+ }, [itemsKey]);
328
+
329
+ // Calculate compact mode based on whether items fit in parent
330
+ useLayoutEffect(() => {
331
+ if (!enabled) return;
332
+
333
+ const container = containerRef.current;
334
+ if (!container || variant !== "default" || !allItemsHaveIcons) {
335
+ return;
336
+ }
337
+
338
+ const validItems = itemRefs.current.filter(Boolean);
339
+ if (validItems.length !== items.length) return;
340
+
341
+ const availableWidth =
342
+ (containerSize?.width ?? container.clientWidth) - CONTAINER_PADDING_X_PX;
343
+
344
+ // Use ref to check current compact state to avoid re-triggering on state change
345
+ if (!isCompactRef.current) {
346
+ // Check if any item's content is being truncated
347
+ // Compare inner span width (content) to button clientWidth (available space)
348
+ // Note: Grid with minmax(min-content, 1fr) prevents container overflow,
349
+ // so we must check individual items for truncation instead
350
+ const isOverflowing = validItems.some((item) => {
351
+ const span = item!.querySelector("span");
352
+ if (!span) return false;
353
+ const contentWidth = span.scrollWidth;
354
+ const availableInButton = item!.clientWidth - ITEM_PADDING_PX;
355
+ return contentWidth >= availableInButton - 1; // -1 for rounding tolerance
356
+ });
357
+
358
+ if (isOverflowing) {
359
+ // Store the width needed for expanded mode (for hysteresis calculation)
360
+ const totalItemsWidth = validItems.reduce((sum, item) => {
361
+ const span = item!.querySelector("span");
362
+ const contentWidth = span ? span.scrollWidth : 0;
363
+ return sum + contentWidth + ITEM_PADDING_PX;
364
+ }, 0);
365
+ expandedWidthRef.current = totalItemsWidth;
366
+ isCompactRef.current = true;
367
+ setIsCompact(true);
368
+ }
369
+ } else {
370
+ // Currently compact - check if we can go back to expanded
371
+ const expandedWidth = expandedWidthRef.current;
372
+ if (!expandedWidth) return;
373
+
374
+ if (availableWidth >= expandedWidth + EXPANSION_THRESHOLD_PX) {
375
+ isCompactRef.current = false;
376
+ expandedWidthRef.current = null; // Reset to re-measure next time
377
+ setIsCompact(false);
378
+ }
379
+ }
380
+ }, [
381
+ enabled,
382
+ containerRef,
383
+ itemRefs,
384
+ variant,
385
+ allItemsHaveIcons,
386
+ containerSize?.width,
387
+ itemsKey,
388
+ items.length,
389
+ ]);
390
+
391
+ return isCompact;
392
+ }
393
+
394
+ export const SegmentedControl = memoGeneric(function SegmentedControl<
395
+ T extends string,
396
+ >({
397
+ id: idProp,
398
+ value,
399
+ onValueChange,
400
+ colorScheme,
401
+ allowEmpty,
402
+ items,
403
+ variant = "default",
404
+ className,
405
+ style,
406
+ itemWidth = "auto",
407
+ enableCompactMode = false,
408
+ }: SegmentedControlProps<T>) {
409
+ const { fieldId: id } = useLabel({ fieldId: idProp });
410
+
411
+ const containerRef = useRef<HTMLDivElement>(null);
412
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
413
+
414
+ const selectedIndex = useMemo(() => {
415
+ if (!value) return -1;
416
+ return items.findIndex((item) => item.value === value);
417
+ }, [value, items]);
418
+
419
+ const isCompact = useCompactMode({
420
+ containerRef,
421
+ itemRefs,
422
+ variant,
423
+ items,
424
+ enabled: enableCompactMode,
425
+ });
426
+
427
+ const { indicatorStyle, hasInitialized, isResizingRef } =
428
+ useIndicatorPosition({
429
+ containerRef,
430
+ itemRefs,
431
+ selectedIndex,
432
+ variant,
433
+ itemCount: items.length,
434
+ isCompact,
435
+ });
242
436
 
243
437
  const handleSelect = useCallback(
244
438
  (newValue: string) => {
@@ -254,8 +448,8 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
254
448
  );
255
449
 
256
450
  const contextValue = useMemo(
257
- () => ({ colorScheme, onSelect: handleSelect, itemWidth }),
258
- [colorScheme, handleSelect, itemWidth]
451
+ () => ({ colorScheme, onSelect: handleSelect, itemWidth, isCompact }),
452
+ [colorScheme, handleSelect, itemWidth, isCompact]
259
453
  );
260
454
 
261
455
  const indicatorBgClass =
@@ -277,7 +471,7 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
277
471
  variant === "default" &&
278
472
  (itemWidth === "fit" ? "n-flex n-w-fit" : "n-grid"),
279
473
  variant === "default" &&
280
- "n-min-h-input-height n-bg-input-background n-rounded n-py-0.5 n-px-0.5",
474
+ `n-min-h-input-height n-bg-input-background n-rounded n-py-0.5 ${CONTAINER_PADDING_X}`,
281
475
  variant !== "default" && "n-flex",
282
476
  variant === "tabs" && "n-gap-1.5 n-min-h-[33px]",
283
477
  variant === "buttons" && "n-gap-1.5 n-min-h-input-height",
@@ -301,6 +495,7 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
301
495
  "n-absolute n-top-0.5 n-bottom-0.5 n-rounded n-shadow-segment n-pointer-events-none",
302
496
  indicatorBgClass,
303
497
  hasInitialized.current &&
498
+ !isResizingRef.current &&
304
499
  "n-transition-[left,width] n-duration-200 n-ease-out"
305
500
  )}
306
501
  style={{