Skip to content

Commit

Permalink
feat(Slider): add component (#1502)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielsimao authored Aug 4, 2023
1 parent 0a0d424 commit b98f7e2
Show file tree
Hide file tree
Showing 12 changed files with 608 additions and 2 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@react-aria/dialog": "^3.3.1",
"@react-aria/focus": "^3.6.1",
"@react-aria/gridlist": "^3.1.2",
"@react-aria/i18n": "^3.8.0",
"@react-aria/interactions": "^3.11.0",
"@react-aria/label": "^3.4.3",
"@react-aria/link": "^3.4.0",
Expand All @@ -28,6 +29,7 @@
"@react-aria/progress": "^3.4.1",
"@react-aria/select": "^3.9.0",
"@react-aria/separator": "^3.2.5",
"@react-aria/slider": "^3.5.0",
"@react-aria/switch": "^3.2.4",
"@react-aria/table": "^3.4.0",
"@react-aria/tabs": "^3.5.0",
Expand All @@ -39,6 +41,7 @@
"@react-stately/list": "^3.6.1",
"@react-stately/overlays": "^3.5.1",
"@react-stately/select": "^3.4.0",
"@react-stately/slider": "^3.4.0",
"@react-stately/table": "^3.3.0",
"@react-stately/tabs": "^3.4.0",
"@react-stately/toggle": "^3.4.2",
Expand Down
34 changes: 34 additions & 0 deletions src/component-library/Slider/Slider.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Meta, Story } from '@storybook/react';

import { Slider, SliderProps } from '.';

const Template: Story<SliderProps> = (args) => <Slider {...args} />;

const Default = Template.bind({});
Default.args = { label: 'Leverage', step: 1, minValue: 0, maxValue: 5, marks: false };

const CustomMark = Template.bind({});
CustomMark.args = {
label: 'Leverage',
step: 1,
minValue: 0,
maxValue: 5,
marks: true,
renderMarkText: (value) => `${value}x`
};

const Marks = Template.bind({});
Marks.args = { label: 'Leverage', step: 1, minValue: 0, maxValue: 5, marks: true };

const Decimal = Template.bind({});
Decimal.args = { label: 'Leverage', step: 0.1, minValue: 0.1, maxValue: 0.5, marks: true };

const Disabled = Template.bind({});
Disabled.args = { label: 'Leverage', step: 1, minValue: 0, maxValue: 5, isDisabled: true };

export { CustomMark, Decimal, Default, Disabled, Marks };

export default {
title: 'Forms/Slider',
component: Slider
} as Meta;
156 changes: 156 additions & 0 deletions src/component-library/Slider/Slider.style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import styled, { css } from 'styled-components';

import { Flex } from '../Flex';
import { Span } from '../Text';
import { theme } from '../theme';

type StyledSliderThumbProps = {
$isHovered: boolean;
$isDragged: boolean;
$isFocused: boolean;
$isFocusVisible: boolean;
};

type StyledFilledTrackProps = {
$percentage: number;
};

type StyledMarkProps = {
$isFilled: boolean;
$position: number;
};

type StyledMarkTextProps = {
$position: number;
};

type StyledControlsProps = {
$hasMarks?: boolean;
};

type StyledSliderWrapperProps = {
$isDisabled?: boolean;
};

const StyledSliderWrapper = styled(Flex)<StyledSliderWrapperProps>`
width: 300px;
cursor: ${({ $isDisabled }) => $isDisabled && 'default'};
opacity: ${({ $isDisabled }) => $isDisabled && 0.5};
`;

const StyledControls = styled.div<StyledControlsProps>`
position: relative;
display: inline-block;
vertical-align: top;
width: 100%;
min-height: 32px;
margin-bottom: ${({ $hasMarks }) => $hasMarks && '20px'};
`;

const StyledBaseTrack = styled.div`
display: block;
position: absolute;
top: 50%;
height: ${theme.slider.track.size};
transform: translateY(-50%);
border-radius: ${theme.rounded.full};
`;

const StyledTrack = styled(StyledBaseTrack)`
background-color: ${theme.slider.track.bg};
width: 100%;
z-index: 1;
`;

const StyledFilledTrack = styled(StyledBaseTrack)<StyledFilledTrackProps>`
width: ${({ $percentage }) => `calc((100% * ${$percentage}))`};
background-color: ${theme.slider.track.fillBg};
z-index: 2;
`;

const StyledMark = styled.span<StyledMarkProps>`
position: absolute;
left: ${({ $position }) => `${$position}%`};
top: 50%;
z-index: 2;
transform: translate(-50%, -50%);
width: ${theme.slider.mark.width};
height: ${theme.slider.mark.height};
background-color: ${({ $isFilled }) => ($isFilled ? theme.slider.track.fillBg : theme.slider.track.bg)};
border-radius: ${theme.rounded.full};
`;

const StyledMarkText = styled(Span)<StyledMarkTextProps>`
position: absolute;
top: 35px;
left: ${({ $position }) => `${$position}%`};
transform: translateX(-50%);
cursor: default;
`;

const StyledSliderThumb = styled.div<StyledSliderThumbProps>`
height: ${theme.slider.thumb.size};
width: ${theme.slider.thumb.size};
top: 50%;
z-index: 3;
background-color: ${({ $isHovered }) => ($isHovered ? theme.slider.thumb.hover.bg : theme.slider.thumb.bg)};
border-style: solid;
border-color: ${theme.colors.textSecondary};
border-width: ${({ $isDragged }) => ($isDragged ? '8px' : '2px')};
border-radius: ${theme.rounded.full};
transition: border-width ${theme.transition.duration.duration100}ms ease-in-out;
&::before {
content: '';
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: ${theme.slider.thumb.size};
width: ${theme.slider.thumb.size};
border-radius: ${theme.rounded.full};
transition: box-shadow ${theme.transition.duration.duration150}ms ease-in-out;
${({ $isFocusVisible }) =>
$isFocusVisible &&
css`
box-shadow: 0 0 0 2px var(--colors-input-focus-border);
height: 28px;
width: 28px;
`}
}
`;

