From 6ba6e4871c460bb2e545bdff88728cb0230aaa85 Mon Sep 17 00:00:00 2001 From: Ryan Laurie <30528226+iethree@users.noreply.github.com> Date: Fri, 3 Jun 2022 14:38:18 -0600 Subject: [PATCH] Dual Slider Component (#23085) * add generic range slider input --- .../core/components/Slider/Slider.stories.tsx | 15 +++ .../core/components/Slider/Slider.styled.tsx | 59 ++++++++++++ .../core/components/Slider/Slider.tsx | 93 +++++++++++++++++++ .../components/Slider/Slider.unit.spec.tsx | 52 +++++++++++ .../metabase/core/components/Slider/index.ts | 1 + 5 files changed, 220 insertions(+) create mode 100644 frontend/src/metabase/core/components/Slider/Slider.stories.tsx create mode 100644 frontend/src/metabase/core/components/Slider/Slider.styled.tsx create mode 100644 frontend/src/metabase/core/components/Slider/Slider.tsx create mode 100644 frontend/src/metabase/core/components/Slider/Slider.unit.spec.tsx create mode 100644 frontend/src/metabase/core/components/Slider/index.ts diff --git a/frontend/src/metabase/core/components/Slider/Slider.stories.tsx b/frontend/src/metabase/core/components/Slider/Slider.stories.tsx new file mode 100644 index 00000000000..255d0dde4c0 --- /dev/null +++ b/frontend/src/metabase/core/components/Slider/Slider.stories.tsx @@ -0,0 +1,15 @@ +import React, { useState } from "react"; +import { ComponentStory } from "@storybook/react"; +import Slider from "./Slider"; + +export default { + title: "Core/Slider", + component: Slider, +}; + +const Template: ComponentStory<typeof Slider> = args => { + const [value, setValue] = useState([10, 40]); + return <Slider {...args} value={value} onChange={setValue} />; +}; + +export const Default = Template.bind({}); diff --git a/frontend/src/metabase/core/components/Slider/Slider.styled.tsx b/frontend/src/metabase/core/components/Slider/Slider.styled.tsx new file mode 100644 index 00000000000..604f82a49ac --- /dev/null +++ b/frontend/src/metabase/core/components/Slider/Slider.styled.tsx @@ -0,0 +1,59 @@ +import styled from "@emotion/styled"; +import { color, alpha } from "metabase/lib/colors"; + +export const SliderContainer = styled.div` + position: relative; + display: flex; +`; + +const thumbStyles = ` + -webkit-appearance: none; + width: 1.2rem; + height: 1.2rem; + border-radius: 50%; + border: 2px solid ${color("brand")}; + background-color: ${color("white")}; + cursor: pointer; + box-shadow: 0 0 2px 1px ${color("brand")}; + pointer-events: all; + &:active { + box-shadow: 0 0 4px 1px ${color("brand")}; + } +`; + +export const SliderInput = styled.input` + -webkit-appearance: none; + position: absolute; + width: 100%; + height: 0; + border: none; + outline: none; + background: none; + pointer-events: none; + &::-webkit-slider-thumb { + ${thumbStyles} + } + &::-moz-range-thumb { + ${thumbStyles} + } +`; + +export const SliderTrack = styled.span` + width: 100%; + background-color: ${alpha("brand", 0.5)}; + height: 0.2rem; + border-radius: 0.2rem; +`; + +interface ActiveTrackProps { + left: number; + width: number; +} + +export const ActiveTrack = styled.span<ActiveTrackProps>` + position: absolute; + left: ${props => props.left}%; + width: ${props => props.width}%; + background-color: ${color("brand")}; + height: 0.2rem; +`; diff --git a/frontend/src/metabase/core/components/Slider/Slider.tsx b/frontend/src/metabase/core/components/Slider/Slider.tsx new file mode 100644 index 00000000000..cd779f25e93 --- /dev/null +++ b/frontend/src/metabase/core/components/Slider/Slider.tsx @@ -0,0 +1,93 @@ +import React, { + ChangeEvent, + InputHTMLAttributes, + useCallback, + useMemo, +} from "react"; + +import { + SliderContainer, + SliderInput, + SliderTrack, + ActiveTrack, +} from "./Slider.styled"; + +export type NumericInputAttributes = Omit< + InputHTMLAttributes<HTMLDivElement>, + "value" | "size" | "onChange" +>; + +export interface SliderProps extends NumericInputAttributes { + value: number[]; + onChange: (value: number[]) => void; + min?: number; + max?: number; + step?: number; +} + +const Slider = ({ + value, + onChange, + min = 0, + max = 100, + step = 1, +}: SliderProps) => { + const [rangeMin, rangeMax] = useMemo( + () => [Math.min(...value, min, max), Math.max(...value, min, max)], + [value, min, max], + ); + + const [beforeRange, rangeWidth] = useMemo(() => { + const totalRange = rangeMax - rangeMin; + + return [ + ((Math.min(...value) - rangeMin) / totalRange) * 100, + (Math.abs(value[1] - value[0]) / totalRange) * 100, + ]; + }, [value, rangeMin, rangeMax]); + + const handleChange = useCallback( + (event: ChangeEvent<HTMLInputElement>, valueIndex: number) => { + const changedValue = [...value]; + changedValue[valueIndex] = Number(event.target.value); + onChange(changedValue); + }, + [value, onChange], + ); + + const sortValues = useCallback(() => { + if (value[0] > value[1]) { + const sortedValues = [...value].sort(); + onChange(sortedValues); + } + }, [value, onChange]); + + return ( + <SliderContainer> + <SliderTrack /> + <ActiveTrack left={beforeRange} width={rangeWidth} /> + <SliderInput + type="range" + aria-label="min" + value={value[0]} + onChange={e => handleChange(e, 0)} + onMouseUp={sortValues} + min={rangeMin} + max={rangeMax} + step={step} + /> + <SliderInput + type="range" + aria-label="max" + value={value[1]} + onChange={e => handleChange(e, 1)} + onMouseUp={sortValues} + min={rangeMin} + max={rangeMax} + step={step} + /> + </SliderContainer> + ); +}; + +export default Slider; diff --git a/frontend/src/metabase/core/components/Slider/Slider.unit.spec.tsx b/frontend/src/metabase/core/components/Slider/Slider.unit.spec.tsx new file mode 100644 index 00000000000..4852a25a39c --- /dev/null +++ b/frontend/src/metabase/core/components/Slider/Slider.unit.spec.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react"; +import Slider from "./Slider"; + +describe("Slider", () => { + it("should render 2 range inputs", () => { + render(<Slider value={[10, 40]} onChange={() => null} min={0} max={100} />); + + const minInput = screen.getByLabelText("min"); + const maxInput = screen.getByLabelText("max"); + + expect(minInput).toHaveAttribute("type", "range"); + expect(maxInput).toHaveAttribute("type", "range"); + }); + + it("should always have values in range", () => { + render( + <Slider value={[10, 412]} onChange={() => null} min={0} max={100} />, + ); + + const minInput = screen.getByLabelText("min"); + + expect(minInput).toHaveAttribute("max", "412"); + }); + + it("should call onChange with the new value on input change", () => { + const spy = jest.fn(); + render(<Slider value={[10, 20]} onChange={spy} min={0} max={100} />); + + const minInput = screen.getByLabelText("min"); + const maxInput = screen.getByLabelText("max"); + + // would be nice to use userEvent when we upgrade to v14 so we mock drage events + fireEvent.change(minInput, { target: { value: "5" } }); + fireEvent.change(maxInput, { target: { value: "15" } }); + + const [firstCall, secondCall] = spy.mock.calls; + + expect(firstCall[0]).toEqual([5, 20]); + expect(secondCall[0]).toEqual([10, 15]); + }); + + it("should sort input values on mouse up", () => { + const spy = jest.fn(); + render(<Slider value={[2, 1]} onChange={spy} min={0} max={100} />); + + const minInput = screen.getByLabelText("min"); + + fireEvent.mouseUp(minInput); + expect(spy.mock.calls[0][0]).toEqual([1, 2]); + }); +}); diff --git a/frontend/src/metabase/core/components/Slider/index.ts b/frontend/src/metabase/core/components/Slider/index.ts new file mode 100644 index 00000000000..8e1091006b3 --- /dev/null +++ b/frontend/src/metabase/core/components/Slider/index.ts @@ -0,0 +1 @@ +export { default } from "./Slider"; -- GitLab