@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,243 @@
1
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
2
+ import * as React from "react";
3
+ import { cn } from "@/lib/cn";
4
+
5
+ function MenuCheckIcon(props: React.ComponentProps<"svg">) {
6
+ return (
7
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden {...props}>
8
+ <path
9
+ d="M3.5 8.5 6.5 11.5 12.5 4.5"
10
+ stroke="currentColor"
11
+ strokeWidth="1.75"
12
+ strokeLinecap="round"
13
+ strokeLinejoin="round"
14
+ />
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ function MenuDotIcon(props: React.ComponentProps<"svg">) {
20
+ return (
21
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden {...props}>
22
+ <circle cx="8" cy="8" r="3" />
23
+ </svg>
24
+ );
25
+ }
26
+
27
+ function MenuChevronRightIcon(props: React.ComponentProps<"svg">) {
28
+ return (
29
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden {...props}>
30
+ <path
31
+ d="M6 3.5 10.5 8 6 12.5"
32
+ stroke="currentColor"
33
+ strokeWidth="1.5"
34
+ strokeLinecap="round"
35
+ strokeLinejoin="round"
36
+ />
37
+ </svg>
38
+ );
39
+ }
40
+
41
+ function DropdownMenu(
42
+ props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>,
43
+ ) {
44
+ return <DropdownMenuPrimitive.Root {...props} />;
45
+ }
46
+
47
+ function DropdownMenuPortal(
48
+ props: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
49
+ ) {
50
+ return <DropdownMenuPrimitive.Portal {...props} />;
51
+ }
52
+
53
+ function DropdownMenuTrigger(
54
+ props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>,
55
+ ) {
56
+ return <DropdownMenuPrimitive.Trigger {...props} />;
57
+ }
58
+
59
+ function DropdownMenuContent({
60
+ className,
61
+ sideOffset = 6,
62
+ ...props
63
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
64
+ return (
65
+ <DropdownMenuPrimitive.Portal>
66
+ <DropdownMenuPrimitive.Content
67
+ sideOffset={sideOffset}
68
+ className={cn("ui-dropdown-content", className)}
69
+ {...props}
70
+ />
71
+ </DropdownMenuPrimitive.Portal>
72
+ );
73
+ }
74
+
75
+ function DropdownMenuGroup(
76
+ props: React.ComponentProps<typeof DropdownMenuPrimitive.Group>,
77
+ ) {
78
+ return <DropdownMenuPrimitive.Group {...props} />;
79
+ }
80
+
81
+ function DropdownMenuItem({
82
+ className,
83
+ inset = false,
84
+ variant = "default",
85
+ ...props
86
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
87
+ inset?: boolean;
88
+ variant?: "default" | "destructive";
89
+ }) {
90
+ return (
91
+ <DropdownMenuPrimitive.Item
92
+ data-inset={inset ? "true" : undefined}
93
+ data-variant={variant}
94
+ className={cn("ui-dropdown-item", className)}
95
+ {...props}
96
+ />
97
+ );
98
+ }
99
+
100
+ function DropdownMenuCheckboxItem({
101
+ className,
102
+ children,
103
+ checked,
104
+ ...props
105
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
106
+ return (
107
+ <DropdownMenuPrimitive.CheckboxItem
108
+ checked={checked}
109
+ className={cn(
110
+ "ui-dropdown-item ui-dropdown-item--with-indicator",
111
+ className,
112
+ )}
113
+ {...props}
114
+ >
115
+ <span className="ui-dropdown-indicator" aria-hidden>
116
+ <DropdownMenuPrimitive.ItemIndicator>
117
+ <MenuCheckIcon className="ui-dropdown-indicator-icon" />
118
+ </DropdownMenuPrimitive.ItemIndicator>
119
+ </span>
120
+ {children}
121
+ </DropdownMenuPrimitive.CheckboxItem>
122
+ );
123
+ }
124
+
125
+ function DropdownMenuRadioGroup(
126
+ props: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>,
127
+ ) {
128
+ return <DropdownMenuPrimitive.RadioGroup {...props} />;
129
+ }
130
+
131
+ function DropdownMenuRadioItem({
132
+ className,
133
+ children,
134
+ ...props
135
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
136
+ return (
137
+ <DropdownMenuPrimitive.RadioItem
138
+ className={cn(
139
+ "ui-dropdown-item ui-dropdown-item--with-indicator",
140
+ className,
141
+ )}
142
+ {...props}
143
+ >
144
+ <span className="ui-dropdown-indicator" aria-hidden>
145
+ <DropdownMenuPrimitive.ItemIndicator>
146
+ <MenuDotIcon className="ui-dropdown-indicator-icon" />
147
+ </DropdownMenuPrimitive.ItemIndicator>
148
+ </span>
149
+ {children}
150
+ </DropdownMenuPrimitive.RadioItem>
151
+ );
152
+ }
153
+
154
+ function DropdownMenuLabel({
155
+ className,
156
+ inset = false,
157
+ ...props
158
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
159
+ inset?: boolean;
160
+ }) {
161
+ return (
162
+ <DropdownMenuPrimitive.Label
163
+ data-inset={inset ? "true" : undefined}
164
+ className={cn("ui-dropdown-label", className)}
165
+ {...props}
166
+ />
167
+ );
168
+ }
169
+
170
+ function DropdownMenuSeparator({
171
+ className,
172
+ ...props
173
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
174
+ return (
175
+ <DropdownMenuPrimitive.Separator
176
+ className={cn("ui-dropdown-separator", className)}
177
+ {...props}
178
+ />
179
+ );
180
+ }
181
+
182
+ function DropdownMenuShortcut({
183
+ className,
184
+ ...props
185
+ }: React.ComponentProps<"span">) {
186
+ return <span className={cn("ui-dropdown-shortcut", className)} {...props} />;
187
+ }
188
+
189
+ function DropdownMenuSub(
190
+ props: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>,
191
+ ) {
192
+ return <DropdownMenuPrimitive.Sub {...props} />;
193
+ }
194
+
195
+ function DropdownMenuSubTrigger({
196
+ className,
197
+ inset = false,
198
+ children,
199
+ ...props
200
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
201
+ inset?: boolean;
202
+ }) {
203
+ return (
204
+ <DropdownMenuPrimitive.SubTrigger
205
+ data-inset={inset ? "true" : undefined}
206
+ className={cn("ui-dropdown-item ui-dropdown-sub-trigger", className)}
207
+ {...props}
208
+ >
209
+ {children}
210
+ <MenuChevronRightIcon className="ui-dropdown-chevron" />
211
+ </DropdownMenuPrimitive.SubTrigger>
212
+ );
213
+ }
214
+
215
+ function DropdownMenuSubContent({
216
+ className,
217
+ ...props
218
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
219
+ return (
220
+ <DropdownMenuPrimitive.SubContent
221
+ className={cn("ui-dropdown-content ui-dropdown-sub-content", className)}
222
+ {...props}
223
+ />
224
+ );
225
+ }
226
+
227
+ export {
228
+ DropdownMenu,
229
+ DropdownMenuPortal,
230
+ DropdownMenuTrigger,
231
+ DropdownMenuContent,
232
+ DropdownMenuGroup,
233
+ DropdownMenuLabel,
234
+ DropdownMenuItem,
235
+ DropdownMenuCheckboxItem,
236
+ DropdownMenuRadioGroup,
237
+ DropdownMenuRadioItem,
238
+ DropdownMenuSeparator,
239
+ DropdownMenuShortcut,
240
+ DropdownMenuSub,
241
+ DropdownMenuSubTrigger,
242
+ DropdownMenuSubContent,
243
+ };
@@ -0,0 +1,323 @@
1
+ import {
2
+ forwardRef,
3
+ type CSSProperties,
4
+ type HTMLAttributes,
5
+ type KeyboardEvent,
6
+ type PointerEvent,
7
+ type WheelEvent,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { cn } from "@/lib/cn";
12
+
13
+ type EncoderSize = "sm" | "md";
14
+
15
+ type DragState = {
16
+ pointerId: number;
17
+ lastY: number;
18
+ sliderValue: number;
19
+ };
20
+
21
+ const FULL_RANGE_DRAG_DISTANCE_PX = 80;
22
+ const FINE_DRAG_DISTANCE_MULTIPLIER = 8;
23
+
24
+ const clamp = (value: number, min: number, max: number) =>
25
+ Math.min(max, Math.max(min, value));
26
+
27
+ const calcSliderValue = (
28
+ actualValue: number,
29
+ min: number,
30
+ max: number,
31
+ exp?: number,
32
+ ) => {
33
+ if (exp === undefined || exp === 1) return actualValue;
34
+
35
+ const range = max - min;
36
+ if (range === 0) return min;
37
+
38
+ const normalizedValue = (actualValue - min) / range;
39
+ const inverseExp = 1 / exp;
40
+
41
+ return min + Math.pow(normalizedValue, inverseExp) * range;
42
+ };
43
+
44
+ const calcActualValue = (
45
+ sliderValue: number,
46
+ min: number,
47
+ max: number,
48
+ exp?: number,
49
+ ) => {
50
+ if (exp === undefined || exp === 1) return sliderValue;
51
+
52
+ const range = max - min;
53
+ if (range === 0) return min;
54
+
55
+ const normalizedSlider = (sliderValue - min) / range;
56
+
57
+ return min + Math.pow(normalizedSlider, exp) * range;
58
+ };
59
+
60
+ const getStepPrecision = (step: number) => {
61
+ const stepString = `${step}`;
62
+ const decimalIndex = stepString.indexOf(".");
63
+
64
+ return decimalIndex === -1 ? 0 : stepString.length - decimalIndex - 1;
65
+ };
66
+
67
+ const roundToStep = (value: number, min: number, step: number) => {
68
+ const precision = getStepPrecision(step);
69
+ const nextValue = Math.round((value - min) / step) * step + min;
70
+
71
+ return Number(nextValue.toFixed(precision));
72
+ };
73
+
74
+ const formatDisplayValue = (value: number, step: number) => {
75
+ const precision = Math.min(4, getStepPrecision(step));
76
+
77
+ if (precision === 0) return `${Math.round(value)}`;
78
+
79
+ return `${Number(value.toFixed(precision))}`;
80
+ };
81
+
82
+ export interface EncoderProps extends Omit<
83
+ HTMLAttributes<HTMLDivElement>,
84
+ "onChange"
85
+ > {
86
+ name: string;
87
+ onChange: (value: number) => void;
88
+ defaultValue?: number;
89
+ value?: number;
90
+ max?: number;
91
+ min?: number;
92
+ step?: number;
93
+ exp?: number;
94
+ size?: EncoderSize;
95
+ disabled?: boolean;
96
+ formatValue?: (value: number) => string;
97
+ }
98
+
99
+ const Encoder = forwardRef<HTMLDivElement, EncoderProps>(
100
+ (
101
+ {
102
+ className,
103
+ style,
104
+ name,
105
+ onChange,
106
+ value,
107
+ defaultValue,
108
+ max = 1,
109
+ min = 0,
110
+ step = 0.01,
111
+ exp,
112
+ size = "md",
113
+ disabled = false,
114
+ formatValue,
115
+ onKeyDown,
116
+ onPointerDown,
117
+ onPointerMove,
118
+ onPointerUp,
119
+ onPointerCancel,
120
+ onWheel,
121
+ ...rest
122
+ },
123
+ ref,
124
+ ) => {
125
+ const [uncontrolledValue, setUncontrolledValue] = useState(
126
+ clamp(defaultValue ?? min, min, max),
127
+ );
128
+ const dragStateRef = useRef<DragState | null>(null);
129
+
130
+ const currentValue = clamp(value ?? uncontrolledValue, min, max);
131
+ const currentSliderValue = clamp(
132
+ calcSliderValue(currentValue, min, max, exp),
133
+ min,
134
+ max,
135
+ );
136
+
137
+ const progress = max === min ? 0 : (currentSliderValue - min) / (max - min);
138
+
139
+ const displayValue = formatValue
140
+ ? formatValue(currentValue)
141
+ : formatDisplayValue(currentValue, step);
142
+
143
+ const commitValue = (nextRawValue: number) => {
144
+ const nextValue = clamp(roundToStep(nextRawValue, min, step), min, max);
145
+
146
+ if (nextValue === currentValue) return;
147
+
148
+ if (value === undefined) {
149
+ setUncontrolledValue(nextValue);
150
+ }
151
+
152
+ onChange(nextValue);
153
+ };
154
+
155
+ const changeBySteps = (stepDelta: number) => {
156
+ commitValue(currentValue + stepDelta * step);
157
+ };
158
+
159
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
160
+ onKeyDown?.(event);
161
+
162
+ if (event.defaultPrevented || disabled) return;
163
+
164
+ switch (event.key) {
165
+ case "ArrowUp":
166
+ case "ArrowRight":
167
+ event.preventDefault();
168
+ changeBySteps(1);
169
+ return;
170
+ case "ArrowDown":
171
+ case "ArrowLeft":
172
+ event.preventDefault();
173
+ changeBySteps(-1);
174
+ return;
175
+ case "PageUp":
176
+ event.preventDefault();
177
+ changeBySteps(10);
178
+ return;
179
+ case "PageDown":
180
+ event.preventDefault();
181
+ changeBySteps(-10);
182
+ return;
183
+ case "Home":
184
+ event.preventDefault();
185
+ commitValue(min);
186
+ return;
187
+ case "End":
188
+ event.preventDefault();
189
+ commitValue(max);
190
+ return;
191
+ default:
192
+ return;
193
+ }
194
+ };
195
+
196
+ const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
197
+ onPointerDown?.(event);
198
+
199
+ if (event.defaultPrevented || disabled) return;
200
+
201
+ event.preventDefault();
202
+ event.currentTarget.focus();
203
+ event.currentTarget.setPointerCapture(event.pointerId);
204
+
205
+ dragStateRef.current = {
206
+ pointerId: event.pointerId,
207
+ lastY: event.clientY,
208
+ sliderValue: currentSliderValue,
209
+ };
210
+ };
211
+
212
+ const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
213
+ onPointerMove?.(event);
214
+
215
+ if (event.defaultPrevented || disabled) return;
216
+
217
+ const dragState = dragStateRef.current;
218
+
219
+ if (dragState?.pointerId !== event.pointerId) return;
220
+
221
+ event.preventDefault();
222
+
223
+ const dragDistance =
224
+ FULL_RANGE_DRAG_DISTANCE_PX *
225
+ (event.shiftKey ? FINE_DRAG_DISTANCE_MULTIPLIER : 1);
226
+ const deltaSliderValue =
227
+ ((dragState.lastY - event.clientY) * (max - min)) / dragDistance;
228
+ const nextSliderValue = clamp(
229
+ dragState.sliderValue + deltaSliderValue,
230
+ min,
231
+ max,
232
+ );
233
+ const nextValue = calcActualValue(nextSliderValue, min, max, exp);
234
+
235
+ dragState.lastY = event.clientY;
236
+ dragState.sliderValue = nextSliderValue;
237
+
238
+ commitValue(nextValue);
239
+ };
240
+
241
+ const clearDragState = (event: PointerEvent<HTMLDivElement>) => {
242
+ const dragState = dragStateRef.current;
243
+
244
+ if (dragState?.pointerId !== event.pointerId) return;
245
+
246
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
247
+ event.currentTarget.releasePointerCapture(event.pointerId);
248
+ }
249
+
250
+ dragStateRef.current = null;
251
+ };
252
+
253
+ const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
254
+ onPointerUp?.(event);
255
+
256
+ if (event.defaultPrevented) return;
257
+
258
+ clearDragState(event);
259
+ };
260
+
261
+ const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
262
+ onPointerCancel?.(event);
263
+
264
+ if (event.defaultPrevented) return;
265
+
266
+ clearDragState(event);
267
+ };
268
+
269
+ const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
270
+ onWheel?.(event);
271
+
272
+ if (event.defaultPrevented || disabled || event.deltaY === 0) return;
273
+
274
+ event.preventDefault();
275
+ changeBySteps(event.deltaY < 0 ? 1 : -1);
276
+ };
277
+
278
+ return (
279
+ <div
280
+ ref={ref}
281
+ role="slider"
282
+ tabIndex={disabled ? -1 : 0}
283
+ aria-label={name}
284
+ aria-disabled={disabled}
285
+ aria-valuemin={min}
286
+ aria-valuemax={max}
287
+ aria-valuenow={currentValue}
288
+ aria-valuetext={displayValue}
289
+ data-size={size}
290
+ className={cn(
291
+ "ui-encoder",
292
+ size === "sm" && "ui-encoder--size-sm",
293
+ size === "md" && "ui-encoder--size-md",
294
+ disabled && "ui-encoder--disabled",
295
+ className,
296
+ )}
297
+ style={
298
+ {
299
+ ...style,
300
+ "--ui-encoder-angle": `${-135 + progress * 270}deg`,
301
+ "--ui-encoder-fill": `${progress * 270}deg`,
302
+ } as CSSProperties
303
+ }
304
+ onKeyDown={handleKeyDown}
305
+ onPointerDown={handlePointerDown}
306
+ onPointerMove={handlePointerMove}
307
+ onPointerUp={handlePointerUp}
308
+ onPointerCancel={handlePointerCancel}
309
+ onWheel={handleWheel}
310
+ {...rest}
311
+ >
312
+ <div className="ui-encoder__dial">
313
+ <span className="ui-encoder__indicator" aria-hidden />
314
+ </div>
315
+ <span className="ui-encoder__value">{displayValue}</span>
316
+ </div>
317
+ );
318
+ },
319
+ );
320
+
321
+ Encoder.displayName = "Encoder";
322
+
323
+ export { Encoder };