From bde60e50f3e6f9e70fdb73600e84f61db91a1312 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Wed, 28 Aug 2024 15:41:40 +0400 Subject: [PATCH 1/6] Add slider component' --- packages/ui/src/Slider/index.stories.tsx | 24 +++ packages/ui/src/Slider/index.tsx | 208 +++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 packages/ui/src/Slider/index.stories.tsx create mode 100644 packages/ui/src/Slider/index.tsx diff --git a/packages/ui/src/Slider/index.stories.tsx b/packages/ui/src/Slider/index.stories.tsx new file mode 100644 index 0000000000..24ac10f41f --- /dev/null +++ b/packages/ui/src/Slider/index.stories.tsx @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { Slider } from './index'; + +const meta: Meta = { + component: Slider, + tags: ['autodocs', '!dev'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + min: 0, + max: 10, + step: 1, + leftLabel: 'label', + rightLabel: 'label', + defaultValue: 5, + showValue: true, + showFill: true, + }, +}; diff --git a/packages/ui/src/Slider/index.tsx b/packages/ui/src/Slider/index.tsx new file mode 100644 index 0000000000..60d42fc78d --- /dev/null +++ b/packages/ui/src/Slider/index.tsx @@ -0,0 +1,208 @@ +import React, { useState, useRef } from 'react'; +import styled from 'styled-components'; +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; + showTrackGaps?: boolean; + trackGapBackground?: string; + showFill?: boolean; +} + +const THUMB_SIZE = '16px'; +const TRACK_HEIGHT = '4px'; +const TRACK_GAP_WIDTH = '4px'; + +const SliderContainer = styled.div` + position: relative; + width: 100%; + height: ${THUMB_SIZE}; + cursor: pointer; + touch-action: none; +`; + +const SliderTrack = styled.div` + position: absolute; + width: 100%; + top: 50%; + transform: translateY(-50%); + height: ${TRACK_HEIGHT}; + background: rgba(250, 250, 250, 0.1); +`; + +const SliderGap = styled.div<{ $left: number; $background: string }>` + position: absolute; + z-index: 2; + width: ${TRACK_GAP_WIDTH}; + height: ${TRACK_HEIGHT}; + background: ${props => props.$background}; + left: ${props => props.$left}%; + transform: translateX(-50%); +`; + +const SliderThumb = styled.div<{ $left: number }>` + position: absolute; + z-index: 3; + width: ${THUMB_SIZE}; + height: ${THUMB_SIZE}; + background: ${props => props.theme.color.neutral.contrast}; + border-radius: 50%; + left: ${props => props.$left}%; + top: 50%; + transform: translate(-50%, -50%); + cursor: grab; + touch-action: none; +`; + +const SliderFill = styled.div<{ percentage: number }>` + position: absolute; + z-index: 1; + height: ${TRACK_HEIGHT}; + background: ${props => props.theme.color.primary.main}; + border-radius: 2px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + width: ${props => props.percentage}%; +`; + +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' }>` + font-size: ${props => props.theme.fontSize.textSm}; + color: ${props => props.theme.color.text.secondary}; + justify-self: ${props => (props.$position === 'left' ? 'flex-start' : 'flex-end')}; +`; + +const ValueDisplay = styled.div` + margin-top: ${props => props.theme.spacing(1)}; + border: 1px solid ${props => props.theme.color.other.tonalStroke}; + font-size: ${props => props.theme.fontSize.textSm}; + color: ${props => props.theme.color.text.primary}; + padding: ${props => props.theme.spacing(1)} ${props => props.theme.spacing(2)}; +`; + +/** + * Renders a segmented control where only one option can be selected at a time. + * Functionally equivalent to a `` element or a set of radio buttons, - * but looks nicer when you only have a few options to choose from. (Probably - * shouldn't be used with more than 5 options.) - * - * Fully accessible and keyboard-controllable. - * - * @example - * ```TSX - * - * ``` - */ +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 = ({ min = 0, max = 100, @@ -124,9 +115,11 @@ export const Slider: React.FC = ({ leftLabel, rightLabel, showValue = false, + valueDetails, showTrackGaps = true, trackGapBackground = theme.color.base.black, showFill = false, + fontSize = theme.fontSize.textSm, }) => { const [value, setValue] = useState(defaultValue); const sliderRef = useRef(null); @@ -184,8 +177,12 @@ export const Slider: React.FC = ({
{(!!leftLabel || !!rightLabel) && ( - - + + )} @@ -202,7 +199,12 @@ export const Slider: React.FC = ({ - {showValue && {value}} + {showValue && ( + + {value} + {valueDetails && ยท {valueDetails}} + + )}
); }; From 31e31ba27e2a87d87b8b82256477fa3de3675a0a Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Wed, 28 Aug 2024 16:31:57 +0400 Subject: [PATCH 3/6] Add tests for Slider --- packages/ui/src/Slider/index.stories.tsx | 2 +- packages/ui/src/Slider/index.test.tsx | 18 ++++++++++++++++++ packages/ui/src/Slider/index.tsx | 9 ++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/Slider/index.test.tsx diff --git a/packages/ui/src/Slider/index.stories.tsx b/packages/ui/src/Slider/index.stories.tsx index 24ac10f41f..b82f3196af 100644 --- a/packages/ui/src/Slider/index.stories.tsx +++ b/packages/ui/src/Slider/index.stories.tsx @@ -15,9 +15,9 @@ export const Default: Story = { min: 0, max: 10, step: 1, + defaultValue: 5, leftLabel: 'label', rightLabel: 'label', - defaultValue: 5, showValue: true, showFill: true, }, diff --git a/packages/ui/src/Slider/index.test.tsx b/packages/ui/src/Slider/index.test.tsx new file mode 100644 index 0000000000..869297db4a --- /dev/null +++ b/packages/ui/src/Slider/index.test.tsx @@ -0,0 +1,18 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Slider } from '.'; +import { PenumbraUIProvider } from '../PenumbraUIProvider'; + +describe('', () => { + it('renders correctly', () => { + const { container } = render( + , + { + wrapper: PenumbraUIProvider, + }, + ); + + expect(container).toHaveTextContent('left'); + expect(container).toHaveTextContent('right'); + }); +}); diff --git a/packages/ui/src/Slider/index.tsx b/packages/ui/src/Slider/index.tsx index bcfd5e466c..a9236fd4c9 100644 --- a/packages/ui/src/Slider/index.tsx +++ b/packages/ui/src/Slider/index.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef } from 'react'; import styled from 'styled-components'; +import { detail, detailTechnical } from '../utils/typography'; import { theme } from '../PenumbraUIProvider/theme'; interface SliderProps { @@ -83,18 +84,20 @@ const LabelContainer = styled.div` `; 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(1)}; + 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(1)} ${props => props.theme.spacing(2)}; + padding: ${props => props.theme.spacing(2)} ${props => props.theme.spacing(3)}; `; const ValueDisplay = styled.div` @@ -119,7 +122,7 @@ export const Slider: React.FC = ({ showTrackGaps = true, trackGapBackground = theme.color.base.black, showFill = false, - fontSize = theme.fontSize.textSm, + fontSize = theme.fontSize.textXs, }) => { const [value, setValue] = useState(defaultValue); const sliderRef = useRef(null); From fb924b4903f38c7ec88fbf206f243300ec257067 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Wed, 28 Aug 2024 16:33:51 +0400 Subject: [PATCH 4/6] Add changeset --- .changeset/young-ties-beam.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/young-ties-beam.md diff --git a/.changeset/young-ties-beam.md b/.changeset/young-ties-beam.md new file mode 100644 index 0000000000..d07a3eb8a6 --- /dev/null +++ b/.changeset/young-ties-beam.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/ui': minor +--- + +Add Slider Component From 7c5460e84097fa49e3485d8425c529e5c1b6b0f4 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Thu, 29 Aug 2024 18:13:21 +0400 Subject: [PATCH 5/6] Use RadixSlider for Slider component --- packages/ui/src/Slider/index.tsx | 177 ++++++++++++++----------------- 1 file changed, 78 insertions(+), 99 deletions(-) diff --git a/packages/ui/src/Slider/index.tsx b/packages/ui/src/Slider/index.tsx index a9236fd4c9..637708b833 100644 --- a/packages/ui/src/Slider/index.tsx +++ b/packages/ui/src/Slider/index.tsx @@ -1,5 +1,6 @@ -import React, { useState, useRef } from 'react'; -import styled from 'styled-components'; +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'; @@ -13,67 +14,81 @@ interface SliderProps { rightLabel?: string; showValue?: boolean; valueDetails?: string; + focusedOutlineColor?: string; showTrackGaps?: boolean; trackGapBackground?: string; showFill?: boolean; fontSize?: string; + disabled?: boolean; } -const THUMB_SIZE = '16px'; -const TRACK_HEIGHT = '4px'; -const TRACK_GAP_WIDTH = '4px'; +const THUMB_SIZE = theme.spacing(4); +const TRACK_HEIGHT = theme.spacing(1); -const SliderContainer = styled.div` +const SliderContainer = styled(RadixSlider.Root)` position: relative; + display: flex; + align-items: center; width: 100%; height: ${THUMB_SIZE}; - cursor: pointer; - touch-action: none; `; -const SliderTrack = styled.div` - position: absolute; +const Track = styled(RadixSlider.Track)` + background-color: ${props => props.theme.color.other.tonalFill10}; + position: relative; width: 100%; - top: 50%; - transform: translateY(-50%); height: ${TRACK_HEIGHT}; - background: rgba(250, 250, 250, 0.1); `; -const SliderGap = styled.div<{ $left: number; $background: string }>` +const TrackGap = styled.div<{ $left: number; $gapBackground?: string }>` position: absolute; - z-index: 2; - width: ${TRACK_GAP_WIDTH}; + width: 2px; height: ${TRACK_HEIGHT}; - background: ${props => props.$background}; left: ${props => props.$left}%; transform: translateX(-50%); + background-color: ${props => props.$gapBackground}; `; -const SliderThumb = styled.div<{ $left: number }>` +const Range = styled(RadixSlider.Range)` position: absolute; - z-index: 3; + 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: ${props => props.theme.color.neutral.contrast}; + background-color: ${props => props.theme.color.neutral.contrast}; border-radius: 50%; - left: ${props => props.$left}%; - top: 50%; - transform: translate(-50%, -50%); - cursor: grab; - touch-action: none; -`; -const SliderFill = styled.div<{ percentage: number }>` - position: absolute; - z-index: 1; - height: ${TRACK_HEIGHT}; - background: ${props => props.theme.color.primary.main}; - border-radius: 2px; - top: 50%; - transform: translateY(-50%); - pointer-events: none; - width: ${props => props.percentage}%; + ${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` @@ -117,64 +132,23 @@ export const Slider: React.FC = ({ onChange, leftLabel, rightLabel, - showValue = false, - valueDetails, + showValue = true, + showFill = false, showTrackGaps = true, trackGapBackground = theme.color.base.black, - showFill = false, + focusedOutlineColor = theme.color.action.neutralFocusOutline, + valueDetails, fontSize = theme.fontSize.textXs, + disabled = false, }) => { const [value, setValue] = useState(defaultValue); - const sliderRef = useRef(null); - - const handleChange = (newValue: number) => { - const clampedValue = Math.min(Math.max(newValue, min), max); - const steppedValue = Math.round((clampedValue - min) / step) * step + min; - setValue(steppedValue); - onChange?.(steppedValue); - }; - - const updateValue = (clientX: number) => { - if (sliderRef.current) { - const rect = sliderRef.current.getBoundingClientRect(); - const percentage = (clientX - rect.left) / rect.width; - const newValue = percentage * (max - min) + min; - handleChange(newValue); - } - }; - - const handleStart = ( - event: React.MouseEvent | React.TouchEvent, - ) => { - event.preventDefault(); - const clientX = 'touches' in event ? event.touches[0]?.clientX : event.clientX; - if (clientX !== undefined) { - updateValue(clientX); - } - - const handleMove = (e: MouseEvent | TouchEvent) => { - const moveClientX = 'touches' in e ? e.touches[0]?.clientX : e.clientX; - if (moveClientX !== undefined) { - updateValue(moveClientX); - } - }; - - const handleEnd = () => { - document.removeEventListener('mousemove', handleMove); - document.removeEventListener('touchmove', handleMove); - document.removeEventListener('mouseup', handleEnd); - document.removeEventListener('touchend', handleEnd); - }; - - document.addEventListener('mousemove', handleMove); - document.addEventListener('touchmove', handleMove); - document.addEventListener('mouseup', handleEnd); - document.addEventListener('touchend', handleEnd); + const handleValueChange = (newValue: number[]) => { + const updatedValue = newValue[0] ?? defaultValue; + setValue(updatedValue); + onChange?.(updatedValue); }; - const range = max - min; - const numberOfSteps = Math.floor(range / step); - const percentage = ((value - min) / range) * 100; + const totalSteps = (max - min) / step; return (
@@ -188,19 +162,24 @@ export const Slider: React.FC = ({ )} - - + + + {showFill && } {showTrackGaps && - Array.from({ length: numberOfSteps + 1 }).map((_, index) => ( - - ))} - {showFill && } - - + Array.from({ length: totalSteps - 1 }) + .map((_, i): number => ((i + 1) / totalSteps) * 100) + .map(left => ( + + ))} + + {showValue && ( From 22551a41307e1d314475a26f6ac8880ea4c9b0a8 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Thu, 29 Aug 2024 18:24:58 +0400 Subject: [PATCH 6/6] Add onChange test for Slider --- packages/ui/src/Slider/index.test.tsx | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/Slider/index.test.tsx b/packages/ui/src/Slider/index.test.tsx index 869297db4a..8379f057f0 100644 --- a/packages/ui/src/Slider/index.test.tsx +++ b/packages/ui/src/Slider/index.test.tsx @@ -1,8 +1,14 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +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('', () => { it('renders correctly', () => { const { container } = render( @@ -15,4 +21,21 @@ describe('', () => { expect(container).toHaveTextContent('left'); expect(container).toHaveTextContent('right'); }); + + it('handles onChange correctly', () => { + const onChange = vi.fn(); + + const { container } = render( + , + { + wrapper: PenumbraUIProvider, + }, + ); + + const slider = container.querySelector('[role="slider"]')!; + fireEvent.focus(slider); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + + expect(onChange).toHaveBeenCalledWith(6); + }); });