@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/.turbo/turbo-build.log +37 -38
- package/.turbo/turbo-lint.log +1 -15
- package/CHANGELOG.md +15 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +43 -13
- package/dist/index.d.ts +43 -13
- package/dist/index.js +682 -469
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +608 -389
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -5
- package/src/components/Drawer.tsx +8 -5
- package/src/components/EmojiPicker.tsx +13 -1
- package/src/components/Popover.tsx +8 -6
- package/src/components/SegmentedControl.tsx +237 -42
- package/src/components/SelectionToolbar.tsx +88 -71
- package/src/components/resizablePanels/Panel.tsx +22 -2
- package/src/components/resizablePanels/PanelGroup.tsx +15 -8
- package/src/components/workspace/DrawerWorkspaceLayout.tsx +131 -152
- package/src/components/workspace/HorizontalTabBar.tsx +43 -0
- package/src/components/workspace/PanelWorkspaceLayout.tsx +112 -54
- package/src/components/workspace/WorkspaceLayout.tsx +19 -5
- package/src/components/workspace/types.ts +17 -5
- package/src/index.css +12 -2
- package/src/utils/mergeProps.ts +38 -0
- package/dist/chunk-D57E6H3M.mjs +0 -36
- package/dist/chunk-D57E6H3M.mjs.map +0 -1
- package/dist/chunk-FJ6ZGZIA.mjs +0 -43
- package/dist/chunk-FJ6ZGZIA.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noya-app/noya-designsystem",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"@
|
|
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.
|
|
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
|
-
//
|
|
127
|
-
mergedProps.ref = (
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
103
|
-
mergedProps.ref = (
|
|
104
|
-
|
|
105
|
-
|
|
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 {
|
|
87
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
179
|
-
<Tooltip content={
|
|
206
|
+
return effectiveTooltip ? (
|
|
207
|
+
<Tooltip content={effectiveTooltip}>{itemElement}</Tooltip>
|
|
180
208
|
) : (
|
|
181
209
|
itemElement
|
|
182
210
|
);
|
|
183
211
|
});
|
|
184
212
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
204
|
-
const
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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,
|
|
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
|
-
|
|
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={{
|