@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.
@@ -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
+ };