Skip to content

Commit 5e33e5b

Browse files
authored
Merge pull request #125 from buildo/3692786-implement_slider
2 parents 4eaf4d6 + 273722f commit 5e33e5b

File tree

11 files changed

+450
-2
lines changed

11 files changed

+450
-2
lines changed

packages/bento-design-system/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@react-aria/progress": "^3.1.7",
5656
"@react-aria/radio": "^3.1.6",
5757
"@react-aria/separator": "^3.1.3",
58+
"@react-aria/slider": "^3.0.7",
5859
"@react-aria/switch": "^3.1.5",
5960
"@react-aria/textfield": "^3.5.0",
6061
"@react-aria/tooltip": "^3.1.5",
@@ -65,6 +66,7 @@
6566
"@react-stately/numberfield": "^3.0.2",
6667
"@react-stately/overlays": "^3.1.6",
6768
"@react-stately/radio": "^3.3.2",
69+
"@react-stately/slider": "^3.0.7",
6870
"@react-stately/toggle": "^3.2.3",
6971
"@react-stately/tooltip": "^3.0.7",
7072
"@react-types/overlays": "^3.5.4",

packages/bento-design-system/src/Field/createFormFields.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616
SelectionControlGroupConfig,
1717
} from "./Config";
1818
import { DropdownConfig } from "../SelectField/Config";
19+
import { createSlider } from "../Slider/createSlider";
20+
import { SliderConfig } from "../Slider/Config";
21+
import { createSliderField } from "../SliderField/createSliderField";
1922

2023
type FieldsConfig = {
2124
field: FieldConfig;
@@ -25,6 +28,7 @@ type FieldsConfig = {
2528
element: SelectionControlConfig;
2629
};
2730
dropdown: DropdownConfig;
31+
slider: SliderConfig;
2832
};
2933

