Skip to content

Commit

Permalink
Add Slider component (#1738)
Browse files Browse the repository at this point in the history
* Add slider component'

* Add fontSize and valueDetails props

* Add tests for Slider

* Add changeset

* Use RadixSlider for Slider component

* Add onChange test for Slider
  • Loading branch information
JasonMHasperhoven authored Sep 3, 2024
1 parent 5100518 commit d938456
Show file tree
Hide file tree
Showing 4 changed files with 262 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/young-ties-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

Add Slider Component
24 changes: 24 additions & 0 deletions packages/ui/src/Slider/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Meta, StoryObj } from '@storybook/react';
import { Slider } from './index';

const meta: Meta<typeof Slider> = {
component: Slider,
tags: ['autodocs', '!dev'],
};

export default meta;

type Story = StoryObj<typeof Slider>;

export const Default: Story = {
args: {
min: 0,
max: 10,
step: 1,
defaultValue: 5,
leftLabel: 'label',
rightLabel: 'label',
showValue: true,
showFill: true,
},
};
41 changes: 41 additions & 0 deletions packages/ui/src/Slider/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { render, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Slider } from '.';
import { PenumbraUIProvider } from '../PenumbraUIProvider';

window.ResizeObserver = vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));

describe('<Slider />', () => {
it('renders correctly', () => {
const { container } = render(
<Slider min={0} max={10} step={1} defaultValue={5} leftLabel='left' rightLabel='right' />,
{
wrapper: PenumbraUIProvider,
},
);

expect(container).toHaveTextContent('left');
expect(container).toHaveTextContent('right');
});

it('handles onChange correctly', () => {
const onChange = vi.fn();

const { container } = render(
<Slider min={0} max={10} step={1} defaultValue={5} onChange={onChange} />,
{
wrapper: PenumbraUIProvider,
},
);

const slider = container.querySelector('[role="slider"]')!;
fireEvent.focus(slider);
fireEvent.keyDown(slider, { key: 'ArrowRight' });

expect(onChange).toHaveBeenCalledWith(6);
});
});
192 changes: 192 additions & 0 deletions packages/ui/src/Slider/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import styled, { css } from 'styled-components';
import * as RadixSlider from '@radix-ui/react-slider';
import { detail, detailTechnical } from '../utils/typography';
import { theme } from '../PenumbraUIProvider/theme';

interface SliderProps {
min?: number;
max?: number;
step?: number;
defaultValue?: number;
onChange?: (value: number) => void;
leftLabel?: string;
rightLabel?: string;
showValue?: boolean;
valueDetails?: string;
focusedOutlineColor?: string;
showTrackGaps?: boolean;
trackGapBackground?: string;
showFill?: boolean;
fontSize?: string;
disabled?: boolean;
}

const THUMB_SIZE = theme.spacing(4);
const TRACK_HEIGHT = theme.spacing(1);

const SliderContainer = styled(RadixSlider.Root)`
position: relative;
display: flex;
align-items: center;
width: 100%;
height: ${THUMB_SIZE};
`;

const Track = styled(RadixSlider.Track)`
background-color: ${props => props.theme.color.other.tonalFill10};
position: relative;
width: 100%;
height: ${TRACK_HEIGHT};
`;

const TrackGap = styled.div<{ $left: number; $gapBackground?: string }>`
position: absolute;
width: 2px;
height: ${TRACK_HEIGHT};
left: ${props => props.$left}%;
transform: translateX(-50%);
background-color: ${props => props.$gapBackground};
`;

const Range = styled(RadixSlider.Range)`
position: absolute;
background-color: ${props => props.theme.color.primary.main};
height: 100%;
`;

const Thumb = styled(RadixSlider.Thumb)<{
$focusedOutlineColor: string;
$disabled: boolean;
}>`
display: block;
width: ${THUMB_SIZE};
height: ${THUMB_SIZE};
background-color: ${props => props.theme.color.neutral.contrast};
border-radius: 50%;
${props =>
props.$disabled
? css`
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: ${props => props.theme.color.action.disabledOverlay};
}
`
: css<{ $focusedOutlineColor: string }>`
cursor: grab;
&:hover {
background-color: ${props => props.theme.color.neutral.contrast};
}
&:focus {
outline: 2px solid ${props => props.$focusedOutlineColor};
}
`}
`;

const LabelContainer = styled.div`
display: flex;
width: 100%;
justify-content: space-between;
margin-bottom: ${props => props.theme.spacing(1)};
`;

const Label = styled.div<{ $position: 'left' | 'right'; $fontSize: string }>`
${detailTechnical}
font-size: ${props => props.$fontSize};
color: ${props => props.theme.color.text.secondary};
justify-self: ${props => (props.$position === 'left' ? 'flex-start' : 'flex-end')};
`;

const ValueContainer = styled.div<{ $fontSize: string }>`
${detail}
display: flex;
margin-top: ${props => props.theme.spacing(2)};
border: 1px solid ${props => props.theme.color.other.tonalStroke};
font-size: ${props => props.$fontSize};
color: ${props => props.theme.color.text.primary};
padding: ${props => props.theme.spacing(2)} ${props => props.theme.spacing(3)};
`;

const ValueDisplay = styled.div`
color: ${props => props.theme.color.text.primary};
`;

const ValueDetails = styled.div`
margin-left: ${props => props.theme.spacing(1)};
color: ${props => props.theme.color.text.secondary};
`;

export const Slider: React.FC<SliderProps> = ({
min = 0,
max = 100,
step = 1,
defaultValue = 0,
onChange,
leftLabel,
rightLabel,
showValue = true,
showFill = false,
showTrackGaps = true,
trackGapBackground = theme.color.base.black,
focusedOutlineColor = theme.color.action.neutralFocusOutline,
valueDetails,
fontSize = theme.fontSize.textXs,
disabled = false,
}) => {
const [value, setValue] = useState(defaultValue);
const handleValueChange = (newValue: number[]) => {
const updatedValue = newValue[0] ?? defaultValue;
setValue(updatedValue);
onChange?.(updatedValue);
};

const totalSteps = (max - min) / step;

return (
<div>
{(!!leftLabel || !!rightLabel) && (
<LabelContainer>
<Label $fontSize={fontSize} $position='left'>
{leftLabel}
</Label>
<Label $fontSize={fontSize} $position='right'>
{rightLabel}
</Label>
</LabelContainer>
)}
<SliderContainer
min={min}
max={max}
step={step}
defaultValue={[defaultValue]}
onValueChange={handleValueChange}
disabled={disabled}
>
<Track>
{showFill && <Range />}
{showTrackGaps &&
Array.from({ length: totalSteps - 1 })
.map((_, i): number => ((i + 1) / totalSteps) * 100)
.map(left => (
<TrackGap key={left} $left={left} $gapBackground={trackGapBackground} />
))}
</Track>
<Thumb $disabled={disabled} $focusedOutlineColor={focusedOutlineColor} />
</SliderContainer>
{showValue && (
<ValueContainer $fontSize={fontSize}>
<ValueDisplay>{value}</ValueDisplay>
{valueDetails && <ValueDetails>· {valueDetails}</ValueDetails>}
</ValueContainer>
)}
</div>
);
};

0 comments on commit d938456

Please sign in to comment.