panelui-web 0.9.1 → 0.10.0
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/dist/components/choice-cards/index.d.ts +167 -0
- package/dist/components/choice-cards/index.js +132 -0
- package/dist/components/inspector/index.d.ts +242 -0
- package/dist/components/inspector/index.js +230 -0
- package/dist/components/sheet/index.d.ts +16 -5
- package/dist/components/sheet/index.js +72 -15
- package/dist/components/slider/index.d.ts +293 -0
- package/dist/components/slider/index.js +342 -0
- package/dist/components/timeline/index.d.ts +328 -0
- package/dist/components/timeline/index.js +613 -0
- package/dist/components/toolbar/index.d.ts +185 -0
- package/dist/components/toolbar/index.js +270 -0
- package/dist/components/workbench/index.d.ts +224 -0
- package/dist/components/workbench/index.js +306 -0
- package/dist/components/xy-pad/index.d.ts +180 -0
- package/dist/components/xy-pad/index.js +278 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slider — a number chosen by dragging along a track, with its name and its
|
|
3
|
+
* value written inside the track.
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <Slider label="Opacity" defaultValue={64} format={{ style: 'unit', unit: 'percent' }} />
|
|
7
|
+
*
|
|
8
|
+
* <Slider variant="line" label="Zoom" min={10} max={400} defaultValue={100}>
|
|
9
|
+
* <Slider.Step by={-10} aria-label="Zoom out"><MinusIcon /></Slider.Step>
|
|
10
|
+
* <Slider.Step by={10} aria-label="Zoom in"><PlusIcon /></Slider.Step>
|
|
11
|
+
* </Slider>
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## The track is the label
|
|
15
|
+
*
|
|
16
|
+
* In a panel of settings, a column of sliders each with a label above and a
|
|
17
|
+
* number beside is three things to line up per row. The `fill` slider puts
|
|
18
|
+
* the name at the start of the track and the value at its end, so a row is
|
|
19
|
+
* one bar the height of a button, and a stack of them reads as a table.
|
|
20
|
+
*
|
|
21
|
+
* ## It moves the way the hand does
|
|
22
|
+
*
|
|
23
|
+
* A press away from the handle glides the fill there on a curve with a
|
|
24
|
+
* little overshoot; a drag follows the pointer exactly, with nothing
|
|
25
|
+
* easing behind it. Pulled past either end, the track stretches a few
|
|
26
|
+
* pixels towards the pointer and springs back on release, so the end of
|
|
27
|
+
* the range is felt rather than hit.
|
|
28
|
+
*
|
|
29
|
+
* ## Two weights
|
|
30
|
+
*
|
|
31
|
+
* `fill` is the slider for a settings panel. `line` is a thin track with a
|
|
32
|
+
* round knob, for a toolbar, where a zoom or a scrubber sits between two
|
|
33
|
+
* buttons: `Slider.Step` is that button, and it moves the value by `by`.
|
|
34
|
+
*
|
|
35
|
+
* `origin="center"` fills from the middle out, for a value that can go
|
|
36
|
+
* either way from nothing: exposure, a balance, a pitch shift.
|
|
37
|
+
*/
|
|
38
|
+
import { Slider as BaseSlider } from '@base-ui/react/slider';
|
|
39
|
+
import { type ComponentProps, type CSSProperties, type ReactNode } from 'react';
|
|
40
|
+
export type SliderVariant = 'fill' | 'line';
|
|
41
|
+
export type SliderSize = 'sm' | 'md' | 'lg';
|
|
42
|
+
export type SliderColor = 'neutral' | 'primary' | 'info' | 'success' | 'warning' | 'destructive';
|
|
43
|
+
declare const sliderVariants: import("tailwind-variants").TVReturnType<{
|
|
44
|
+
variant: {
|
|
45
|
+
fill: {
|
|
46
|
+
control: string;
|
|
47
|
+
track: string;
|
|
48
|
+
fill: string;
|
|
49
|
+
tick: string[];
|
|
50
|
+
handle: string[];
|
|
51
|
+
text: string;
|
|
52
|
+
};
|
|
53
|
+
line: {
|
|
54
|
+
control: string;
|
|
55
|
+
track: string;
|
|
56
|
+
fill: string;
|
|
57
|
+
tick: string;
|
|
58
|
+
handle: string[];
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
color: {
|
|
62
|
+
neutral: {
|
|
63
|
+
root: string;
|
|
64
|
+
};
|
|
65
|
+
primary: {
|
|
66
|
+
root: string;
|
|
67
|
+
};
|
|
68
|
+
info: {
|
|
69
|
+
root: string;
|
|
70
|
+
};
|
|
71
|
+
success: {
|
|
72
|
+
root: string;
|
|
73
|
+
};
|
|
74
|
+
warning: {
|
|
75
|
+
root: string;
|
|
76
|
+
};
|
|
77
|
+
destructive: {
|
|
78
|
+
root: string;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
size: {
|
|
82
|
+
sm: {
|
|
83
|
+
root: string;
|
|
84
|
+
};
|
|
85
|
+
md: {
|
|
86
|
+
root: string;
|
|
87
|
+
};
|
|
88
|
+
lg: {
|
|
89
|
+
root: string;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}, {
|
|
93
|
+
root: string;
|
|
94
|
+
control: string[];
|
|
95
|
+
track: string;
|
|
96
|
+
fill: string[];
|
|
97
|
+
tick: string;
|
|
98
|
+
center: string;
|
|
99
|
+
handle: string[];
|
|
100
|
+
thumb: string;
|
|
101
|
+
text: string;
|
|
102
|
+
label: string;
|
|
103
|
+
value: string;
|
|
104
|
+
step: string[];
|
|
105
|
+
}, undefined, {
|
|
106
|
+
variant: {
|
|
107
|
+
fill: {
|
|
108
|
+
control: string;
|
|
109
|
+
track: string;
|
|
110
|
+
fill: string;
|
|
111
|
+
tick: string[];
|
|
112
|
+
handle: string[];
|
|
113
|
+
text: string;
|
|
114
|
+
};
|
|
115
|
+
line: {
|
|
116
|
+
control: string;
|
|
117
|
+
track: string;
|
|
118
|
+
fill: string;
|
|
119
|
+
tick: string;
|
|
120
|
+
handle: string[];
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
color: {
|
|
124
|
+
neutral: {
|
|
125
|
+
root: string;
|
|
126
|
+
};
|
|
127
|
+
primary: {
|
|
128
|
+
root: string;
|
|
129
|
+
};
|
|
130
|
+
info: {
|
|
131
|
+
root: string;
|
|
132
|
+
};
|
|
133
|
+
success: {
|
|
134
|
+
root: string;
|
|
135
|
+
};
|
|
136
|
+
warning: {
|
|
137
|
+
root: string;
|
|
138
|
+
};
|
|
139
|
+
destructive: {
|
|
140
|
+
root: string;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
size: {
|
|
144
|
+
sm: {
|
|
145
|
+
root: string;
|
|
146
|
+
};
|
|
147
|
+
md: {
|
|
148
|
+
root: string;
|
|
149
|
+
};
|
|
150
|
+
lg: {
|
|
151
|
+
root: string;
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
}, {
|
|
155
|
+
root: string;
|
|
156
|
+
control: string[];
|
|
157
|
+
track: string;
|
|
158
|
+
fill: string[];
|
|
159
|
+
tick: string;
|
|
160
|
+
center: string;
|
|
161
|
+
handle: string[];
|
|
162
|
+
thumb: string;
|
|
163
|
+
text: string;
|
|
164
|
+
label: string;
|
|
165
|
+
value: string;
|
|
166
|
+
step: string[];
|
|
167
|
+
}, import("tailwind-variants").TVReturnType<{
|
|
168
|
+
variant: {
|
|
169
|
+
fill: {
|
|
170
|
+
control: string;
|
|
171
|
+
track: string;
|
|
172
|
+
fill: string;
|
|
173
|
+
tick: string[];
|
|
174
|
+
handle: string[];
|
|
175
|
+
text: string;
|
|
176
|
+
};
|
|
177
|
+
line: {
|
|
178
|
+
control: string;
|
|
179
|
+
track: string;
|
|
180
|
+
fill: string;
|
|
181
|
+
tick: string;
|
|
182
|
+
handle: string[];
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
color: {
|
|
186
|
+
neutral: {
|
|
187
|
+
root: string;
|
|
188
|
+
};
|
|
189
|
+
primary: {
|
|
190
|
+
root: string;
|
|
191
|
+
};
|
|
192
|
+
info: {
|
|
193
|
+
root: string;
|
|
194
|
+
};
|
|
195
|
+
success: {
|
|
196
|
+
root: string;
|
|
197
|
+
};
|
|
198
|
+
warning: {
|
|
199
|
+
root: string;
|
|
200
|
+
};
|
|
201
|
+
destructive: {
|
|
202
|
+
root: string;
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
size: {
|
|
206
|
+
sm: {
|
|
207
|
+
root: string;
|
|
208
|
+
};
|
|
209
|
+
md: {
|
|
210
|
+
root: string;
|
|
211
|
+
};
|
|
212
|
+
lg: {
|
|
213
|
+
root: string;
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
}, {
|
|
217
|
+
root: string;
|
|
218
|
+
control: string[];
|
|
219
|
+
track: string;
|
|
220
|
+
fill: string[];
|
|
221
|
+
tick: string;
|
|
222
|
+
center: string;
|
|
223
|
+
handle: string[];
|
|
224
|
+
thumb: string;
|
|
225
|
+
text: string;
|
|
226
|
+
label: string;
|
|
227
|
+
value: string;
|
|
228
|
+
step: string[];
|
|
229
|
+
}, undefined, unknown, unknown, undefined>>;
|
|
230
|
+
type SliderValue = number | number[];
|
|
231
|
+
export interface SliderProps<Value extends SliderValue = number> extends Omit<BaseSlider.Root.Props<Value>, 'className' | 'style' | 'format' | 'orientation' | 'children' | 'render' | 'onValueChange' | 'value' | 'defaultValue'> {
|
|
232
|
+
/** The value, when the caller holds it. Two numbers make a range with two handles. */
|
|
233
|
+
value?: Value;
|
|
234
|
+
/** The value to start at, when the caller does not hold it. Default `min`. */
|
|
235
|
+
defaultValue?: Value;
|
|
236
|
+
/** Called with the new value while it changes, by a drag, a press, a key or a `Slider.Step`. */
|
|
237
|
+
onValueChange?: (value: Value) => void;
|
|
238
|
+
/** `fill` writes the name and value inside a bar; `line` is a thin track with a knob. Default `fill`. */
|
|
239
|
+
variant?: SliderVariant;
|
|
240
|
+
/**
|
|
241
|
+
* What it sets. Written inside a `fill` slider, and what a screen reader
|
|
242
|
+
* calls either kind.
|
|
243
|
+
*/
|
|
244
|
+
label?: string;
|
|
245
|
+
/**
|
|
246
|
+
* How the value is written: number-format options such as
|
|
247
|
+
* `{ style: 'unit', unit: 'percent' }`, or a function from the number to
|
|
248
|
+
* a string for anything else, such as a multiplier written `1.40×`. Also
|
|
249
|
+
* what a screen reader reads.
|
|
250
|
+
*/
|
|
251
|
+
format?: Intl.NumberFormatOptions | ((value: number) => string);
|
|
252
|
+
/** Where the fill grows from. `center` fills outwards from the middle of the range. Default `start`. */
|
|
253
|
+
origin?: 'start' | 'center';
|
|
254
|
+
/** The colour of the fill and the handle. `neutral` by default. */
|
|
255
|
+
color?: SliderColor;
|
|
256
|
+
/** Height of a `fill` slider. Default `md`. */
|
|
257
|
+
size?: SliderSize;
|
|
258
|
+
/** Show the value at the end of a `fill` slider. Default true. */
|
|
259
|
+
showValue?: boolean;
|
|
260
|
+
/** Mark every step with a notch. Default: on when there are 12 steps or fewer. */
|
|
261
|
+
ticks?: boolean;
|
|
262
|
+
/** `Slider.Step` buttons. One with a negative `by` stands before the track, a positive one after it. */
|
|
263
|
+
children?: ReactNode;
|
|
264
|
+
className?: string;
|
|
265
|
+
style?: CSSProperties;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* A slider. `defaultValue`, or `value` with `onValueChange`; `min`, `max`
|
|
269
|
+
* and `step` as for any range.
|
|
270
|
+
*/
|
|
271
|
+
declare function SliderRoot<Value extends SliderValue = number>({ value: valueProp, defaultValue, onValueChange, variant, label, format, origin, color, size, showValue, ticks, min, max, step, disabled, locale, children, className, style, ...props }: SliderProps<Value>): import("react").JSX.Element;
|
|
272
|
+
declare namespace SliderRoot {
|
|
273
|
+
var displayName: string;
|
|
274
|
+
}
|
|
275
|
+
export interface SliderStepProps extends Omit<ComponentProps<'button'>, 'children'> {
|
|
276
|
+
/** How much one press moves the value. Negative steps down and sits before the track. */
|
|
277
|
+
by: number;
|
|
278
|
+
/** Usually an icon. Give the button an `aria-label` then. */
|
|
279
|
+
children?: ReactNode;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* A button beside the track that moves the value by `by`: zoom out and zoom
|
|
283
|
+
* in on either side of a zoom slider. It is disabled at the end it moves
|
|
284
|
+
* towards.
|
|
285
|
+
*/
|
|
286
|
+
declare function SliderStep({ by, className, disabled, onClick, ...props }: SliderStepProps): import("react").JSX.Element;
|
|
287
|
+
declare namespace SliderStep {
|
|
288
|
+
var displayName: string;
|
|
289
|
+
}
|
|
290
|
+
export declare const Slider: typeof SliderRoot & {
|
|
291
|
+
Step: typeof SliderStep;
|
|
292
|
+
};
|
|
293
|
+
export { SliderStep, sliderVariants };
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Slider — a number chosen by dragging along a track, with its name and its
|
|
5
|
+
* value written inside the track.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <Slider label="Opacity" defaultValue={64} format={{ style: 'unit', unit: 'percent' }} />
|
|
9
|
+
*
|
|
10
|
+
* <Slider variant="line" label="Zoom" min={10} max={400} defaultValue={100}>
|
|
11
|
+
* <Slider.Step by={-10} aria-label="Zoom out"><MinusIcon /></Slider.Step>
|
|
12
|
+
* <Slider.Step by={10} aria-label="Zoom in"><PlusIcon /></Slider.Step>
|
|
13
|
+
* </Slider>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## The track is the label
|
|
17
|
+
*
|
|
18
|
+
* In a panel of settings, a column of sliders each with a label above and a
|
|
19
|
+
* number beside is three things to line up per row. The `fill` slider puts
|
|
20
|
+
* the name at the start of the track and the value at its end, so a row is
|
|
21
|
+
* one bar the height of a button, and a stack of them reads as a table.
|
|
22
|
+
*
|
|
23
|
+
* ## It moves the way the hand does
|
|
24
|
+
*
|
|
25
|
+
* A press away from the handle glides the fill there on a curve with a
|
|
26
|
+
* little overshoot; a drag follows the pointer exactly, with nothing
|
|
27
|
+
* easing behind it. Pulled past either end, the track stretches a few
|
|
28
|
+
* pixels towards the pointer and springs back on release, so the end of
|
|
29
|
+
* the range is felt rather than hit.
|
|
30
|
+
*
|
|
31
|
+
* ## Two weights
|
|
32
|
+
*
|
|
33
|
+
* `fill` is the slider for a settings panel. `line` is a thin track with a
|
|
34
|
+
* round knob, for a toolbar, where a zoom or a scrubber sits between two
|
|
35
|
+
* buttons: `Slider.Step` is that button, and it moves the value by `by`.
|
|
36
|
+
*
|
|
37
|
+
* `origin="center"` fills from the middle out, for a value that can go
|
|
38
|
+
* either way from nothing: exposure, a balance, a pitch shift.
|
|
39
|
+
*/
|
|
40
|
+
import { Slider as BaseSlider } from '@base-ui/react/slider';
|
|
41
|
+
import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
42
|
+
import { tv } from 'tailwind-variants';
|
|
43
|
+
/** A glide with a little overshoot, for a press. A drag turns it off. */
|
|
44
|
+
const GLIDE = 'duration-[420ms] ease-[cubic-bezier(0.3,1.3,0.45,1)]';
|
|
45
|
+
/** How far the track gives past an end, at most, in pixels. */
|
|
46
|
+
const STRETCH = 8;
|
|
47
|
+
/** How far the pointer has to travel before a press counts as a drag. */
|
|
48
|
+
const DRAG_SLOP = 3;
|
|
49
|
+
/** Steps are marked when there are this many or fewer. */
|
|
50
|
+
const MAX_TICKS = 12;
|
|
51
|
+
const sliderVariants = tv({
|
|
52
|
+
slots: {
|
|
53
|
+
root: 'group/slider relative flex w-full min-w-0 items-center gap-1.5 data-disabled:opacity-50',
|
|
54
|
+
control: [
|
|
55
|
+
'relative min-w-0 flex-1 cursor-pointer touch-none select-none outline-none',
|
|
56
|
+
'transition-[margin] duration-500 ease-[cubic-bezier(0.3,1.4,0.4,1)] data-stretching:duration-0',
|
|
57
|
+
'data-disabled:cursor-not-allowed motion-reduce:transition-none',
|
|
58
|
+
],
|
|
59
|
+
track: 'absolute overflow-hidden',
|
|
60
|
+
fill: [
|
|
61
|
+
'absolute inset-y-0 start-(--slider-start) w-(--slider-span)',
|
|
62
|
+
`transition-[inset-inline-start,width] ${GLIDE} group-data-moving/slider:duration-0 motion-reduce:transition-none`,
|
|
63
|
+
],
|
|
64
|
+
tick: 'pointer-events-none absolute -translate-x-1/2 rtl:translate-x-1/2',
|
|
65
|
+
center: 'pointer-events-none absolute inset-y-1.5 start-1/2 w-px -translate-x-1/2 bg-foreground/20 rtl:translate-x-1/2',
|
|
66
|
+
handle: [
|
|
67
|
+
'pointer-events-none absolute top-1/2 start-(--slider-at) -translate-y-1/2',
|
|
68
|
+
`transition-[inset-inline-start,height,width,background-color,scale] ${GLIDE} group-data-moving/slider:duration-0 motion-reduce:transition-none`,
|
|
69
|
+
],
|
|
70
|
+
// Base UI's thumb carries the keyboard and the screen reader. It is
|
|
71
|
+
// invisible: the handle above is what is seen.
|
|
72
|
+
thumb: 'pointer-events-none size-px opacity-0',
|
|
73
|
+
text: 'pointer-events-none absolute inset-0 flex items-center justify-between gap-3 px-3',
|
|
74
|
+
// Lighter than muted, so the name reads on a coloured fill as well as on the track.
|
|
75
|
+
label: 'truncate text-foreground/70',
|
|
76
|
+
value: 'shrink-0 tabular-nums text-foreground',
|
|
77
|
+
step: [
|
|
78
|
+
'inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none',
|
|
79
|
+
'transition-[color,background-color,scale] duration-150 hover:bg-foreground/[0.06] hover:text-foreground active:scale-90',
|
|
80
|
+
'focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40',
|
|
81
|
+
'[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
variants: {
|
|
85
|
+
variant: {
|
|
86
|
+
fill: {
|
|
87
|
+
control: 'h-(--slider-h) rounded-[10px] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring',
|
|
88
|
+
track: 'inset-0 rounded-[inherit] bg-muted',
|
|
89
|
+
fill: 'bg-(--slider-fill)',
|
|
90
|
+
// A notch on the top and bottom edges at every step, like the marks
|
|
91
|
+
// on a rule: the text runs between them and never crosses one.
|
|
92
|
+
tick: [
|
|
93
|
+
'inset-y-0 w-px',
|
|
94
|
+
'bg-[linear-gradient(to_bottom,var(--slider-notch)_0_5px,transparent_5px_calc(100%-5px),var(--slider-notch)_calc(100%-5px))]',
|
|
95
|
+
'[--slider-notch:color-mix(in_oklab,var(--foreground)_22%,transparent)]',
|
|
96
|
+
],
|
|
97
|
+
handle: [
|
|
98
|
+
// Centred on the value, so it stands on the same line as the fill's
|
|
99
|
+
// edge and the notch of the step it is on.
|
|
100
|
+
'h-[calc(var(--slider-h)*0.44)] w-[3px] -translate-x-1/2 rounded-full bg-(--slider-mark) opacity-60 rtl:translate-x-1/2',
|
|
101
|
+
'group-hover/slider:h-[calc(var(--slider-h)*0.56)] group-hover/slider:opacity-100 group-data-moving/slider:h-[calc(var(--slider-h)*0.56)] group-data-moving/slider:opacity-100',
|
|
102
|
+
'group-data-disabled/slider:opacity-40',
|
|
103
|
+
// Where it would cross the name or the value, it steps out of the way:
|
|
104
|
+
// the edge of the fill still says where the value is.
|
|
105
|
+
'data-crowded:opacity-0 group-hover/slider:data-crowded:opacity-0 group-data-moving/slider:data-crowded:opacity-0',
|
|
106
|
+
],
|
|
107
|
+
text: 'text-(length:--slider-text) font-medium',
|
|
108
|
+
},
|
|
109
|
+
line: {
|
|
110
|
+
control: 'h-6 rounded-full',
|
|
111
|
+
track: 'inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-foreground/15',
|
|
112
|
+
fill: 'rounded-full bg-(--slider-mark)',
|
|
113
|
+
tick: 'top-1/2 size-[3px] -translate-y-1/2 rounded-full bg-background/70',
|
|
114
|
+
handle: [
|
|
115
|
+
'size-4 -translate-x-1/2 rounded-full border-2 border-(--slider-mark) bg-background shadow-[0_1px_3px_rgb(0_0_0/0.2)] rtl:translate-x-1/2',
|
|
116
|
+
'group-hover/slider:scale-110 group-data-moving/slider:scale-125',
|
|
117
|
+
'group-has-[:focus-visible]/slider:ring-2 group-has-[:focus-visible]/slider:ring-ring group-has-[:focus-visible]/slider:ring-offset-2 group-has-[:focus-visible]/slider:ring-offset-background',
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
color: {
|
|
122
|
+
neutral: { root: '[--slider-fill:color-mix(in_oklab,var(--foreground)_11%,transparent)] [--slider-mark:var(--foreground)]' },
|
|
123
|
+
primary: { root: '[--slider-fill:color-mix(in_oklab,var(--primary)_24%,transparent)] [--slider-mark:var(--primary)]' },
|
|
124
|
+
info: { root: '[--slider-fill:color-mix(in_oklab,var(--info)_26%,transparent)] [--slider-mark:var(--info)]' },
|
|
125
|
+
success: { root: '[--slider-fill:color-mix(in_oklab,var(--success)_26%,transparent)] [--slider-mark:var(--success)]' },
|
|
126
|
+
warning: { root: '[--slider-fill:color-mix(in_oklab,var(--warning)_28%,transparent)] [--slider-mark:var(--warning)]' },
|
|
127
|
+
destructive: {
|
|
128
|
+
root: '[--slider-fill:color-mix(in_oklab,var(--destructive)_24%,transparent)] [--slider-mark:var(--destructive)]',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
size: {
|
|
132
|
+
sm: { root: '[--slider-h:1.75rem] [--slider-text:12px]' },
|
|
133
|
+
md: { root: '[--slider-h:2rem] [--slider-text:13px]' },
|
|
134
|
+
lg: { root: '[--slider-h:2.5rem] [--slider-text:14px]' },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
compoundVariants: [
|
|
138
|
+
// The line's fill is drawn solid, so a neutral one is the foreground itself.
|
|
139
|
+
{ variant: 'line', color: 'neutral', className: { fill: 'bg-foreground' } },
|
|
140
|
+
],
|
|
141
|
+
defaultVariants: { variant: 'fill', color: 'neutral', size: 'md' },
|
|
142
|
+
});
|
|
143
|
+
const SliderContext = createContext(null);
|
|
144
|
+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
145
|
+
const noop = () => () => { };
|
|
146
|
+
const yes = () => true;
|
|
147
|
+
const no = () => false;
|
|
148
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
149
|
+
const toPercent = (value, min, max) => ((clamp(value, min, max) - min) / (max - min)) * 100;
|
|
150
|
+
/** Rounds away the float noise that repeated steps leave, such as 0.30000000000000004. */
|
|
151
|
+
const tidy = (value, step) => {
|
|
152
|
+
const decimals = (String(step).split('.')[1] ?? '').length;
|
|
153
|
+
return Number(value.toFixed(Math.min(decimals + 2, 10)));
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* A slider. `defaultValue`, or `value` with `onValueChange`; `min`, `max`
|
|
157
|
+
* and `step` as for any range.
|
|
158
|
+
*/
|
|
159
|
+
function SliderRoot({ value: valueProp, defaultValue, onValueChange, variant = 'fill', label, format, origin = 'start', color, size, showValue = true, ticks, min = 0, max = 100, step = 1, disabled = false, locale, children, className, style, ...props }) {
|
|
160
|
+
const slots = sliderVariants({ variant, color, size });
|
|
161
|
+
const [inner, setInner] = useState(() => defaultValue ?? (origin === 'center' ? (min + max) / 2 : min));
|
|
162
|
+
const value = (valueProp ?? inner);
|
|
163
|
+
const values = Array.isArray(value) ? value : [value];
|
|
164
|
+
const range = values.length > 1;
|
|
165
|
+
const live = useRef({ value, onValueChange, controlled: valueProp !== undefined });
|
|
166
|
+
live.current = { value, onValueChange, controlled: valueProp !== undefined };
|
|
167
|
+
const change = useCallback((next) => {
|
|
168
|
+
if (!live.current.controlled)
|
|
169
|
+
setInner(next);
|
|
170
|
+
live.current.onValueChange?.(next);
|
|
171
|
+
}, []);
|
|
172
|
+
// The server cannot know the reader's locale, so the page is first written
|
|
173
|
+
// in a fixed one and switches to theirs once it is running in the browser.
|
|
174
|
+
const hydrated = useSyncExternalStore(noop, yes, no);
|
|
175
|
+
const write = useMemo(() => {
|
|
176
|
+
if (typeof format === 'function')
|
|
177
|
+
return format;
|
|
178
|
+
const formatter = new Intl.NumberFormat(locale ?? (hydrated ? undefined : 'en-US'), format);
|
|
179
|
+
return (number) => formatter.format(number);
|
|
180
|
+
}, [format, locale, hydrated]);
|
|
181
|
+
// The fill: from the start, from the middle, or between two handles.
|
|
182
|
+
const percents = values.map((each) => toPercent(each, min, max));
|
|
183
|
+
const [low, high] = range
|
|
184
|
+
? [Math.min(...percents), Math.max(...percents)]
|
|
185
|
+
: origin === 'center'
|
|
186
|
+
? [Math.min(50, percents[0]), Math.max(50, percents[0])]
|
|
187
|
+
: [0, percents[0]];
|
|
188
|
+
const count = (max - min) / step;
|
|
189
|
+
const showTicks = ticks ?? (Number.isInteger(Math.round(count * 1e6) / 1e6) && count >= 2 && count <= MAX_TICKS);
|
|
190
|
+
/*
|
|
191
|
+
* Press, drag and stretch. Base UI moves the value; this watches the same
|
|
192
|
+
* pointer to tell a press from a drag, which decides whether the fill
|
|
193
|
+
* glides, and to measure how far past an end the pointer has gone.
|
|
194
|
+
*/
|
|
195
|
+
const control = useRef(null);
|
|
196
|
+
// Held in state as well, because the element can be replaced after
|
|
197
|
+
// hydration, and the measuring below has to follow it to the new one.
|
|
198
|
+
const [controlElement, setControlElement] = useState(null);
|
|
199
|
+
const controlRef = useCallback((element) => {
|
|
200
|
+
control.current = element;
|
|
201
|
+
setControlElement(element);
|
|
202
|
+
}, []);
|
|
203
|
+
const root = useRef(null);
|
|
204
|
+
const [stretch, setStretch] = useState(0);
|
|
205
|
+
const press = (event) => {
|
|
206
|
+
if (disabled || event.button !== 0)
|
|
207
|
+
return;
|
|
208
|
+
const box = control.current?.getBoundingClientRect();
|
|
209
|
+
if (!box)
|
|
210
|
+
return;
|
|
211
|
+
const startX = event.clientX;
|
|
212
|
+
const startY = event.clientY;
|
|
213
|
+
const rtl = getComputedStyle(control.current).direction === 'rtl';
|
|
214
|
+
const move = (next) => {
|
|
215
|
+
if (Math.hypot(next.clientX - startX, next.clientY - startY) > DRAG_SLOP) {
|
|
216
|
+
root.current?.setAttribute('data-moving', '');
|
|
217
|
+
}
|
|
218
|
+
const past = next.clientX < box.left ? next.clientX - box.left : next.clientX > box.right ? next.clientX - box.right : 0;
|
|
219
|
+
// Eases off, so the further the pointer goes the less the track follows.
|
|
220
|
+
const give = Math.sign(past) * STRETCH * Math.sqrt(Math.min(Math.max(Math.abs(past) - 12, 0) / 200, 1));
|
|
221
|
+
setStretch(rtl ? -give : give);
|
|
222
|
+
};
|
|
223
|
+
const release = () => {
|
|
224
|
+
root.current?.removeAttribute('data-moving');
|
|
225
|
+
setStretch(0);
|
|
226
|
+
window.removeEventListener('pointermove', move);
|
|
227
|
+
window.removeEventListener('pointerup', release);
|
|
228
|
+
window.removeEventListener('pointercancel', release);
|
|
229
|
+
};
|
|
230
|
+
window.addEventListener('pointermove', move);
|
|
231
|
+
window.addEventListener('pointerup', release);
|
|
232
|
+
window.addEventListener('pointercancel', release);
|
|
233
|
+
};
|
|
234
|
+
useEffect(() => () => root.current?.removeAttribute('data-moving'), []);
|
|
235
|
+
// Which handles would land on the text, measured where they are going
|
|
236
|
+
// rather than where the glide has them now.
|
|
237
|
+
const labelRef = useRef(null);
|
|
238
|
+
const valueRef = useRef(null);
|
|
239
|
+
const [crowded, setCrowded] = useState([]);
|
|
240
|
+
const percentKey = percents.join(',');
|
|
241
|
+
useIsomorphicLayoutEffect(() => {
|
|
242
|
+
const box = controlElement;
|
|
243
|
+
if (variant !== 'fill' || !box)
|
|
244
|
+
return;
|
|
245
|
+
const measure = () => {
|
|
246
|
+
const width = box.clientWidth;
|
|
247
|
+
const rtl = getComputedStyle(box).direction === 'rtl';
|
|
248
|
+
// Distances from the start edge, in the direction the text runs.
|
|
249
|
+
const span = (element) => {
|
|
250
|
+
if (!element || !element.textContent)
|
|
251
|
+
return null;
|
|
252
|
+
const start = rtl ? width - element.offsetLeft - element.offsetWidth : element.offsetLeft;
|
|
253
|
+
return [start - 6, start + element.offsetWidth + 6];
|
|
254
|
+
};
|
|
255
|
+
const zones = [span(labelRef.current), span(valueRef.current)].filter((zone) => zone !== null);
|
|
256
|
+
const next = percentKey.split(',').map((percent) => {
|
|
257
|
+
const x = (Number(percent) / 100) * width;
|
|
258
|
+
const at = Math.min(Math.max(x, 8), width - 8) - 1.5;
|
|
259
|
+
return zones.some(([left, right]) => at + 3 > left && at < right);
|
|
260
|
+
});
|
|
261
|
+
setCrowded((last) => (last.length === next.length && last.every((each, i) => each === next[i]) ? last : next));
|
|
262
|
+
};
|
|
263
|
+
measure();
|
|
264
|
+
// The text can change size after the first paint, when a web font or a
|
|
265
|
+
// stylesheet arrives, without the track changing at all.
|
|
266
|
+
const observer = new ResizeObserver(measure);
|
|
267
|
+
for (const element of [box, labelRef.current, valueRef.current])
|
|
268
|
+
if (element)
|
|
269
|
+
observer.observe(element);
|
|
270
|
+
let live = true;
|
|
271
|
+
document.fonts?.ready.then(() => live && measure());
|
|
272
|
+
return () => {
|
|
273
|
+
live = false;
|
|
274
|
+
observer.disconnect();
|
|
275
|
+
};
|
|
276
|
+
}, [variant, percentKey, controlElement]);
|
|
277
|
+
const single = values[0];
|
|
278
|
+
const context = useMemo(() => ({
|
|
279
|
+
nudge: (by) => {
|
|
280
|
+
if (range)
|
|
281
|
+
return;
|
|
282
|
+
change(tidy(clamp(single + by, min, max), step));
|
|
283
|
+
},
|
|
284
|
+
atMin: !range && single <= min,
|
|
285
|
+
atMax: !range && single >= max,
|
|
286
|
+
disabled,
|
|
287
|
+
}), [range, single, min, max, step, change, disabled]);
|
|
288
|
+
const text = range ? values.map(write).join(' – ') : write(single);
|
|
289
|
+
const name = label ?? props['aria-label'];
|
|
290
|
+
return (_jsx(SliderContext.Provider, { value: context, children: _jsxs(BaseSlider.Root, { ref: root, "data-slot": "slider", "data-variant": variant, value: value, onValueChange: (next) => change(next), min: min, max: max, step: step, disabled: disabled, locale: locale, className: slots.root({ className }), style: {
|
|
291
|
+
...style,
|
|
292
|
+
'--slider-start': `${low}%`,
|
|
293
|
+
'--slider-span': `${high - low}%`,
|
|
294
|
+
}, ...props, children: [_jsxs(BaseSlider.Control, { ref: controlRef, "data-slot": "slider-control", "data-stretching": stretch !== 0 ? '' : undefined, onPointerDown: press, className: slots.control(), style: {
|
|
295
|
+
marginInlineStart: stretch < 0 ? stretch : 0,
|
|
296
|
+
marginInlineEnd: stretch > 0 ? -stretch : 0,
|
|
297
|
+
}, children: [_jsxs("div", { "data-slot": "slider-track", className: slots.track(), children: [_jsx("div", { "data-slot": "slider-fill", className: slots.fill() }), showTicks
|
|
298
|
+
? Array.from({ length: Math.round(count) - 1 }, (_, at) => (_jsx("span", { "aria-hidden": true, className: slots.tick(), style: { insetInlineStart: `${((at + 1) / Math.round(count)) * 100}%` } }, at)))
|
|
299
|
+
: null, origin === 'center' && !range ? _jsx("span", { "aria-hidden": true, className: slots.center() }) : null] }), values.map((each, at) => {
|
|
300
|
+
const percent = toPercent(each, min, max);
|
|
301
|
+
// At either end the fill's handle stays clear of the rounded corner.
|
|
302
|
+
const place = variant !== 'fill' ? `${percent}%` : `clamp(8px, ${percent}%, calc(100% - 8px))`;
|
|
303
|
+
return (_jsx("span", { "aria-hidden": true, "data-slot": "slider-handle", "data-crowded": crowded[at] ? '' : undefined, className: slots.handle(), style: { ['--slider-at']: place } }, at));
|
|
304
|
+
}), variant === 'fill' && (label || showValue) ? (_jsxs("span", { "aria-hidden": true, "data-slot": "slider-text", className: slots.text(), children: [_jsx("span", { ref: labelRef, className: slots.label(), children: label }), showValue ? (_jsx("span", { ref: valueRef, className: slots.value(), children: text })) : null] })) : null, values.map((_, at) => (_jsx(BaseSlider.Thumb, { index: range ? at : undefined, "aria-label": range ? `${name ?? 'Value'}, ${at === 0 ? 'start' : 'end'}` : name, getAriaValueText: (_formatted, number) => write(number), className: slots.thumb() }, at)))] }), _jsx(SliderSteps, { children: children })] }) }));
|
|
305
|
+
}
|
|
306
|
+
SliderRoot.displayName = 'Slider';
|
|
307
|
+
/** Puts each `Slider.Step` on its side of the track: a step down before, a step up after. */
|
|
308
|
+
function SliderSteps({ children }) {
|
|
309
|
+
const before = [];
|
|
310
|
+
const after = [];
|
|
311
|
+
Children.forEach(children, (child) => {
|
|
312
|
+
if (isValidElement(child) && typeof child.props.by === 'number' && child.props.by < 0) {
|
|
313
|
+
before.push(child);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
after.push(child);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
return (_jsxs(_Fragment, { children: [before.length ? _jsx("div", { className: "order-first flex shrink-0 items-center", children: before }) : null, after.length ? _jsx("div", { className: "order-last flex shrink-0 items-center", children: after }) : null] }));
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A button beside the track that moves the value by `by`: zoom out and zoom
|
|
323
|
+
* in on either side of a zoom slider. It is disabled at the end it moves
|
|
324
|
+
* towards.
|
|
325
|
+
*/
|
|
326
|
+
function SliderStep({ by, className, disabled, onClick, ...props }) {
|
|
327
|
+
const slider = useContext(SliderContext);
|
|
328
|
+
if (!slider)
|
|
329
|
+
throw new Error('Slider.Step must be used inside a <Slider>.');
|
|
330
|
+
const slots = sliderVariants();
|
|
331
|
+
const stuck = by < 0 ? slider.atMin : slider.atMax;
|
|
332
|
+
return (_jsx("button", { type: "button", "data-slot": "slider-step", disabled: disabled || slider.disabled || stuck, className: slots.step({ className }), onClick: (event) => {
|
|
333
|
+
onClick?.(event);
|
|
334
|
+
if (!event.defaultPrevented)
|
|
335
|
+
slider.nudge(by);
|
|
336
|
+
}, ...props }));
|
|
337
|
+
}
|
|
338
|
+
SliderStep.displayName = 'Slider.Step';
|
|
339
|
+
export const Slider = Object.assign(SliderRoot, {
|
|
340
|
+
Step: SliderStep,
|
|
341
|
+
});
|
|
342
|
+
export { SliderStep, sliderVariants };
|