3034
export function createFormFields(
@@ -44,6 +48,8 @@ export function createFormFields(
4448
const NumberField = createNumberField({ Field, NumberInput });
4549
const SelectField = createSelectField(config.input, config.dropdown, { Field });
4650
const ReadOnlyField = createReadOnlyField({ TextField });
51+
const Slider = createSlider(config.slider);
52+
const SliderField = createSliderField({ Slider, Field });
4753

4854
return {
4955
CheckboxField,
@@ -54,6 +60,8 @@ export function createFormFields(
5460
SelectField,
5561
TextField,
5662
ReadOnlyField,
63+
Slider,
64+
SliderField,
5765
};
5866
}
5967

@@ -66,3 +74,4 @@ export type {
6674
CheckboxGroupFieldProps,
6775
} from "../CheckboxGroupField/createCheckboxGroupField";
6876
export type { NumberFieldProps } from "../NumberField/createNumberField";
77+
export type { SliderFieldProps } from "../SliderField/createSliderField";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { BentoSprinkles } from "../internal";
2+
import { LabelProps } from "../Typography/Label/Label";
3+
4+
export type SliderConfig = {
5+
valueSize: LabelProps["size"];
6+
labelsSize: LabelProps["size"];
7+
internalSpacing: BentoSprinkles["gap"];
8+
trailColor: BentoSprinkles["color"];
9+
trailRadius: BentoSprinkles["borderRadius"];
10+
thumbRadius: BentoSprinkles["borderRadius"];
11+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { style } from "@vanilla-extract/css";
2+
import { bentoSprinkles } from "../internal";
3+
import { strictRecipe } from "../util/strictRecipe";
4+
import { vars } from "../vars.css";
5+
6+
const trackContainerHeight = 24;
7+
const trackHeight = 8;
8+
const trackOffsetY = (trackContainerHeight - trackHeight) / 2;
9+
10+
export const slider = style({
11+
// NOTE(gabro): not super pretty, but this is because the thumbs as positioned absolutely,
12+
// so their labels don't contribute to the vertical space, causing them to overlap with elements
13+
// below the Slider.
14+
// This takes into account the height the label + the space between the label and the thumb
15+
paddingBottom: `calc(${vars.lineHeight.bodyMedium} + ${vars.space[8]})`,
16+
});
17+
18+
export const trackContainer = style([
19+
{
20+
height: trackContainerHeight,
21+
},
22+
bentoSprinkles({
23+
position: "relative",
24+
width: "full",
25+
}),
26+
]);
27+
28+
export const trackActive = style([
29+
{ top: trackOffsetY },
30+
bentoSprinkles({
31+
height: 8,
32+
color: {
33+
disabled: "foregroundDisabled",
34+
},
35+
position: "absolute",
36+
}),
37+
]);
38+
39+
export const trackInactive = style([
40+
{ top: trackOffsetY },
41+
bentoSprinkles({
42+
height: 8,
43+
position: "absolute",
44+
background: "backgroundOverlay",
45+
width: "full",
46+
}),
47+
]);
48+
49+
export const thumbRecipe = strictRecipe({
50+
base: bentoSprinkles({
51+
width: 24,
52+
height: 24,
53+
cursor: { default: "pointer", disabled: "notAllowed" },
54+
background: "backgroundSecondary",
55+
boxShadow: {
56+
disabled: "outlineInputDisabled",
57+
hover: "outlineInputHover",
58+
},
59+
}),
60+
variants: {
61+
isFocused: {
62+
true: bentoSprinkles({
63+
boxShadow: {
64+
default: "outlineInputFocus",
65+
},
66+
}),
67+
false: bentoSprinkles({
68+
boxShadow: {
69+
default: "outlineInputEnabled",
70+
},
71+
}),
72+
},
73+
},
74+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { useFocusRing } from "@react-aria/focus";
2+
import { useSliderThumb } from "@react-aria/slider";
3+
import { mergeProps } from "@react-aria/utils";
4+
import { VisuallyHidden } from "@react-aria/visually-hidden";
5+
import { SliderState } from "@react-stately/slider";
6+
import { OutputHTMLAttributes, RefObject, useRef } from "react";
7+
import { Box, Column, Columns, Stack } from "../internal";
8+
import { Label } from "../Typography/Label/Label";
9+
import { unsafeLocalizedString } from "../util/LocalizedString";
10+
import { SliderConfig } from "./Config";
11+
import { slider, thumbRecipe, trackActive, trackContainer, trackInactive } from "./Slider.css";
12+
13+
type Props = {
14+
type: "single" | "double";
15+
disabled?: boolean;
16+
trackRef: RefObject<HTMLDivElement>;
17+
state: SliderState;
18+
groupProps: React.HTMLAttributes<HTMLElement>;
19+
trackProps: React.HTMLAttributes<HTMLElement>;
20+
outputProps: OutputHTMLAttributes<HTMLOutputElement>;
21+
numberFormatter: Intl.NumberFormat;
22+
};
23+
24+
export function createSlider(config: SliderConfig) {
25+
return function Slider(props: Props) {
26+
return (
27+
<Box className={slider} {...props.groupProps} color={undefined}>
28+
<Columns space={24} alignY="center">
29+
<Column width="content">
30+
<Label size="large" color={props.disabled ? "disabled" : "secondary"}>
31+
{unsafeLocalizedString(props.numberFormatter.format(props.state.getThumbMinValue(0)))}
32+
</Label>
33+
</Column>
34+
<Box
35+
className={trackContainer}
36+
{...props.trackProps}
37+
ref={props.trackRef}
38+
color={undefined}
39+
>
40+
<Box
41+
className={trackInactive}
42+
disabled={props.disabled}
43+
borderRadius={config.trailRadius}
44+
/>
45+
<Box
46+
className={trackActive}
47+
color={config.trailColor}
48+
background="currentColor"
49+
disabled={props.disabled}
50+
borderRadius={config.trailRadius}
51+
style={{
52+
left: props.type === "single" ? 0 : `${props.state.getThumbPercent(0) * 100}%`,
53+
width:
54+
props.type === "single"
55+
? `${props.state.getThumbPercent(0) * 100}%`
56+
: `${(props.state.getThumbPercent(1) - props.state.getThumbPercent(0)) * 100}%`,
57+
}}
58+
/>
59+
<Thumb
60+
index={0}
61+
state={props.state}
62+
trackRef={props.trackRef}
63+
outputProps={props.outputProps}
64+
disabled={props.disabled}
65+
/>
66+
{props.type === "double" && (
67+
<Thumb
68+
index={1}
69+
state={props.state}
70+
trackRef={props.trackRef}
71+
outputProps={props.outputProps}
72+
disabled={props.disabled}
73+
/>
74+
)}
75+
</Box>
76+
<Column width="content">
77+
<Label size="large" color={props.disabled ? "disabled" : "secondary"}>
78+
{unsafeLocalizedString(
79+
props.numberFormatter.format(
80+
props.state.getThumbMaxValue(props.type === "single" ? 0 : 1)
81+
)
82+
)}
83+
</Label>
84+
</Column>
85+
</Columns>
86+
</Box>
87+
);
88+
};
89+
90+
type ThumbProps = {
91+
trackRef: RefObject<HTMLDivElement>;
92+
state: SliderState;
93+
index: number;
94+
outputProps: OutputHTMLAttributes<HTMLOutputElement>;
95+
disabled?: boolean;
96+
};
97+
98+
function Thumb(props: ThumbProps) {
99+
const { state, trackRef, index } = props;
100+
const inputRef = useRef<HTMLInputElement>(null);
101+
const { thumbProps, inputProps } = useSliderThumb(
102+
{ index, trackRef, inputRef, isDisabled: props.disabled },
103+
state
104+
);
105+
const { focusProps, isFocusVisible } = useFocusRing();
106+
107+
return (
108+
<Box
109+
position="absolute"
110+
style={{
111+
transform: "translateX(-50%)",
112+
left: `${state.getThumbPercent(index) * 100}%`,
113+
}}
114+
>
115+
<Stack space={8} align="center">
116+
<Box
117+
className={thumbRecipe({
118+
isFocused: isFocusVisible,
119+
})}
120+
{...thumbProps}
121+
color={undefined}
122+
disabled={props.disabled}
123+
borderRadius={config.thumbRadius}
124+
>
125+
<VisuallyHidden>
126+
<input ref={inputRef} {...mergeProps(inputProps, focusProps)} />
127+
</VisuallyHidden>
128+
</Box>
129+
<Box as="output" {...props.outputProps} color={undefined}>
130+
<Label size="large" color={props.disabled ? "disabled" : undefined}>
131+
{unsafeLocalizedString(state.getThumbValueLabel(props.index))}
132+
</Label>
133+
</Box>
134+
</Stack>
135+
</Box>
136+
);
137+
}
138+
}
139+
140+
export type { Props as SliderProps };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { useNumberFormatter } from "@react-aria/i18n";
2+
import { useSlider } from "@react-aria/slider";
3+
import { useSliderState } from "@react-stately/slider";
4+
import { useRef, FunctionComponent } from "react";
5+
import { FieldType } from "../Field/createField";
6+
import { FieldProps } from "../Field/FieldProps";
7+
import { useFormatOptions } from "../NumberInput/formatOptions";
8+
import { FormatProps } from "../NumberInput/FormatProps";
9+
import { SliderProps } from "../Slider/createSlider";
10+
11+
type Props = (
12+
| ({ type: "single" } & FieldProps<number>)
13+
| ({
14+
type: "double";
15+
} & FieldProps<[number, number]>)
16+
) & {
17+
minValue: number;
18+
maxValue: number;
19+
step?: number;
20+
autoFocus?: boolean;
21+
} & FormatProps;
22+
23+
export function createSliderField({
24+
Field,
25+
Slider,
26+
}: {
27+
Field: FieldType;
28+
Slider: FunctionComponent<SliderProps>;
29+
}) {
30+
return function SliderField(props: Props) {
31+
const trackRef = useRef<HTMLDivElement>(null);
32+
const formatOptions = useFormatOptions(props);
33+
const numberFormatter = useNumberFormatter(formatOptions);
34+
const valueProps =
35+
props.type === "double"
36+
? {
37+
value: props.value,
38+
onChange: (values: number[]) => props.onChange([values[0], values[1]]),
39+
}
40+
: {
41+
value: [props.value],
42+
onChange: (values: number[]) => props.onChange(values[0]),
43+
};
44+
45+
const state = useSliderState({
46+
...props,
47+
...valueProps,
48+
numberFormatter,
49+
});
50+
51+
const { groupProps, trackProps, outputProps, labelProps } = useSlider(
52+
{ ...props, ...valueProps },
53+
state,
54+
trackRef
55+
);
56+
57+
return (
58+
<Field {...props} labelProps={labelProps} assistiveTextProps={{}} errorMessageProps={{}}>
59+
<Slider
60+
{...props}
61+
trackRef={trackRef}
62+
groupProps={groupProps}
63+
trackProps={trackProps}
64+
outputProps={outputProps}
65+
state={state}
66+
numberFormatter={numberFormatter}
67+
/>
68+
</Field>
69+
);
70+
};
71+
}
72+
73+
export type { Props as SliderFieldProps };

packages/bento-design-system/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export * from "./Placeholder/Placeholder";
3939
export * from "./Popover/Popover";
4040
export * from "./ProgressBar/createProgressBar";
4141
export * from "./SearchBar/createSearchBar";
42+
export * from "./Slider/createSlider";
4243
export * from "./Stepper/createStepper";
4344
export * from "./Switch/createSwitch";
4445
export * from "./Table/createTable";

packages/bento-design-system/src/util/defaultConfigs.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { TabsConfig } from "../Tabs/Config";
4242
import { ToastConfig } from "../Toast/Config";
4343
import { ProgressBarConfig } from "../ProgressBar/Config";
4444
import { StepperConfig } from "../Stepper/Config";
45+
import { SliderConfig } from "../Slider/Config";
4546

4647
export const actions: ActionsConfig = {
4748
buttonsAlignment: "right",
@@ -417,6 +418,15 @@ export const progressBar: ProgressBarConfig = {
417418
discreteInternalSpacing: 8,
418419
};
419420

421+
export const slider: SliderConfig = {
422+
valueSize: "large",
423+
labelsSize: "large",
424+
internalSpacing: 24,
425+
trailColor: "brandPrimary",
426+
trailRadius: "circledX",
427+
thumbRadius: 8,
428+
};
429+
420430
export const stepper: StepperConfig = {
421431
spaceBetweenSteps: 40,
422432
internalSpacing: 8,

0 commit comments

Comments
 (0)