const StyledInput = styled.input`
cursor: default;
pointer-events: none;
overflow: hidden;
height: ${theme.slider.thumb.size};
width: ${theme.slider.thumb.size};
position: absolute;
top: 50%;
transform: translate(-20%, -20%);
&:focus {
outline: none;
}
`;

export {
StyledControls,
StyledFilledTrack,
StyledInput,
StyledMark,
StyledMarkText,
StyledSliderThumb,
StyledSliderWrapper,
StyledTrack
};
100 changes: 100 additions & 0 deletions src/component-library/Slider/Slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useNumberFormatter } from '@react-aria/i18n';
import { AriaSliderProps, useSlider } from '@react-aria/slider';
import { useSliderState } from '@react-stately/slider';
import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react';

import { Label } from '../Label';
import { useDOMRef } from '../utils/dom';
import { StyledControls, StyledFilledTrack, StyledSliderWrapper, StyledTrack } from './Slider.style';
import { SliderMarks } from './SliderMarks';
import { SliderThumb } from './SliderThumb';

type Props = {
marks?: boolean;
formatOptions?: Intl.NumberFormatOptions;
onChange?: (value: number) => void;
renderMarkText: (text: ReactNode) => ReactNode;
};

type NativeAttrs = Omit<InputHTMLAttributes<unknown>, keyof Props>;

type InheritAttrs = Omit<AriaSliderProps, keyof Props>;

type SliderProps = Props & NativeAttrs & InheritAttrs;

const Slider = forwardRef<HTMLDivElement, SliderProps>(
(
{
className,
style,
hidden,
step = 1,
minValue = 0,
maxValue = 100,
label,
marks,
onChange,
renderMarkText,
name,
formatOptions,
isDisabled,
...props
},
ref
): JSX.Element => {
const domRef = useDOMRef(ref);
const trackRef = useRef<HTMLInputElement>(null);

const ariaProps: AriaSliderProps = {
...props,
step,
minValue,
maxValue,
label,
isDisabled,
onChange: ((value: number[]) => onChange?.(value[0])) as any
};

const numberFormatter = useNumberFormatter(formatOptions);
const state = useSliderState({
...ariaProps,
numberFormatter
});

const { groupProps, trackProps, labelProps } = useSlider(ariaProps, state, trackRef);

return (
<StyledSliderWrapper
{...groupProps}
$isDisabled={isDisabled}
direction='column'
ref={domRef}
className={className}
style={style}
hidden={hidden}
>
<Label {...labelProps}>{label}</Label>
<StyledControls ref={trackRef} $hasMarks={!!marks} {...trackProps}>
<StyledTrack />
<SliderThumb index={0} trackRef={trackRef} state={state} name={name} />
<StyledFilledTrack $percentage={state.getThumbPercent(0)} />
{marks && (
<SliderMarks
state={state}
step={step}
minValue={minValue}
maxValue={maxValue}
numberFormatter={numberFormatter}
renderMarkText={renderMarkText}
/>
)}
</StyledControls>
</StyledSliderWrapper>
);
}
);

Slider.displayName = 'Slider';

export { Slider };
export type { SliderProps };
61 changes: 61 additions & 0 deletions src/component-library/Slider/SliderMarks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { AriaSliderProps } from '@react-aria/slider';
import { SliderState } from '@react-stately/slider';
import { Fragment, ReactNode, useMemo } from 'react';

import { StyledMark, StyledMarkText } from './Slider.style';

type Props = { renderMarkText: (text: ReactNode) => ReactNode; state: SliderState; numberFormatter: Intl.NumberFormat };

type InheritAttrs = Omit<Pick<AriaSliderProps, 'minValue' | 'maxValue' | 'step'>, keyof Props>;

type SliderMarksProps = Props & InheritAttrs;

const SliderMarks = ({
step = 1,
minValue = 0,
maxValue = 100,
state,
numberFormatter,
renderMarkText = (text) => text
}: SliderMarksProps): JSX.Element => {
const thumbPercent = state.getThumbPercent(0);

const { marks } = useMemo(() => {
const range = maxValue - minValue;

const numSteps = Math.ceil(range / step);

return {
range,
marks: Array(numSteps + 1)
.fill(undefined)
.map((_, idx) => {
const value = minValue + idx * step;
const percentage = ((value - minValue) / range) * 100;
const formattedValue = numberFormatter.format(value);

return { label: renderMarkText(formattedValue), percentage };
})
};
}, [maxValue, minValue, renderMarkText, numberFormatter, step]);

return (
<>
{marks.map((mark, idx) => {
const isFilled = thumbPercent * 100 >= mark.percentage;

return (
<Fragment key={idx}>
<StyledMark $position={mark.percentage} $isFilled={isFilled} />
<StyledMarkText $position={mark.percentage} size='xs'>
{mark.label}
</StyledMarkText>
</Fragment>
);
})}
</>
);
};

export { SliderMarks };
export type { SliderMarksProps };
Loading

2 comments on commit b98f7e2

@vercel
Copy link

@vercel vercel bot commented on b98f7e2 Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on b98f7e2 Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.