@noya-app/noya-designsystem 0.1.82 → 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.82",
3
+ "version": "0.1.83",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -32,7 +32,7 @@
32
32
  "@noya-app/noya-colorpicker": "0.1.34",
33
33
  "@noya-app/noya-utils": "0.1.9",
34
34
  "@noya-app/noya-geometry": "0.1.17",
35
- "@noya-app/noya-icons": "0.1.18",
35
+ "@noya-app/noya-icons": "0.1.19",
36
36
  "@noya-app/noya-keymap": "0.1.4",
37
37
  "@noya-app/noya-tailwind-config": "0.1.10",
38
38
  "@radix-ui/react-compose-refs": "^1.0.0",
@@ -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,54 +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
- // Trigger recalculation when container resizes
213
- const [resizeTrigger, setResizeTrigger] = useState(0);
214
-
215
- const selectedIndex = useMemo(() => {
216
- if (!value) return -1;
217
- return items.findIndex((item) => item.value === value);
218
- }, [value, items]);
238
+ const measureIndicator = useCallback(() => {
239
+ // Reference these to trigger re-measurement when items change size
240
+ void itemCount;
241
+ void isCompact;
219
242
 
220
- useLayoutEffect(() => {
221
243
  if (variant !== "default" || selectedIndex < 0) {
222
244
  setIndicatorStyle(null);
223
245
  return;
@@ -239,9 +261,10 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
239
261
  width: itemRect.width,
240
262
  });
241
263
 
242
- // Enable animation after first measurement
243
264
  hasInitialized.current = true;
244
- }, [selectedIndex, variant, items.length, resizeTrigger]);
265
+ }, [containerRef, itemRefs, selectedIndex, variant, itemCount, isCompact]);
266
+
267
+ useLayoutEffect(measureIndicator, [measureIndicator]);
245
268
 
246
269
  // Watch for container resize to recalculate indicator position
247
270
  useLayoutEffect(() => {
@@ -249,12 +272,167 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
249
272
  if (!container || variant !== "default") return;
250
273
 
251
274
  const observer = new ResizeObserver(() => {
252
- setResizeTrigger((prev) => prev + 1);
275
+ isResizingRef.current = true;
276
+ clearTimeout(resizeTimeoutRef.current);
277
+ resizeTimeoutRef.current = setTimeout(() => {
278
+ isResizingRef.current = false;
279
+ }, RESIZE_SETTLE_MS);
280
+ measureIndicator();
253
281
  });
254
282
  observer.observe(container);
255
283
 
256
- return () => observer.disconnect();
257
- }, [variant]);
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
+ });
258
436
 
259
437
  const handleSelect = useCallback(
260
438
  (newValue: string) => {
@@ -270,8 +448,8 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
270
448
  );
271
449
 
272
450
  const contextValue = useMemo(
273
- () => ({ colorScheme, onSelect: handleSelect, itemWidth }),
274
- [colorScheme, handleSelect, itemWidth]
451
+ () => ({ colorScheme, onSelect: handleSelect, itemWidth, isCompact }),
452
+ [colorScheme, handleSelect, itemWidth, isCompact]
275
453
  );
276
454
 
277
455
  const indicatorBgClass =
@@ -293,7 +471,7 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
293
471
  variant === "default" &&
294
472
  (itemWidth === "fit" ? "n-flex n-w-fit" : "n-grid"),
295
473
  variant === "default" &&
296
- "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}`,
297
475
  variant !== "default" && "n-flex",
298
476
  variant === "tabs" && "n-gap-1.5 n-min-h-[33px]",
299
477
  variant === "buttons" && "n-gap-1.5 n-min-h-input-height",
@@ -317,6 +495,7 @@ export const SegmentedControl = memoGeneric(function SegmentedControl<
317
495
  "n-absolute n-top-0.5 n-bottom-0.5 n-rounded n-shadow-segment n-pointer-events-none",
318
496
  indicatorBgClass,
319
497
  hasInitialized.current &&
498
+ !isResizingRef.current &&
320
499
  "n-transition-[left,width] n-duration-200 n-ease-out"
321
500
  )}
322
501
  style={{
@@ -25,7 +25,17 @@ interface PanelProps {
25
25
 
26
26
  export const Panel = forwardRef<ImperativePanelHandle, PanelProps>(
27
27
  function Panel(
28
- { id: propId, order, className, defaultSize, minSize, maxSize, collapsible, defaultCollapsed, children },
28
+ {
29
+ id: propId,
30
+ order,
31
+ className,
32
+ defaultSize,
33
+ minSize,
34
+ maxSize,
35
+ collapsible,
36
+ defaultCollapsed,
37
+ children,
38
+ },
29
39
  ref
30
40
  ) {
31
41
  const generatedId = useId();
@@ -56,7 +66,17 @@ export const Panel = forwardRef<ImperativePanelHandle, PanelProps>(
56
66
  return () => {
57
67
  unregisterPanel(id);
58
68
  };
59
- }, [id, order, defaultSize, minSize, maxSize, collapsible, defaultCollapsed, registerPanel, unregisterPanel]);
69
+ }, [
70
+ id,
71
+ order,
72
+ defaultSize,
73
+ minSize,
74
+ maxSize,
75
+ collapsible,
76
+ defaultCollapsed,
77
+ registerPanel,
78
+ unregisterPanel,
79
+ ]);
60
80
 
61
81
  const size = getPanelSize(id);
62
82
  const isFlexPanel = defaultSize === undefined;
@@ -144,17 +144,24 @@ export const PanelGroup = forwardRef<ImperativePanelGroupHandle, PanelGroupProps
144
144
  const registerPanel = useCallback(
145
145
  (panel: PanelRegistration) => {
146
146
  setPanels((prev) => {
147
+ // Check saved sizes first, then use defaultCollapsed
147
148
  const savedSizes = loadSavedSizes(autoSaveId);
148
149
  const savedSize = savedSizes?.[panel.id];
149
- // Use saved size if available, otherwise check defaultCollapsed, then use defaultSize
150
150
  const hasSavedSize = savedSize !== undefined;
151
- const collapsed = hasSavedSize
152
- ? savedSize === 0 && !!panel.collapsible
153
- : !!panel.defaultCollapsed && !!panel.collapsible;
154
- const initialSize = hasSavedSize
155
- ? savedSize
156
- : collapsed
157
- ? 0
151
+
152
+ let collapsed: boolean;
153
+ if (hasSavedSize) {
154
+ // Use saved state
155
+ collapsed = savedSize === 0 && !!panel.collapsible;
156
+ } else {
157
+ // Use defaultCollapsed
158
+ collapsed = !!panel.defaultCollapsed && !!panel.collapsible;
159
+ }
160
+
161
+ const initialSize = collapsed
162
+ ? 0
163
+ : hasSavedSize
164
+ ? savedSize
158
165
  : panel.defaultSize ?? 0;
159
166
 
160
167
  const newPanels = new Map(prev);