@blibliki/ui 0.9.2
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/README.md +91 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/eslint/index.js +18 -0
- package/eslint/ui-governance-plugin.js +252 -0
- package/package.json +59 -0
- package/scripts/check-css-palette.mjs +92 -0
- package/src/UIProvider.tsx +38 -0
- package/src/components/badge.tsx +57 -0
- package/src/components/button.tsx +65 -0
- package/src/components/card.tsx +44 -0
- package/src/components/context-menu.tsx +239 -0
- package/src/components/dialog.tsx +127 -0
- package/src/components/divider.tsx +45 -0
- package/src/components/dropdown-menu.tsx +243 -0
- package/src/components/encoder.tsx +323 -0
- package/src/components/fader.tsx +230 -0
- package/src/components/icon-button.tsx +51 -0
- package/src/components/input.tsx +38 -0
- package/src/components/label.tsx +14 -0
- package/src/components/select.tsx +342 -0
- package/src/components/stack.tsx +90 -0
- package/src/components/surface.tsx +88 -0
- package/src/components/switch.tsx +72 -0
- package/src/components/text.tsx +59 -0
- package/src/components/textarea.tsx +43 -0
- package/src/index.ts +120 -0
- package/src/lib/cn.ts +6 -0
- package/src/semantic.ts +72 -0
- package/src/theme.ts +161 -0
- package/styles.css +2041 -0
- package/tokens.css +90 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { throttle } from "@blibliki/utils";
|
|
2
|
+
import { ChangeEvent } from "react";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
|
|
6
|
+
export type TOrientation = "vertical" | "horizontal";
|
|
7
|
+
|
|
8
|
+
export type MarkProps = {
|
|
9
|
+
value: number;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface FaderProps extends Omit<
|
|
14
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
15
|
+
"onChange"
|
|
16
|
+
> {
|
|
17
|
+
name: string;
|
|
18
|
+
onChange: (value: number, calculatedValue: number) => void;
|
|
19
|
+
defaultValue?: number;
|
|
20
|
+
value?: number;
|
|
21
|
+
orientation?: TOrientation;
|
|
22
|
+
marks?: readonly MarkProps[];
|
|
23
|
+
hideMarks?: boolean;
|
|
24
|
+
max?: number;
|
|
25
|
+
min?: number;
|
|
26
|
+
step?: number;
|
|
27
|
+
exp?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const calcValue = (
|
|
31
|
+
sliderValue: number,
|
|
32
|
+
min: number,
|
|
33
|
+
max: number,
|
|
34
|
+
exp?: number,
|
|
35
|
+
) => {
|
|
36
|
+
if (exp === undefined || exp === 1) return sliderValue;
|
|
37
|
+
|
|
38
|
+
const range = max - min;
|
|
39
|
+
if (range === 0) return min;
|
|
40
|
+
const normalizedSlider = (sliderValue - min) / range;
|
|
41
|
+
const curved = Math.pow(normalizedSlider, exp);
|
|
42
|
+
|
|
43
|
+
return min + curved * range;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const revCalcValue = (
|
|
47
|
+
actualValue: number,
|
|
48
|
+
min: number,
|
|
49
|
+
max: number,
|
|
50
|
+
exp?: number,
|
|
51
|
+
) => {
|
|
52
|
+
if (exp === undefined || exp === 1) return actualValue;
|
|
53
|
+
|
|
54
|
+
const range = max - min;
|
|
55
|
+
if (range === 0) return min;
|
|
56
|
+
const normalizedValue = (actualValue - min) / range;
|
|
57
|
+
const inverseExp = 1 / exp;
|
|
58
|
+
const sliderPosition = Math.pow(normalizedValue, inverseExp);
|
|
59
|
+
|
|
60
|
+
return min + sliderPosition * range;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function formatTooltipValue(value?: number) {
|
|
64
|
+
if (value === undefined) return "";
|
|
65
|
+
if (Number.isInteger(value)) return `${value}`;
|
|
66
|
+
return value.toFixed(2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
70
|
+
Math.min(max, Math.max(min, value));
|
|
71
|
+
|
|
72
|
+
function Fader(props: FaderProps) {
|
|
73
|
+
const {
|
|
74
|
+
className,
|
|
75
|
+
name,
|
|
76
|
+
onChange,
|
|
77
|
+
value,
|
|
78
|
+
defaultValue,
|
|
79
|
+
marks,
|
|
80
|
+
hideMarks = false,
|
|
81
|
+
exp,
|
|
82
|
+
min = 0,
|
|
83
|
+
orientation = "vertical",
|
|
84
|
+
max: maxProp,
|
|
85
|
+
step: stepProp,
|
|
86
|
+
...rest
|
|
87
|
+
} = props;
|
|
88
|
+
|
|
89
|
+
const marksCount = marks?.length ?? 0;
|
|
90
|
+
const hasMarkedScale = marksCount > 0;
|
|
91
|
+
const max = maxProp ?? (hasMarkedScale ? marksCount - 1 : 1);
|
|
92
|
+
const step = stepProp ?? (hasMarkedScale ? 1 : 0.01);
|
|
93
|
+
|
|
94
|
+
const sliderValue =
|
|
95
|
+
value !== undefined
|
|
96
|
+
? clamp(revCalcValue(value, min, max, exp), min, max)
|
|
97
|
+
: undefined;
|
|
98
|
+
const sliderDefaultValue =
|
|
99
|
+
defaultValue !== undefined ? clamp(defaultValue, min, max) : min;
|
|
100
|
+
|
|
101
|
+
const onRangeChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
102
|
+
const nextSliderValue = clamp(Number(event.target.value), min, max);
|
|
103
|
+
onChange(nextSliderValue, calcValue(nextSliderValue, min, max, exp));
|
|
104
|
+
};
|
|
105
|
+
const debouncedOnChange = throttle(onRangeChange, 1000);
|
|
106
|
+
|
|
107
|
+
const onMarkClick = (markValue: number) => () => {
|
|
108
|
+
const nextSliderValue = clamp(markValue, min, max);
|
|
109
|
+
onChange(nextSliderValue, calcValue(nextSliderValue, min, max, exp));
|
|
110
|
+
};
|
|
111
|
+
const getHorizontalMarkPosition = (markValue: number) => {
|
|
112
|
+
if (max === min) return 0;
|
|
113
|
+
return ((clamp(markValue, min, max) - min) / (max - min)) * 100;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const hasOnlyZeroMarks = marks
|
|
117
|
+
? marks.every((mark) => mark.value === 0)
|
|
118
|
+
: false;
|
|
119
|
+
const showTooltip = hideMarks || !marks || hasOnlyZeroMarks;
|
|
120
|
+
const currentSliderValue = sliderValue ?? sliderDefaultValue;
|
|
121
|
+
const activeMark = marks?.find((mark) => mark.value === currentSliderValue);
|
|
122
|
+
const currentDisplayValue =
|
|
123
|
+
value ?? calcValue(currentSliderValue, min, max, exp);
|
|
124
|
+
const tooltipText =
|
|
125
|
+
activeMark?.label ?? formatTooltipValue(currentDisplayValue);
|
|
126
|
+
|
|
127
|
+
const rangeValueProps =
|
|
128
|
+
sliderValue !== undefined
|
|
129
|
+
? ({ value: sliderValue } as const)
|
|
130
|
+
: ({ defaultValue: sliderDefaultValue } as const);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
className={cn(
|
|
135
|
+
"ui-fader nodrag",
|
|
136
|
+
orientation === "horizontal" && "ui-fader--horizontal",
|
|
137
|
+
className,
|
|
138
|
+
)}
|
|
139
|
+
{...rest}
|
|
140
|
+
>
|
|
141
|
+
<div
|
|
142
|
+
className={cn(
|
|
143
|
+
"ui-fader__control",
|
|
144
|
+
orientation === "horizontal"
|
|
145
|
+
? "ui-fader__control--horizontal"
|
|
146
|
+
: "ui-fader__control--vertical",
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<input
|
|
150
|
+
type="range"
|
|
151
|
+
min={min}
|
|
152
|
+
max={max}
|
|
153
|
+
{...rangeValueProps}
|
|
154
|
+
step={step}
|
|
155
|
+
aria-label={name}
|
|
156
|
+
className={cn(
|
|
157
|
+
"ui-fader__range",
|
|
158
|
+
orientation === "horizontal"
|
|
159
|
+
? "ui-fader__range--horizontal"
|
|
160
|
+
: "ui-fader__range--vertical",
|
|
161
|
+
)}
|
|
162
|
+
onChange={debouncedOnChange}
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
{showTooltip && (
|
|
166
|
+
<div
|
|
167
|
+
className={cn(
|
|
168
|
+
"ui-fader__tooltip",
|
|
169
|
+
orientation === "horizontal"
|
|
170
|
+
? "ui-fader__tooltip--horizontal"
|
|
171
|
+
: "ui-fader__tooltip--vertical",
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{tooltipText}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{marks && !hideMarks && (
|
|
179
|
+
<div
|
|
180
|
+
className={cn(
|
|
181
|
+
"ui-fader__marks",
|
|
182
|
+
orientation === "horizontal"
|
|
183
|
+
? "ui-fader__marks--horizontal"
|
|
184
|
+
: "ui-fader__marks--vertical",
|
|
185
|
+
marks.length === 1 && "ui-fader__marks--single",
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
{marks.map((mark, index) => (
|
|
189
|
+
<Button
|
|
190
|
+
key={`${mark.value}-${index}`}
|
|
191
|
+
size="sm"
|
|
192
|
+
variant="text"
|
|
193
|
+
color="secondary"
|
|
194
|
+
className={cn(
|
|
195
|
+
"ui-fader__mark-button",
|
|
196
|
+
orientation === "horizontal" &&
|
|
197
|
+
"ui-fader__mark-button--horizontal",
|
|
198
|
+
orientation === "horizontal" &&
|
|
199
|
+
mark.value <= min &&
|
|
200
|
+
"ui-fader__mark-button--horizontal-start",
|
|
201
|
+
orientation === "horizontal" &&
|
|
202
|
+
mark.value >= max &&
|
|
203
|
+
"ui-fader__mark-button--horizontal-end",
|
|
204
|
+
)}
|
|
205
|
+
style={
|
|
206
|
+
orientation === "horizontal"
|
|
207
|
+
? { left: `${getHorizontalMarkPosition(mark.value)}%` }
|
|
208
|
+
: undefined
|
|
209
|
+
}
|
|
210
|
+
onClick={onMarkClick(mark.value)}
|
|
211
|
+
>
|
|
212
|
+
<span className="ui-fader__mark-dot" aria-hidden />
|
|
213
|
+
{mark.label}
|
|
214
|
+
</Button>
|
|
215
|
+
))}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div className="ui-fader__label-row">
|
|
221
|
+
<span className="ui-fader__label-dot" aria-hidden />
|
|
222
|
+
<span className="ui-fader__label">{name}</span>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
Fader.displayName = "Fader";
|
|
229
|
+
|
|
230
|
+
export { Fader };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
import { Button, type ButtonProps } from "./button";
|
|
4
|
+
|
|
5
|
+
type IconButtonSize = "xs" | "sm" | "md";
|
|
6
|
+
|
|
7
|
+
const sizeClass: Record<IconButtonSize, string> = {
|
|
8
|
+
xs: "ui-icon-button--size-xs",
|
|
9
|
+
sm: "ui-icon-button--size-sm",
|
|
10
|
+
md: "ui-icon-button--size-md",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface IconButtonProps extends Omit<
|
|
14
|
+
ButtonProps,
|
|
15
|
+
"children" | "size" | "asChild"
|
|
16
|
+
> {
|
|
17
|
+
icon: React.ReactNode;
|
|
18
|
+
size?: IconButtonSize;
|
|
19
|
+
"aria-label": string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
23
|
+
(
|
|
24
|
+
{
|
|
25
|
+
icon,
|
|
26
|
+
size = "md",
|
|
27
|
+
variant = "text",
|
|
28
|
+
color = "secondary",
|
|
29
|
+
className,
|
|
30
|
+
...props
|
|
31
|
+
},
|
|
32
|
+
ref,
|
|
33
|
+
) => {
|
|
34
|
+
return (
|
|
35
|
+
<Button
|
|
36
|
+
ref={ref}
|
|
37
|
+
variant={variant}
|
|
38
|
+
color={color}
|
|
39
|
+
size="icon"
|
|
40
|
+
className={cn("ui-icon-button", sizeClass[size], className)}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
{icon}
|
|
44
|
+
</Button>
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
IconButton.displayName = "IconButton";
|
|
50
|
+
|
|
51
|
+
export { IconButton };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { forwardRef, type InputHTMLAttributes } from "react";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
|
|
5
|
+
const inputVariants = cva("ui-input", {
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: "ui-input--size-sm",
|
|
9
|
+
md: "ui-input--size-md",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
defaultVariants: {
|
|
13
|
+
size: "md",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export interface InputProps
|
|
18
|
+
extends
|
|
19
|
+
Omit<InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
20
|
+
VariantProps<typeof inputVariants> {}
|
|
21
|
+
|
|
22
|
+
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
23
|
+
({ className, type = "text", size, ...props }, ref) => {
|
|
24
|
+
return (
|
|
25
|
+
<input
|
|
26
|
+
ref={ref}
|
|
27
|
+
type={type}
|
|
28
|
+
data-size={size ?? "md"}
|
|
29
|
+
className={cn(inputVariants({ size }), className)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
Input.displayName = "Input";
|
|
37
|
+
|
|
38
|
+
export { Input, inputVariants };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
|
|
5
|
+
function Label({
|
|
6
|
+
className,
|
|
7
|
+
...props
|
|
8
|
+
}: ComponentProps<typeof LabelPrimitive.Root>) {
|
|
9
|
+
return (
|
|
10
|
+
<LabelPrimitive.Root className={cn("ui-label", className)} {...props} />
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { Label };
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
|
|
6
|
+
function SelectChevronDownIcon(props: ComponentProps<"svg">) {
|
|
7
|
+
return (
|
|
8
|
+
<svg viewBox="0 0 16 16" fill="none" aria-hidden {...props}>
|
|
9
|
+
<path
|
|
10
|
+
d="M4 6.5 8 10.5 12 6.5"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
strokeWidth="1.6"
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
/>
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function SelectChevronUpIcon(props: ComponentProps<"svg">) {
|
|
21
|
+
return (
|
|
22
|
+
<svg viewBox="0 0 16 16" fill="none" aria-hidden {...props}>
|
|
23
|
+
<path
|
|
24
|
+
d="M4 9.5 8 5.5 12 9.5"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
strokeWidth="1.6"
|
|
27
|
+
strokeLinecap="round"
|
|
28
|
+
strokeLinejoin="round"
|
|
29
|
+
/>
|
|
30
|
+
</svg>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function SelectCheckIcon(props: ComponentProps<"svg">) {
|
|
35
|
+
return (
|
|
36
|
+
<svg viewBox="0 0 16 16" fill="none" aria-hidden {...props}>
|
|
37
|
+
<path
|
|
38
|
+
d="M3.5 8.5 6.5 11.5 12.5 4.5"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
strokeWidth="1.75"
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
strokeLinejoin="round"
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Select(props: ComponentProps<typeof SelectPrimitive.Root>) {
|
|
49
|
+
return <SelectPrimitive.Root {...props} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function SelectGroup(props: ComponentProps<typeof SelectPrimitive.Group>) {
|
|
53
|
+
return <SelectPrimitive.Group {...props} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function SelectValue({
|
|
57
|
+
className,
|
|
58
|
+
...props
|
|
59
|
+
}: ComponentProps<typeof SelectPrimitive.Value>) {
|
|
60
|
+
return (
|
|
61
|
+
<SelectPrimitive.Value
|
|
62
|
+
className={cn("ui-select-value", className)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function SelectTrigger({
|
|
69
|
+
className,
|
|
70
|
+
size = "md",
|
|
71
|
+
children,
|
|
72
|
+
...props
|
|
73
|
+
}: ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
74
|
+
size?: "sm" | "md";
|
|
75
|
+
}) {
|
|
76
|
+
return (
|
|
77
|
+
<SelectPrimitive.Trigger
|
|
78
|
+
data-size={size}
|
|
79
|
+
className={cn("ui-select-trigger", className)}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
<SelectPrimitive.Icon asChild>
|
|
84
|
+
<SelectChevronDownIcon className="ui-select-chevron" />
|
|
85
|
+
</SelectPrimitive.Icon>
|
|
86
|
+
</SelectPrimitive.Trigger>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function SelectContent({
|
|
91
|
+
className,
|
|
92
|
+
children,
|
|
93
|
+
position = "popper",
|
|
94
|
+
...props
|
|
95
|
+
}: ComponentProps<typeof SelectPrimitive.Content>) {
|
|
96
|
+
return (
|
|
97
|
+
<SelectPrimitive.Portal>
|
|
98
|
+
<SelectPrimitive.Content
|
|
99
|
+
position={position}
|
|
100
|
+
className={cn(
|
|
101
|
+
"ui-select-content",
|
|
102
|
+
position === "popper" && "ui-select-content--popper",
|
|
103
|
+
className,
|
|
104
|
+
)}
|
|
105
|
+
{...props}
|
|
106
|
+
>
|
|
107
|
+
<SelectScrollUpButton />
|
|
108
|
+
<SelectPrimitive.Viewport
|
|
109
|
+
className={cn(
|
|
110
|
+
"ui-select-viewport",
|
|
111
|
+
position === "popper" && "ui-select-viewport--popper",
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
{children}
|
|
115
|
+
</SelectPrimitive.Viewport>
|
|
116
|
+
<SelectScrollDownButton />
|
|
117
|
+
</SelectPrimitive.Content>
|
|
118
|
+
</SelectPrimitive.Portal>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function SelectLabel({
|
|
123
|
+
className,
|
|
124
|
+
...props
|
|
125
|
+
}: ComponentProps<typeof SelectPrimitive.Label>) {
|
|
126
|
+
return (
|
|
127
|
+
<SelectPrimitive.Label
|
|
128
|
+
className={cn("ui-select-label", className)}
|
|
129
|
+
{...props}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function SelectItem({
|
|
135
|
+
className,
|
|
136
|
+
children,
|
|
137
|
+
...props
|
|
138
|
+
}: ComponentProps<typeof SelectPrimitive.Item>) {
|
|
139
|
+
return (
|
|
140
|
+
<SelectPrimitive.Item
|
|
141
|
+
className={cn("ui-select-item", className)}
|
|
142
|
+
{...props}
|
|
143
|
+
>
|
|
144
|
+
<span className="ui-select-indicator" aria-hidden>
|
|
145
|
+
<SelectPrimitive.ItemIndicator>
|
|
146
|
+
<SelectCheckIcon className="ui-select-indicator-icon" />
|
|
147
|
+
</SelectPrimitive.ItemIndicator>
|
|
148
|
+
</span>
|
|
149
|
+
<SelectPrimitive.ItemText className="ui-select-item-text">
|
|
150
|
+
{children}
|
|
151
|
+
</SelectPrimitive.ItemText>
|
|
152
|
+
</SelectPrimitive.Item>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function SelectSeparator({
|
|
157
|
+
className,
|
|
158
|
+
...props
|
|
159
|
+
}: ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
160
|
+
return (
|
|
161
|
+
<SelectPrimitive.Separator
|
|
162
|
+
className={cn("ui-select-separator", className)}
|
|
163
|
+
{...props}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function SelectScrollUpButton({
|
|
169
|
+
className,
|
|
170
|
+
...props
|
|
171
|
+
}: ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
172
|
+
return (
|
|
173
|
+
<SelectPrimitive.ScrollUpButton
|
|
174
|
+
className={cn("ui-select-scroll-button", className)}
|
|
175
|
+
{...props}
|
|
176
|
+
>
|
|
177
|
+
<SelectChevronUpIcon className="ui-select-scroll-icon" />
|
|
178
|
+
</SelectPrimitive.ScrollUpButton>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function SelectScrollDownButton({
|
|
183
|
+
className,
|
|
184
|
+
...props
|
|
185
|
+
}: ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
186
|
+
return (
|
|
187
|
+
<SelectPrimitive.ScrollDownButton
|
|
188
|
+
className={cn("ui-select-scroll-button", className)}
|
|
189
|
+
{...props}
|
|
190
|
+
>
|
|
191
|
+
<SelectChevronDownIcon className="ui-select-scroll-icon" />
|
|
192
|
+
</SelectPrimitive.ScrollDownButton>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type OptionSelectValue = string | number;
|
|
197
|
+
type OptionSelectValueOption = { name: string; value: OptionSelectValue };
|
|
198
|
+
type OptionSelectIdOption = { id: string; name: string };
|
|
199
|
+
type OptionSelectInput =
|
|
200
|
+
| readonly string[]
|
|
201
|
+
| readonly number[]
|
|
202
|
+
| readonly OptionSelectValueOption[]
|
|
203
|
+
| readonly OptionSelectIdOption[];
|
|
204
|
+
|
|
205
|
+
export interface OptionSelectProps<T extends OptionSelectValue | undefined> {
|
|
206
|
+
value: T;
|
|
207
|
+
options: OptionSelectInput;
|
|
208
|
+
label?: string;
|
|
209
|
+
contentClassName?: string;
|
|
210
|
+
triggerClassName?: string;
|
|
211
|
+
disabled?: boolean;
|
|
212
|
+
onChange: (value: T) => void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isValueOption(value: unknown): value is OptionSelectValueOption {
|
|
216
|
+
return Boolean(
|
|
217
|
+
value &&
|
|
218
|
+
typeof value === "object" &&
|
|
219
|
+
"name" in value &&
|
|
220
|
+
"value" in value &&
|
|
221
|
+
(typeof (value as { value: unknown }).value === "string" ||
|
|
222
|
+
typeof (value as { value: unknown }).value === "number"),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isIdOption(value: unknown): value is OptionSelectIdOption {
|
|
227
|
+
return Boolean(
|
|
228
|
+
value &&
|
|
229
|
+
typeof value === "object" &&
|
|
230
|
+
"id" in value &&
|
|
231
|
+
"name" in value &&
|
|
232
|
+
typeof (value as { id: unknown }).id === "string",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function OptionSelect<T extends OptionSelectValue | undefined>({
|
|
237
|
+
value,
|
|
238
|
+
options,
|
|
239
|
+
label,
|
|
240
|
+
contentClassName,
|
|
241
|
+
triggerClassName,
|
|
242
|
+
disabled = false,
|
|
243
|
+
onChange,
|
|
244
|
+
}: OptionSelectProps<T>) {
|
|
245
|
+
const normalizedOptions = useMemo(() => {
|
|
246
|
+
if (!options.length) return [];
|
|
247
|
+
|
|
248
|
+
const first = options[0];
|
|
249
|
+
if (typeof first === "string" || typeof first === "number") {
|
|
250
|
+
return (options as readonly OptionSelectValue[]).map((option) => ({
|
|
251
|
+
name: option.toString(),
|
|
252
|
+
value: option,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (isValueOption(first)) {
|
|
257
|
+
return Array.from(options as readonly OptionSelectValueOption[]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (isIdOption(first)) {
|
|
261
|
+
return (options as readonly OptionSelectIdOption[]).map((option) => ({
|
|
262
|
+
name: option.name,
|
|
263
|
+
value: option.id,
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return [];
|
|
268
|
+
}, [options]);
|
|
269
|
+
|
|
270
|
+
const valueKind = useMemo<"number" | "string">(() => {
|
|
271
|
+
if (!normalizedOptions.length) {
|
|
272
|
+
return typeof value === "number" ? "number" : "string";
|
|
273
|
+
}
|
|
274
|
+
return typeof normalizedOptions[0]?.value === "number"
|
|
275
|
+
? "number"
|
|
276
|
+
: "string";
|
|
277
|
+
}, [normalizedOptions, value]);
|
|
278
|
+
|
|
279
|
+
const onValueChange = (newValue: string) => {
|
|
280
|
+
if (valueKind === "number") {
|
|
281
|
+
onChange(Number(newValue) as T);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
onChange(newValue as T);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const selectedValue =
|
|
288
|
+
value !== undefined && value !== "" ? value.toString() : undefined;
|
|
289
|
+
const placeholder = label && label.length > 0 ? label : "Select...";
|
|
290
|
+
const longestDisplayLength = useMemo(() => {
|
|
291
|
+
const longestOption = normalizedOptions.reduce((maxLength, option) => {
|
|
292
|
+
const length = option.name.length;
|
|
293
|
+
return Math.max(maxLength, length);
|
|
294
|
+
}, 0);
|
|
295
|
+
|
|
296
|
+
return Math.max(placeholder.length, longestOption);
|
|
297
|
+
}, [normalizedOptions, placeholder]);
|
|
298
|
+
const autoWidthCh = Math.min(Math.max(longestDisplayLength + 6, 18), 40);
|
|
299
|
+
|
|
300
|
+
const triggerStyle = triggerClassName
|
|
301
|
+
? undefined
|
|
302
|
+
: ({
|
|
303
|
+
width: `clamp(10rem, ${autoWidthCh}ch, 26rem)`,
|
|
304
|
+
maxWidth: "100%",
|
|
305
|
+
} as const);
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<Select
|
|
309
|
+
value={selectedValue}
|
|
310
|
+
onValueChange={onValueChange}
|
|
311
|
+
disabled={disabled}
|
|
312
|
+
>
|
|
313
|
+
<SelectTrigger className={triggerClassName} style={triggerStyle}>
|
|
314
|
+
<SelectValue placeholder={placeholder} />
|
|
315
|
+
</SelectTrigger>
|
|
316
|
+
<SelectContent className={contentClassName}>
|
|
317
|
+
<SelectGroup>
|
|
318
|
+
<SelectLabel>Select...</SelectLabel>
|
|
319
|
+
{normalizedOptions.map((option) => (
|
|
320
|
+
<SelectItem key={option.value} value={option.value.toString()}>
|
|
321
|
+
{option.name}
|
|
322
|
+
</SelectItem>
|
|
323
|
+
))}
|
|
324
|
+
</SelectGroup>
|
|
325
|
+
</SelectContent>
|
|
326
|
+
</Select>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export {
|
|
331
|
+
OptionSelect,
|
|
332
|
+
Select,
|
|
333
|
+
SelectContent,
|
|
334
|
+
SelectGroup,
|
|
335
|
+
SelectItem,
|
|
336
|
+
SelectLabel,
|
|
337
|
+
SelectScrollDownButton,
|
|
338
|
+
SelectScrollUpButton,
|
|
339
|
+
SelectSeparator,
|
|
340
|
+
SelectTrigger,
|
|
341
|
+
SelectValue,
|
|
342
|
+
};
|