@noya-app/noya-designsystem 0.1.40 → 0.1.42
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 +10 -10
- package/CHANGELOG.md +16 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +221 -124
- package/dist/index.d.ts +221 -124
- package/dist/index.js +7464 -6801
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7867 -7246
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -6
- package/src/__tests__/combobox.test.ts +578 -0
- package/src/components/ActivityIndicator.tsx +4 -4
- package/src/components/AnimatePresence.tsx +5 -5
- package/src/components/Avatar.tsx +5 -2
- package/src/components/BaseToolbar.tsx +61 -0
- package/src/components/Button.tsx +1 -1
- package/src/components/Checkbox.tsx +7 -6
- package/src/components/Combobox.tsx +220 -324
- package/src/components/ComboboxMenu.tsx +88 -0
- package/src/components/Dialog.tsx +10 -6
- package/src/components/DimensionInput.tsx +4 -4
- package/src/components/FillPreviewBackground.tsx +51 -44
- package/src/components/FloatingWindow.tsx +5 -2
- package/src/components/GradientPicker.tsx +14 -14
- package/src/components/IconButton.tsx +6 -12
- package/src/components/Icons.tsx +3 -3
- package/src/components/InputField.tsx +145 -160
- package/src/components/Label.tsx +4 -4
- package/src/components/LabeledElementView.tsx +35 -41
- package/src/components/ListView.tsx +49 -39
- package/src/components/Message.tsx +75 -0
- package/src/components/Progress.tsx +2 -2
- package/src/components/ScrollArea.tsx +7 -5
- package/src/components/SegmentedControl.tsx +171 -0
- package/src/components/SelectMenu.tsx +61 -10
- package/src/components/Slider.tsx +49 -14
- package/src/components/Sortable.tsx +17 -27
- package/src/components/Spacer.tsx +28 -9
- package/src/components/Switch.tsx +8 -8
- package/src/components/TextArea.tsx +23 -13
- package/src/components/Toolbar.tsx +134 -0
- package/src/components/Tooltip.tsx +10 -7
- package/src/components/internal/Menu.tsx +5 -4
- package/src/contexts/DesignSystemConfiguration.tsx +15 -24
- package/src/contexts/DialogContext.tsx +137 -49
- package/src/contexts/ImageDataContext.tsx +6 -12
- package/src/index.css +4 -6
- package/src/index.tsx +12 -3
- package/src/utils/combobox.ts +369 -0
- package/tailwind.config.ts +2 -3
- package/src/components/RadioGroup.tsx +0 -100
- package/src/utils/completions.ts +0 -21
|
@@ -1,50 +1,42 @@
|
|
|
1
|
-
import * as kiwi from
|
|
2
|
-
import React
|
|
3
|
-
Children,
|
|
4
|
-
createRef,
|
|
5
|
-
Fragment,
|
|
6
|
-
isValidElement,
|
|
7
|
-
memo,
|
|
8
|
-
ReactNode,
|
|
9
|
-
useLayoutEffect,
|
|
10
|
-
useMemo,
|
|
11
|
-
useRef,
|
|
12
|
-
} from 'react';
|
|
1
|
+
import * as kiwi from "kiwi.js";
|
|
2
|
+
import * as React from "react";
|
|
13
3
|
|
|
14
4
|
interface ContainerProps {
|
|
15
|
-
children: ReactNode;
|
|
16
|
-
renderLabel: (provided: { id: string; index: number }) => ReactNode;
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
renderLabel: (provided: { id: string; index: number }) => React.ReactNode;
|
|
17
7
|
}
|
|
18
8
|
|
|
19
|
-
export const LabeledElementView = memo(function LabeledElementView({
|
|
9
|
+
export const LabeledElementView = React.memo(function LabeledElementView({
|
|
20
10
|
children,
|
|
21
11
|
renderLabel,
|
|
22
12
|
}: ContainerProps) {
|
|
23
|
-
const elementIds: string[] = Children.toArray(children)
|
|
13
|
+
const elementIds: string[] = React.Children.toArray(children)
|
|
24
14
|
.flatMap((child) =>
|
|
25
|
-
isValidElement(child) && child.type === Fragment
|
|
26
|
-
? (child.props.children as ReactNode[])
|
|
27
|
-
: [child]
|
|
15
|
+
React.isValidElement(child) && child.type === React.Fragment
|
|
16
|
+
? (child.props.children as React.ReactNode[])
|
|
17
|
+
: [child]
|
|
28
18
|
)
|
|
29
19
|
.map((child) =>
|
|
30
|
-
isValidElement(child) &&
|
|
20
|
+
React.isValidElement(child) && "id" in child.props ? child.props.id : null
|
|
31
21
|
)
|
|
32
22
|
.filter((id) => !!id);
|
|
33
|
-
const serializedIds = elementIds.join(
|
|
34
|
-
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
23
|
+
const serializedIds = elementIds.join(",");
|
|
24
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
35
25
|
|
|
36
|
-
const refs = useMemo(() => {
|
|
26
|
+
const refs = React.useMemo(() => {
|
|
37
27
|
return Object.fromEntries(
|
|
38
|
-
serializedIds
|
|
28
|
+
serializedIds
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((id) => [id, React.createRef<HTMLSpanElement>()])
|
|
39
31
|
);
|
|
40
32
|
}, [serializedIds]);
|
|
41
33
|
|
|
42
|
-
const labelElements = useMemo(() => {
|
|
43
|
-
return serializedIds.split(
|
|
34
|
+
const labelElements = React.useMemo(() => {
|
|
35
|
+
return serializedIds.split(",").map((id, index) => (
|
|
44
36
|
<span
|
|
45
37
|
key={id}
|
|
46
38
|
ref={refs[id]}
|
|
47
|
-
style={{ position:
|
|
39
|
+
style={{ position: "absolute", left: `var(--x-offset)` }}
|
|
48
40
|
>
|
|
49
41
|
{renderLabel({
|
|
50
42
|
id,
|
|
@@ -54,7 +46,7 @@ export const LabeledElementView = memo(function LabeledElementView({
|
|
|
54
46
|
));
|
|
55
47
|
}, [refs, serializedIds, renderLabel]);
|
|
56
48
|
|
|
57
|
-
useLayoutEffect(() => {
|
|
49
|
+
React.useLayoutEffect(() => {
|
|
58
50
|
if (!containerRef.current) return;
|
|
59
51
|
|
|
60
52
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
@@ -87,8 +79,8 @@ export const LabeledElementView = memo(function LabeledElementView({
|
|
|
87
79
|
new kiwi.Expression(labelMidX),
|
|
88
80
|
kiwi.Operator.Eq,
|
|
89
81
|
new kiwi.Expression(targetMidXValue),
|
|
90
|
-
kiwi.Strength.strong
|
|
91
|
-
)
|
|
82
|
+
kiwi.Strength.strong
|
|
83
|
+
)
|
|
92
84
|
);
|
|
93
85
|
|
|
94
86
|
if (index === 0) {
|
|
@@ -97,8 +89,8 @@ export const LabeledElementView = memo(function LabeledElementView({
|
|
|
97
89
|
new kiwi.Expression(labelMidX),
|
|
98
90
|
kiwi.Operator.Ge,
|
|
99
91
|
new kiwi.Expression(labelRect.width / 2),
|
|
100
|
-
kiwi.Strength.required
|
|
101
|
-
)
|
|
92
|
+
kiwi.Strength.required
|
|
93
|
+
)
|
|
102
94
|
);
|
|
103
95
|
}
|
|
104
96
|
|
|
@@ -124,8 +116,8 @@ export const LabeledElementView = memo(function LabeledElementView({
|
|
|
124
116
|
new kiwi.Expression(labelMidX),
|
|
125
117
|
kiwi.Operator.Le,
|
|
126
118
|
new kiwi.Expression(containerRect.width - labelRect.width / 2),
|
|
127
|
-
kiwi.Strength.required
|
|
128
|
-
)
|
|
119
|
+
kiwi.Strength.required
|
|
120
|
+
)
|
|
129
121
|
);
|
|
130
122
|
}
|
|
131
123
|
|
|
@@ -136,7 +128,7 @@ export const LabeledElementView = memo(function LabeledElementView({
|
|
|
136
128
|
|
|
137
129
|
solver.updateVariables();
|
|
138
130
|
|
|
139
|
-
Object.entries(refs).forEach(([
|
|
131
|
+
Object.entries(refs).forEach(([_id, ref], index) => {
|
|
140
132
|
if (!ref.current) return;
|
|
141
133
|
|
|
142
134
|
// console.log(id, variables[index].value());
|
|
@@ -144,21 +136,23 @@ export const LabeledElementView = memo(function LabeledElementView({
|
|
|
144
136
|
const labelRect = ref.current.getBoundingClientRect();
|
|
145
137
|
|
|
146
138
|
ref.current.style.setProperty(
|
|
147
|
-
|
|
148
|
-
`${variables[index].value() - labelRect.width / 2}px
|
|
139
|
+
"--x-offset",
|
|
140
|
+
`${variables[index].value() - labelRect.width / 2}px`
|
|
149
141
|
);
|
|
150
142
|
});
|
|
151
143
|
|
|
152
144
|
containerRef.current.style.setProperty(
|
|
153
|
-
|
|
154
|
-
`${Math.max(...heights)}px
|
|
145
|
+
"--height",
|
|
146
|
+
`${Math.max(...heights)}px`
|
|
155
147
|
);
|
|
156
148
|
}, [refs, labelElements]);
|
|
157
149
|
|
|
158
150
|
return (
|
|
159
151
|
<div className="flex flex-1 flex-col relative" ref={containerRef}>
|
|
160
152
|
<div className="flex flex-1 items-center">{children}</div>
|
|
161
|
-
<div className="relative overflow-hidden select-none">
|
|
153
|
+
<div className="relative overflow-hidden select-none">
|
|
154
|
+
{labelElements}
|
|
155
|
+
</div>
|
|
162
156
|
</div>
|
|
163
157
|
);
|
|
164
|
-
});
|
|
158
|
+
});
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
Sortable,
|
|
39
39
|
} from "./Sortable";
|
|
40
40
|
import { Spacer } from "./Spacer";
|
|
41
|
+
import { labelTextStyles } from "./SelectMenu";
|
|
41
42
|
|
|
42
43
|
export type ListRowMarginType = "none" | "top" | "bottom" | "vertical";
|
|
43
44
|
export type ListRowPosition = "only" | "first" | "middle" | "last";
|
|
@@ -202,6 +203,7 @@ const RowContainer = forwardRef<
|
|
|
202
203
|
$colorScheme: ListColorScheme;
|
|
203
204
|
$gap: number;
|
|
204
205
|
$backgroundColor?: CSSProperties["backgroundColor"];
|
|
206
|
+
$clickable?: boolean;
|
|
205
207
|
}
|
|
206
208
|
>(
|
|
207
209
|
(
|
|
@@ -221,6 +223,7 @@ const RowContainer = forwardRef<
|
|
|
221
223
|
$gap,
|
|
222
224
|
$backgroundColor,
|
|
223
225
|
style,
|
|
226
|
+
$clickable,
|
|
224
227
|
"aria-describedby": _, // Causes hydration warning
|
|
225
228
|
...props
|
|
226
229
|
},
|
|
@@ -232,48 +235,46 @@ const RowContainer = forwardRef<
|
|
|
232
235
|
<div
|
|
233
236
|
ref={ref}
|
|
234
237
|
className={cx(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
$isSectionHeader && $sectionHeaderVariant === "label"
|
|
239
|
+
? labelTextStyles
|
|
240
|
+
: "font-sans text-heading5 font-normal",
|
|
241
|
+
!$isSectionHeader &&
|
|
242
|
+
$selected &&
|
|
243
|
+
"text-white active:bg-primary-light",
|
|
244
|
+
!$isSectionHeader && $disabled && "text-text-disabled",
|
|
245
|
+
$clickable ? "cursor-pointer" : "cursor-default",
|
|
246
|
+
$isSectionHeader && "bg-listview-raised-background",
|
|
247
|
+
"flex flex-[0_0_auto] select-none items-center relative",
|
|
240
248
|
className
|
|
241
249
|
)}
|
|
242
250
|
style={{
|
|
243
|
-
...($isSectionHeader && { fontWeight: 500 }),
|
|
244
251
|
gap: $gap,
|
|
245
|
-
flex: "0 0 auto",
|
|
246
|
-
userSelect: "none",
|
|
247
|
-
cursor: "default",
|
|
248
252
|
...($variant !== "bare" && {
|
|
249
|
-
paddingTop: "6px",
|
|
250
|
-
paddingRight: "12px",
|
|
251
|
-
paddingBottom: "6px",
|
|
252
|
-
paddingLeft: "12px",
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
253
|
+
paddingTop: $selected ? "4px" : "6px",
|
|
254
|
+
paddingRight: $selected ? "8px" : "12px",
|
|
255
|
+
paddingBottom: $selected ? "4px" : "6px",
|
|
256
|
+
paddingLeft: $selected ? "8px" : "12px",
|
|
257
|
+
borderRadius:
|
|
258
|
+
$variant === "padded" ? "2px" : $selected ? "4px" : "",
|
|
259
|
+
marginLeft: $variant === "padded" ? "8px" : $selected ? "4px" : "",
|
|
260
|
+
marginRight: $variant === "padded" ? "8px" : $selected ? "4px" : "",
|
|
261
|
+
marginTop:
|
|
262
|
+
$variant === "padded"
|
|
263
|
+
? `${margin.top}px`
|
|
264
|
+
: $selected
|
|
265
|
+
? "2px"
|
|
266
|
+
: "",
|
|
267
|
+
marginBottom:
|
|
268
|
+
$variant === "padded"
|
|
269
|
+
? `${margin.bottom}px`
|
|
270
|
+
: $selected
|
|
271
|
+
? "2px"
|
|
272
|
+
: "",
|
|
260
273
|
}),
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
...($sectionHeaderVariant === "label" && {
|
|
265
|
-
color: cssVars.colors.textDisabled,
|
|
274
|
+
...($selected &&
|
|
275
|
+
!$isSectionHeader && {
|
|
276
|
+
backgroundColor: cssVars.colors[$colorScheme],
|
|
266
277
|
}),
|
|
267
|
-
}),
|
|
268
|
-
...($disabled && {
|
|
269
|
-
color: cssVars.colors.textDisabled,
|
|
270
|
-
}),
|
|
271
|
-
...($selected && {
|
|
272
|
-
color: "white",
|
|
273
|
-
backgroundColor: cssVars.colors[$colorScheme],
|
|
274
|
-
}),
|
|
275
|
-
display: "flex",
|
|
276
|
-
alignItems: "center",
|
|
277
278
|
...($selected &&
|
|
278
279
|
!$isSectionHeader &&
|
|
279
280
|
($selectedPosition === "middle" ||
|
|
@@ -288,7 +289,6 @@ const RowContainer = forwardRef<
|
|
|
288
289
|
borderBottomRightRadius: "0px",
|
|
289
290
|
borderBottomLeftRadius: "0px",
|
|
290
291
|
}),
|
|
291
|
-
position: "relative",
|
|
292
292
|
...($hovered && {
|
|
293
293
|
boxShadow: `0 0 0 1px ${cssVars.colors[$colorScheme]} inset`,
|
|
294
294
|
}),
|
|
@@ -549,6 +549,7 @@ const ListViewRow = forwardRefGeneric(function ListViewRow<
|
|
|
549
549
|
)}
|
|
550
550
|
tabIndex={tabIndex}
|
|
551
551
|
className={className}
|
|
552
|
+
$clickable={!!onPress}
|
|
552
553
|
>
|
|
553
554
|
{relativeDropPosition && (
|
|
554
555
|
<ListViewDragIndicatorElement
|
|
@@ -716,7 +717,7 @@ const VirtualizedListInner = forwardRefGeneric(function VirtualizedListInner<T>(
|
|
|
716
717
|
itemSize={getItemHeight}
|
|
717
718
|
estimatedItemSize={ROW_HEIGHT}
|
|
718
719
|
>
|
|
719
|
-
{VirtualizedListRow}
|
|
720
|
+
{VirtualizedListRow as any}
|
|
720
721
|
</VariableSizeList>
|
|
721
722
|
</div>
|
|
722
723
|
),
|
|
@@ -848,10 +849,19 @@ const ListViewRootInner = forwardRefGeneric(function ListViewRootInner<T>(
|
|
|
848
849
|
pressEventName,
|
|
849
850
|
variant,
|
|
850
851
|
gap,
|
|
851
|
-
sectionHeaderVariant
|
|
852
|
+
sectionHeaderVariant,
|
|
852
853
|
isSectionHeader: false,
|
|
853
854
|
}),
|
|
854
|
-
[
|
|
855
|
+
[
|
|
856
|
+
colorScheme,
|
|
857
|
+
sortable,
|
|
858
|
+
expandable,
|
|
859
|
+
divider,
|
|
860
|
+
pressEventName,
|
|
861
|
+
variant,
|
|
862
|
+
gap,
|
|
863
|
+
sectionHeaderVariant,
|
|
864
|
+
]
|
|
855
865
|
);
|
|
856
866
|
|
|
857
867
|
const getItemContextValue = useCallback(
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { forwardRef, memo, useMemo } from "react";
|
|
2
|
+
import { Icons } from "./Icons";
|
|
3
|
+
import { Avatar } from "./Avatar";
|
|
4
|
+
import { cx } from "../utils/classNames";
|
|
5
|
+
import { Text } from "./Text";
|
|
6
|
+
import { MultiplayerUser } from "@noya-app/noya-multiplayer-react";
|
|
7
|
+
import { colorFromString } from "../utils/colorFromString";
|
|
8
|
+
import { cssVars } from "../theme";
|
|
9
|
+
|
|
10
|
+
export type MessageProps = {
|
|
11
|
+
role: "user" | "assistant" | "system" | "data";
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
user?: MultiplayerUser;
|
|
14
|
+
/** @default 27 */
|
|
15
|
+
avatarSize?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const Message = memo(
|
|
19
|
+
forwardRef(function Message(
|
|
20
|
+
{ role, children, user, avatarSize = 27 }: MessageProps,
|
|
21
|
+
ref: React.ForwardedRef<HTMLDivElement>
|
|
22
|
+
) {
|
|
23
|
+
const randomId = crypto.randomUUID();
|
|
24
|
+
const userMessageBackgroundColor = colorFromString(user?.id ?? randomId);
|
|
25
|
+
const styles = useMemo(
|
|
26
|
+
() =>
|
|
27
|
+
role === "user"
|
|
28
|
+
? {
|
|
29
|
+
backgroundColor: userMessageBackgroundColor,
|
|
30
|
+
}
|
|
31
|
+
: undefined,
|
|
32
|
+
[userMessageBackgroundColor, role]
|
|
33
|
+
);
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={cx(
|
|
37
|
+
"flex gap-2",
|
|
38
|
+
role === "user" ? "flex-row-reverse" : "flex-row"
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{role === "assistant" ? (
|
|
42
|
+
<Avatar
|
|
43
|
+
name="Assistant"
|
|
44
|
+
size={avatarSize}
|
|
45
|
+
backgroundColor={cssVars.colors.inputBackground}
|
|
46
|
+
>
|
|
47
|
+
<Icons.PersonIcon fontSize={22} color={cssVars.colors.text} />
|
|
48
|
+
</Avatar>
|
|
49
|
+
) : (
|
|
50
|
+
<Avatar
|
|
51
|
+
userId={!user ? randomId : user?.id}
|
|
52
|
+
name={user?.name ?? "Anonymous"}
|
|
53
|
+
image={user?.image}
|
|
54
|
+
size={avatarSize}
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
<div
|
|
58
|
+
className={cx(
|
|
59
|
+
"flex flex-row gap-2 rounded-lg py-2 px-3 transition-all duration-200 min-w-6 max-w-[70%] items-start",
|
|
60
|
+
role === "user" ? "ml-auto" : "bg-input-background"
|
|
61
|
+
)}
|
|
62
|
+
style={styles}
|
|
63
|
+
ref={ref}
|
|
64
|
+
>
|
|
65
|
+
<Text
|
|
66
|
+
variant="small"
|
|
67
|
+
className={role === "user" ? "text-white" : "text-text"}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</Text>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
})
|
|
75
|
+
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { clamp } from "@noya-app/noya-utils";
|
|
2
2
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
|
3
|
-
import
|
|
3
|
+
import * as React from "react";
|
|
4
4
|
import { cx } from "../utils/classNames";
|
|
5
5
|
|
|
6
6
|
type ProgressVariant = "normal" | "warning" | "primary" | "secondary";
|
|
@@ -15,7 +15,7 @@ export function Progress({
|
|
|
15
15
|
className?: string;
|
|
16
16
|
}) {
|
|
17
17
|
const clampedValue = clamp(value, 0, 100);
|
|
18
|
-
const transformStyles = useMemo(
|
|
18
|
+
const transformStyles = React.useMemo(
|
|
19
19
|
() => ({
|
|
20
20
|
transform: `translateX(-${100 - clampedValue}%)`,
|
|
21
21
|
}),
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import * as RadixScrollArea from "@radix-ui/react-scroll-area";
|
|
2
|
-
import
|
|
2
|
+
import * as React from "react";
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
|
-
children?:
|
|
5
|
+
children?:
|
|
6
|
+
| React.ReactNode
|
|
7
|
+
| ((scrollElementRef: HTMLDivElement) => React.ReactNode);
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
export const ScrollArea = memo(function ScrollArea({ children }: Props) {
|
|
10
|
+
export const ScrollArea = React.memo(function ScrollArea({ children }: Props) {
|
|
9
11
|
const [scrollElementRef, setScrollElementRef] =
|
|
10
|
-
useState<HTMLDivElement | null>(null);
|
|
12
|
+
React.useState<HTMLDivElement | null>(null);
|
|
11
13
|
|
|
12
14
|
return (
|
|
13
15
|
<div className="flex-1 min-h-0">
|
|
14
16
|
<RadixScrollArea.Root style={{ width: "100%", height: "100%" }}>
|
|
15
17
|
<RadixScrollArea.Viewport
|
|
16
18
|
className="w-full h-full [&>div]:block"
|
|
17
|
-
ref={useCallback(
|
|
19
|
+
ref={React.useCallback(
|
|
18
20
|
(ref: HTMLDivElement | null) => setScrollElementRef(ref),
|
|
19
21
|
[]
|
|
20
22
|
)}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
|
2
|
+
import React, {
|
|
3
|
+
createContext,
|
|
4
|
+
forwardRef,
|
|
5
|
+
memo,
|
|
6
|
+
ReactNode,
|
|
7
|
+
useCallback,
|
|
8
|
+
useContext,
|
|
9
|
+
useMemo,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { cx } from "../utils/classNames";
|
|
12
|
+
import { renderIcon } from "./Icons";
|
|
13
|
+
import { RegularMenuItem } from "./internal/Menu";
|
|
14
|
+
import { Tooltip } from "./Tooltip";
|
|
15
|
+
|
|
16
|
+
type SegmentedControlColorScheme = "primary" | "secondary";
|
|
17
|
+
|
|
18
|
+
type SegmentedControlContextValue = {
|
|
19
|
+
/** @default primary */
|
|
20
|
+
colorScheme?: SegmentedControlColorScheme;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SegmentedControlContext = createContext<SegmentedControlContextValue>({
|
|
24
|
+
colorScheme: "primary",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface SegmentedControlProps<T extends string> {
|
|
28
|
+
id?: string;
|
|
29
|
+
value?: T;
|
|
30
|
+
onValueChange?: (value: T) => void;
|
|
31
|
+
/** @default primary */
|
|
32
|
+
colorScheme?: SegmentedControlColorScheme;
|
|
33
|
+
allowEmpty?: boolean;
|
|
34
|
+
separator?: boolean;
|
|
35
|
+
items: SegmentedControlItemProps<T>[];
|
|
36
|
+
className?: string;
|
|
37
|
+
style?: React.CSSProperties;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SegmentedControlItemProps<T extends string> = {
|
|
41
|
+
title?: ReactNode;
|
|
42
|
+
className?: string;
|
|
43
|
+
style?: React.CSSProperties;
|
|
44
|
+
tooltip?: ReactNode;
|
|
45
|
+
} & Pick<RegularMenuItem<T>, "value" | "disabled" | "icon" | "role">;
|
|
46
|
+
|
|
47
|
+
const SegmentedControlItem = forwardRef(function SegmentedControlItem<
|
|
48
|
+
T extends string,
|
|
49
|
+
>(
|
|
50
|
+
{
|
|
51
|
+
value,
|
|
52
|
+
tooltip,
|
|
53
|
+
title,
|
|
54
|
+
disabled = false,
|
|
55
|
+
icon,
|
|
56
|
+
className,
|
|
57
|
+
...props
|
|
58
|
+
}: SegmentedControlItemProps<T>,
|
|
59
|
+
forwardedRef: React.ForwardedRef<HTMLButtonElement>
|
|
60
|
+
) {
|
|
61
|
+
const { colorScheme } = useContext(SegmentedControlContext);
|
|
62
|
+
|
|
63
|
+
const itemElement = (
|
|
64
|
+
<ToggleGroupPrimitive.Item
|
|
65
|
+
ref={forwardedRef}
|
|
66
|
+
value={(value ?? "").toString()}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
className={cx(
|
|
69
|
+
"font-sans text-heading5 font-normal relative flex-1 appearance-none text-segmented-control-item rounded inline-flex gap-1.5 items-center justify-center align-middle text-center focus:outline-none transition-colors",
|
|
70
|
+
colorScheme === "secondary"
|
|
71
|
+
? "focus:ring-2 focus:ring-secondary focus:ring-offset-1"
|
|
72
|
+
: "focus:ring-2 focus:ring-primary focus:ring-offset-1",
|
|
73
|
+
colorScheme === "secondary"
|
|
74
|
+
? "aria-checked:bg-secondary aria-checked:text-white"
|
|
75
|
+
: colorScheme === "primary"
|
|
76
|
+
? "aria-checked:bg-primary aria-checked:text-white"
|
|
77
|
+
: "aria-checked:bg-background aria-checked:text-text",
|
|
78
|
+
"focus:z-interactable",
|
|
79
|
+
"aria-checked:shadow-[0_1px_1px_rgba(0,0,0,0.1)]",
|
|
80
|
+
className
|
|
81
|
+
)}
|
|
82
|
+
{...props}
|
|
83
|
+
>
|
|
84
|
+
{icon && renderIcon(icon)}
|
|
85
|
+
{title}
|
|
86
|
+
</ToggleGroupPrimitive.Item>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return tooltip ? (
|
|
90
|
+
<Tooltip content={tooltip}>{itemElement}</Tooltip>
|
|
91
|
+
) : (
|
|
92
|
+
itemElement
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const Separator = ({ transparent }: { transparent: boolean }) => (
|
|
97
|
+
<div
|
|
98
|
+
className={cx(
|
|
99
|
+
"w-[1px] my-1 self-stretch bg-divider",
|
|
100
|
+
transparent && "bg-transparent"
|
|
101
|
+
)}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
export const SegmentedControl = memo(function SegmentedControl<
|
|
106
|
+
T extends string,
|
|
107
|
+
>({
|
|
108
|
+
id,
|
|
109
|
+
value,
|
|
110
|
+
onValueChange,
|
|
111
|
+
colorScheme,
|
|
112
|
+
allowEmpty,
|
|
113
|
+
items,
|
|
114
|
+
separator = true,
|
|
115
|
+
className,
|
|
116
|
+
style,
|
|
117
|
+
}: SegmentedControlProps<T>) {
|
|
118
|
+
const contextValue = useMemo(() => ({ colorScheme }), [colorScheme]);
|
|
119
|
+
|
|
120
|
+
const handleValueChange = useCallback(
|
|
121
|
+
(value: T) => {
|
|
122
|
+
if (!allowEmpty && !value) return;
|
|
123
|
+
onValueChange?.(value);
|
|
124
|
+
},
|
|
125
|
+
[allowEmpty, onValueChange]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const selectedIndex = items.findIndex((item) => item?.value === value);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<SegmentedControlContext.Provider value={contextValue}>
|
|
132
|
+
<ToggleGroupPrimitive.Root
|
|
133
|
+
id={id}
|
|
134
|
+
type="single"
|
|
135
|
+
value={value}
|
|
136
|
+
onValueChange={handleValueChange}
|
|
137
|
+
className={cx(
|
|
138
|
+
`flex items-stretch w-full appearance-none relative outline-none min-h-[27px] rounded bg-input-background py-[2px] px-[2px]`,
|
|
139
|
+
className
|
|
140
|
+
)}
|
|
141
|
+
style={style}
|
|
142
|
+
>
|
|
143
|
+
{separator
|
|
144
|
+
? items.map((item, index) => {
|
|
145
|
+
const isLastItem = index === items.length - 1;
|
|
146
|
+
const currentValue = item?.value;
|
|
147
|
+
|
|
148
|
+
// Show separator if:
|
|
149
|
+
// 1. Current item is not selected AND
|
|
150
|
+
// 2. Current item is not immediately before the selected item (except when selected is 0)
|
|
151
|
+
// 3. Current item is not immediately after the selected item
|
|
152
|
+
const showSeparator =
|
|
153
|
+
currentValue !== value &&
|
|
154
|
+
(selectedIndex === 0 && selectedIndex !== index
|
|
155
|
+
? true
|
|
156
|
+
: index !== selectedIndex - 1);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<React.Fragment key={index}>
|
|
160
|
+
<SegmentedControlItem key={index} {...item} />
|
|
161
|
+
{!isLastItem && <Separator transparent={!showSeparator} />}
|
|
162
|
+
</React.Fragment>
|
|
163
|
+
);
|
|
164
|
+
})
|
|
165
|
+
: items.map((item, index) => (
|
|
166
|
+
<SegmentedControlItem key={index} {...item} />
|
|
167
|
+
))}
|
|
168
|
+
</ToggleGroupPrimitive.Root>
|
|
169
|
+
</SegmentedControlContext.Provider>
|
|
170
|
+
);
|
|
171
|
+
});
|