From 186b57f29e718f0039be8e2706dd521aba590ac0 Mon Sep 17 00:00:00 2001 From: Luke Atkinson-Coyle Date: Thu, 18 Apr 2024 14:03:03 +0100 Subject: [PATCH] init input example + aria label types and refactor prettier self review + cs update tooltip css typedocs + change label prop to marks Slider Cypress / QA Tests (#3353) [WIP] Slider range (#3451) validation example update cs prettier + qa tests prevent default update mouse to pointer fix types type error explaination for type issue range slider cypress tests prettier typo update thumb tooltip behaviour --- .changeset/spotty-pants-walk.md | 6 + docs/components/ResponsiveContainer.tsx | 2 - .../__tests__/__e2e__/slider/Slider.cy.tsx | 359 ++++++++---------- packages/lab/src/slider/Slider.css | 238 ++++-------- packages/lab/src/slider/Slider.tsx | 193 ++++------ .../lab/src/slider/internal/SliderContext.ts | 23 ++ .../lab/src/slider/internal/SliderHandle.tsx | 51 --- .../src/slider/internal/SliderMarkLabels.tsx | 55 --- .../lab/src/slider/internal/SliderMarks.tsx | 35 ++ .../lab/src/slider/internal/SliderRail.tsx | 17 - .../src/slider/internal/SliderRailMarks.tsx | 61 --- .../src/slider/internal/SliderSelection.tsx | 46 ++- .../lab/src/slider/internal/SliderThumb.tsx | 70 ++++ .../lab/src/slider/internal/SliderTrack.tsx | 38 ++ packages/lab/src/slider/internal/index.ts | 3 + packages/lab/src/slider/internal/styles.ts | 116 ------ .../src/slider/internal/useKeyDownThumb.ts | 44 +++ .../slider/internal/usePointerDownThumb.ts | 59 +++ .../slider/internal/usePointerDownTrack.ts | 33 ++ .../src/slider/internal/useSliderMouseDown.ts | 128 ------- packages/lab/src/slider/internal/utils.ts | 203 ++++------ .../lab/stories/slider/slider.qa.stories.tsx | 96 +++++ .../lab/stories/slider/slider.stories.tsx | 208 ++++++++-- 23 files changed, 978 insertions(+), 1106 deletions(-) create mode 100644 .changeset/spotty-pants-walk.md create mode 100644 packages/lab/src/slider/internal/SliderContext.ts delete mode 100644 packages/lab/src/slider/internal/SliderHandle.tsx delete mode 100644 packages/lab/src/slider/internal/SliderMarkLabels.tsx create mode 100644 packages/lab/src/slider/internal/SliderMarks.tsx delete mode 100644 packages/lab/src/slider/internal/SliderRail.tsx delete mode 100644 packages/lab/src/slider/internal/SliderRailMarks.tsx create mode 100644 packages/lab/src/slider/internal/SliderThumb.tsx create mode 100644 packages/lab/src/slider/internal/SliderTrack.tsx create mode 100644 packages/lab/src/slider/internal/index.ts delete mode 100644 packages/lab/src/slider/internal/styles.ts create mode 100644 packages/lab/src/slider/internal/useKeyDownThumb.ts create mode 100644 packages/lab/src/slider/internal/usePointerDownThumb.ts create mode 100644 packages/lab/src/slider/internal/usePointerDownTrack.ts delete mode 100644 packages/lab/src/slider/internal/useSliderMouseDown.ts create mode 100644 packages/lab/stories/slider/slider.qa.stories.tsx diff --git a/.changeset/spotty-pants-walk.md b/.changeset/spotty-pants-walk.md new file mode 100644 index 00000000000..b7a96c2dda2 --- /dev/null +++ b/.changeset/spotty-pants-walk.md @@ -0,0 +1,6 @@ +--- +"@salt-ds/lab": minor +--- + +Remove `Slider` props, `pageStep`, `pushable`, `pushDistance`, `disabled`, `hideMarks` +Updated `Marks` prop to recieve inline, bottom or all diff --git a/docs/components/ResponsiveContainer.tsx b/docs/components/ResponsiveContainer.tsx index 0bb9afd6ba8..4455db12516 100644 --- a/docs/components/ResponsiveContainer.tsx +++ b/docs/components/ResponsiveContainer.tsx @@ -36,7 +36,6 @@ export const ResponsiveContainer = ({ children }: { children?: ReactNode }) => { setWidth(nextValue as number)} @@ -51,7 +50,6 @@ export const ResponsiveContainer = ({ children }: { children?: ReactNode }) => { setHeight(nextValue as number)} diff --git a/packages/lab/src/__tests__/__e2e__/slider/Slider.cy.tsx b/packages/lab/src/__tests__/__e2e__/slider/Slider.cy.tsx index 25e895b7e90..c96aacbfd36 100644 --- a/packages/lab/src/__tests__/__e2e__/slider/Slider.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/slider/Slider.cy.tsx @@ -1,202 +1,169 @@ import { Slider } from "@salt-ds/lab"; -describe("Given a Slider with a single value", () => { - it("THEN it should have ARIA roles and attributes", () => { - cy.mount( - , - ); - cy.findByRole("slider") - .should("have.attr", "aria-valuemin", "5") - .and("have.attr", "aria-valuemax", "125") - .and("have.attr", "aria-disabled", "false"); +describe("Given a Slider", () => { + describe("Given a Slider with a single value", () => { + it("THEN it should have ARIA roles and attributes", () => { + cy.mount( + + ); + cy.findByRole("slider") + .should("have.attr", "aria-valuemin", "5") + .and("have.attr", "aria-valuemax", "125") + .and("have.attr", "aria-valuenow", "100"); + }); + + it("THEN onChange should fire on pointer down on slider track", () => { + const changeSpy = cy.stub().as("changeSpy"); + cy.mount(); + cy.get(".saltSliderTrack").trigger("pointerdown", { + clientX: 50, + clientY: 50, + }); + cy.get("@changeSpy").should("have.callCount", 1); + }); + + it("THEN keyboard navigation can be used to change the slider position", () => { + const changeSpy = cy.stub().as("changeSpy"); + cy.mount( + + ); + cy.findByRole("slider").focus().realPress("ArrowRight"); + cy.findByRole("slider").should("have.attr", "aria-valuenow", "105"); + cy.get("@changeSpy").should("have.callCount", 1); + + cy.findByRole("slider").realPress("ArrowLeft"); + cy.findByRole("slider").should("have.attr", "aria-valuenow", "100"); + cy.get("@changeSpy").should("have.callCount", 2); + + cy.findByRole("slider").realPress("End"); + cy.findByRole("slider").should("have.attr", "aria-valuenow", "125"); + cy.get("@changeSpy").should("have.callCount", 3); + + cy.findByRole("slider").realPress("Home"); + cy.findByRole("slider").should("have.attr", "aria-valuenow", "5"); + cy.get("@changeSpy").should("have.callCount", 4); + }); + + it("THEN it should display a tooltip on pointerover", () => { + cy.mount(); + cy.get(".saltSliderThumb-container").trigger("pointerover"); + cy.get(".saltSliderThumb-tooltip").should("be.visible"); + + cy.get(".saltSliderThumb-container").trigger("pointerout"); + cy.get(".saltSliderThumb-tooltip").should("not.be.visible"); + }); }); - it("THEN it should respond to keyboard", () => { - const changeSpy = cy.stub().as("changeSpy"); - cy.mount( - , - ); - - cy.findByRole("slider").focus(); - cy.realPress("ArrowLeft"); - cy.get("@changeSpy") - .should("have.been.calledOnce") - .and("be.calledWith", 95); - cy.findByRole("slider").should("have.attr", "aria-valuenow", "95"); - - // Page Up/Down buttons should move the value one "pageStep" up/down - cy.findByRole("slider").realPress("PageDown"); - cy.get("@changeSpy") - .should("have.been.calledTwice") - .and("be.calledWith", 70); - - // End key should move the value to max - cy.findByRole("slider").realPress("End"); - cy.get("@changeSpy") - .should("have.been.calledThrice") - .and("be.calledWith", 125); - - // Home key should move the value to min - cy.findByRole("slider").realPress("Home"); - cy.get("@changeSpy").should("have.callCount", 4).and("be.calledWith", 5); - }); -}); - -describe("Given a Slider with a range value", () => { - it("THEN it should have ARIA roles and attributes", () => { - cy.mount( - , - ); - - cy.findByRole("group").should( - "have.attr", - "aria-label", - "TestLabel slider from -100 to 100", - ); - - cy.findAllByRole("slider").should("have.length", 2); - - cy.findAllByRole("slider") - .eq(0) - .should("have.attr", "aria-label", "Min") - .and("have.attr", "aria-valuenow", "20"); - - cy.findAllByRole("slider") - .eq(1) - .should("have.attr", "aria-label", "Max") - .and("have.attr", "aria-valuenow", "40"); - }); -}); - -describe("Given a Slider with more than 2 items in the value", () => { - it("THEN it should have ARIA roles and attributes", () => { - cy.mount( - , - ); - - cy.findByRole("group").should( - "have.attr", - "aria-label", - "TestLabel slider from -10 to 110", - ); - - cy.findAllByRole("slider").should("have.length", 3); - - cy.findAllByRole("slider") - .eq(0) - .should("have.attr", "aria-label", "First") - .and("have.attr", "aria-valuenow", "20"); - - cy.findAllByRole("slider") - .eq(1) - .should("have.attr", "aria-label", "Second") - .and("have.attr", "aria-valuenow", "40"); - - cy.findAllByRole("slider") - .eq(2) - .should("have.attr", "aria-label", "Third") - .and("have.attr", "aria-valuenow", "100"); - }); -}); - -describe("Given a pushable range slider", () => { - it("WHEN moving a handle, it should push other handles", () => { - const changeSpy = cy.stub().as("changeSpy"); - - cy.mount( - , - ); - - cy.findAllByRole("slider").should("have.length", 3); - - cy.findAllByRole("slider").eq(0).focus(); - cy.realPress("ArrowRight"); - cy.get("@changeSpy").should("have.been.calledWith", [0, 3, 7]); - - cy.realPress("ArrowRight"); - cy.get("@changeSpy").should("have.been.calledWith", [1, 4, 7]); - - cy.realPress("ArrowRight"); - cy.get("@changeSpy").should("have.been.calledWith", [2, 5, 8]); - - // Should not push beyond max - cy.realPress("ArrowRight"); - cy.get("@changeSpy").should("have.callCount", 3); - cy.findAllByRole("slider").eq(0).should("have.attr", "aria-valuenow", "2"); - cy.findAllByRole("slider").eq(1).should("have.attr", "aria-valuenow", "5"); - cy.findAllByRole("slider").eq(2).should("have.attr", "aria-valuenow", "8"); - }); -}); - -describe("Given a non-pushable range slider", () => { - it("WHEN moving a handle, it should be constrained by the handles next to it", () => { - const changeSpy = cy.stub().as("changeSpy"); - cy.mount( - , - ); - - cy.findAllByRole("slider").should("have.length", 3); - - cy.findAllByRole("slider").eq(0).focus(); - cy.realPress("PageUp"); - cy.realPress("ArrowUp"); - cy.realPress("ArrowRight"); - cy.realPress("End"); - cy.get("@changeSpy") - .should("have.been.calledOnce") - .and("been.calledWith", [3, 3, 7]); - - cy.findAllByRole("slider").eq(2).focus(); - cy.realPress("Home"); - cy.realPress("PageDown"); - cy.realPress("ArrowLeft"); - cy.realPress("ArrowDown"); - cy.get("@changeSpy") - .should("have.been.calledTwice") - .and("been.calledWith", [3, 3, 3]); + describe("Given a Slider with a range value", () => { + it("THEN it should have ARIA roles and attributes", () => { + cy.mount( + + ); + + cy.findAllByRole("slider").should("have.length", 2); + + cy.findAllByRole("slider") + .eq(0) + .should("have.attr", "aria-valuenow", "20"); + + cy.findAllByRole("slider") + .eq(1) + .should("have.attr", "aria-valuenow", "40"); + }); + + it("THEN the nearest slider thumb should move on pointer down track", () => { + const changeSpy = cy.stub().as("changeSpy"); + cy.mount( + + ); + + cy.get(".saltSliderTrack").trigger("pointerdown", { + clientX: 0, + clientY: 0, + }); + cy.get("@changeSpy").should("have.callCount", 1); + cy.findAllByRole("slider") + .eq(0) + .should("have.attr", "aria-valuenow", "0"); + cy.findAllByRole("slider") + .eq(1) + .should("have.attr", "aria-valuenow", "8"); + }); + + it("THEN slider thumbs should not cross and maintain a gap of 1 step when using keyboard nav", () => { + const changeSpy = cy.stub().as("changeSpy"); + cy.mount( + + ); + + cy.findAllByRole("slider") + .eq(0) + .focus() + .realPress("ArrowRight") + .realPress("ArrowRight") + .realPress("ArrowRight"); + // Value does not change on the final arrow right to call count remains at 2 + cy.get("@changeSpy").should("have.callCount", 2); + cy.findAllByRole("slider") + .eq(0) + .should("have.attr", "aria-valuenow", "7"); + }); + + it("THEN slider thumbs should not cross and maintain a gap of 1 step when using keyboard nav", () => { + cy.mount( + + ); + + cy.findAllByRole("slider") + .eq(0) + .trigger("pointerdown") + .trigger("pointermove", { + clientX: 1000, + clientY: 1000, + }); + + cy.findAllByRole("slider") + .eq(0) + .should("have.attr", "aria-valuenow", "4"); + }); }); }); diff --git a/packages/lab/src/slider/Slider.css b/packages/lab/src/slider/Slider.css index cf1a2024ffa..2c3cbd8b937 100644 --- a/packages/lab/src/slider/Slider.css +++ b/packages/lab/src/slider/Slider.css @@ -1,201 +1,111 @@ -.salt-density-high { - --slider-clickable-paddingTop: var(--saltSlider-clickable-paddingTop, 2px); - --slider-clickable-paddingBottom: var(--saltSlider-clickable-paddingBottom, 2px); - --slider-paddingTop: var(--saltSlider-paddingTop, 3px); - --slider-paddingBottom: var(--saltSlider-paddingBottom, 0); -} - -.salt-density-medium { - --slider-clickable-paddingTop: var(--saltSlider-clickable-paddingTop, 4px); - --slider-clickable-paddingBottom: var(--saltSlider-clickable-paddingBottom, 4px); - --slider-paddingTop: var(--saltSlider-paddingTop, 6px); - --slider-paddingBottom: var(--saltSlider-paddingBottom, 0); +.saltSlider { + display: grid; + gap: var(--salt-spacing-100); + grid-template-columns: auto 1fr auto; + user-select: none; } -.salt-density-low { - --slider-clickable-paddingTop: var(--saltSlider-clickable-paddingTop, 8px); - --slider-clickable-paddingBottom: var(--saltSlider-clickable-paddingBottom, 8px); - --slider-paddingTop: var(--saltSlider-paddingTop, 9px); - --slider-paddingBottom: var(--saltSlider-paddingBottom, 0); +.saltSlider-bottomLabel { + grid-template-columns: 1fr; } -.salt-density-touch { - --slider-clickable-paddingTop: var(--saltSlider-clickable-paddingTop, 12px); - --slider-clickable-paddingBottom: var(--saltSlider-clickable-paddingBottom, 12px); - --slider-paddingTop: var(--saltSlider-paddingTop, 12px); - --slider-paddingBottom: var(--saltSlider-paddingBottom, 0); +.saltSlider-labelMinBottom { + grid-row: 2; + grid-column: 1; + justify-self: start; + width: min-content; } -.saltSlider { - --slider-rail-height: var(--saltSlider-rail-height, 8px); - --slider-rail-color: var(--saltSlider-rail-color, var(--salt-track-borderColor)); - - --slider-rail-mark-height: var(--saltSlider-rail-mark-height, 7px); - --slider-rail-mark-color: var(--saltSlider-rail-mark-color, var(--slider-rail-color)); - - --slider-selection-color: var(--saltSlider-selection-color, var(--salt-accent-background)); - --slider-selection-height: var(--saltSlider-selection-height, 2px); - - --slider-handle-size: var(--saltSlider-handle-size, calc(var(--salt-size-base) * 0.5)); - --slider-handle-outlineStyle: var(--saltSlider-handle-outlineStyle, var(--salt-focused-outlineStyle)); - --slider-handle-outlineWidth: var(--saltSlider-handle-outlineWidth, var(--salt-focused-outlineWidth)); - --slider-handle-outlineColor: var(--saltSlider-handle-outlineColor, var(--salt-focused-outlineColor)); - --slider-handle-outlineOffset: var(--saltSlider-handle-outlineOffset, var(--salt-focused-outlineOffset)); - - --slider-arrow-height: var(--saltSlider-arrow-height, 6px); - --slider-arrow-width: var(--saltSlider-arrow-width, 8px); - --slider-arrow-color: var(--saltSlider-arrow-color, var(--slider-selection-color)); - - --slider-borderStyle: var(--saltSlider-borderStyle, none); - --slider-borderWidth: var(--saltSlider-borderWidth, 0); - --slider-borderColor: var(--saltSlider-borderColor, transparent); - --slider-width: var(--saltSlider-width, 300px); - - --slider-clickable-paddingLeft: var(--saltSlider-clickable-paddingLeft, calc(var(--salt-size-base) * 0.5)); - --slider-clickable-paddingRight: var(--saltSlider-clickable-paddingRight, calc(var(--salt-size-base) * 0.5)); - - --slider-label-fontSize: var(--saltSlider-label-fontSize, var(--salt-text-label-fontSize)); - --slider-label-paddingLeft: var(--saltSlider-label-paddingLeft, 0); - --slider-label-paddingRight: var(--saltSlider-label-paddingRight, 0); +.saltSlider-labelMaxBottom { + grid-row: 2; + grid-column: 1; + justify-self: end; + width: min-content; } -.saltSlider { - width: var(--slider-width); - border-style: var(--slider-borderStyle); - border-width: var(--slider-borderWidth); - border-color: var(--slider-borderColor); - +.saltSliderTrack { display: flex; - flex-direction: column; - align-items: stretch; - - padding-top: var(--slider-paddingTop); - padding-bottom: var(--slider-paddingBottom); + position: relative; + cursor: pointer; + align-items: center; } -.saltSlider-disabled { +.saltSliderTrack-rail { + height: var(--salt-size-bar); + background: var(--salt-track-borderColor); + width: 100%; + position: absolute; } -.saltSlider-clickable { - padding: var(--slider-clickable-paddingTop) var(--slider-clickable-paddingRight) var(--slider-clickable-paddingBottom) var(--slider-clickable-paddingLeft); - margin-right: calc(-1 * var(--slider-clickable-paddingRight)); - margin-left: calc(-1 * var(--slider-clickable-paddingLeft)); +.saltSliderTrack:active { + cursor: grabbing; +} +.saltSliderThumb-container { + position: absolute; display: flex; - flex-direction: column; + align-items: center; justify-content: center; - align-items: stretch; + transform: translate(-50%, 0%); + width: var(--salt-size-base); + height: var(--salt-size-base); } -.saltSlider-track { - display: grid; - grid-template-rows: auto auto auto; - align-items: end; - grid-template-columns: auto auto auto; - row-gap: 0; - transition: grid-template-columns 100ms ease; +.saltSliderThumb-container:hover > .saltSliderThumb-tooltip { + display: block; } -.saltSliderRail { - grid-row: 1; - grid-column-start: 1; - grid-column-end: -1; - height: var(--slider-rail-height); - border-style: solid; - border-width: 0 1px 1px 1px; - border-color: var(--slider-rail-color); +.saltSliderThumb-tooltip { + background: var(--salt-container-primary-background); + border-color: var(--salt-status-info-borderColor); + border-style: var(--salt-container-borderStyle); + border-width: var(--salt-size-border); + line-height: var(--salt-text-lineHeight); + box-shadow: var(--salt-overlayable-shadow-popout); + color: var(--salt-content-primary-foreground); + max-width: var(--saltTooltip-maxWidth, 230px); + padding: var(--saltTooltip-padding, var(--salt-size-unit)); + position: absolute; + z-index: var(--salt-zIndex-flyover); + transform: translate(0, -100%); + display: none; } -.saltSliderRailMarks { - grid-row: 2; - grid-column-start: 1; - grid-column-end: -1; - /*height: var(--markedRail-height);*/ - display: grid; - grid-template-rows: auto; +.saltSliderThumb-showTooltip { + display: block; } -.saltSliderRailMarks-mark { - grid-row: 1; - width: 0; - height: var(--slider-rail-mark-height); - border-left: 1px solid var(--slider-rail-mark-color); -} - -.saltSliderRailMarks-max { - margin-left: -1px; -} - -.saltSliderMarkLabels { - grid-row: 3; - grid-column-start: 1; - grid-column-end: -1; - - display: grid; - grid-template-rows: auto; - justify-items: center; +.saltSliderThumb { + position: relative; + width: var(--salt-size-indicator); + height: var(--salt-size-selectable); + background: var(--salt-accent-borderColor); } -.saltSliderMarkLabels-label { - color: var(--saltSlider-label-text-color, var(--salt-content-secondary-foreground)); - font-size: var(--slider-label-fontSize); - margin-top: var(--saltSlider-label-marginTop); - line-height: var(--saltSlider-label-lineHeight, var(--salt-text-lineHeight)); - - white-space: nowrap; +.saltSliderThumb:focus-visible { + outline-style: var(--salt-focused-outlineStyle); + outline-width: var(--salt-focused-outlineWidth); + outline-offset: var(--salt-focused-outlineOffset); + outline-color: var(--salt-focused-outlineColor); } .saltSliderSelection { - grid-row: 1; - grid-column-start: 1; - grid-column-end: -2; - height: var(--slider-selection-height); - background: var(--slider-selection-color); + height: var(--salt-size-bar); + background: var(--salt-accent-borderColor); + align-items: start; + position: absolute; } .saltSliderSelection-range { - grid-row: 1; - grid-column-start: 2; - grid-column-end: -2; - height: var(--slider-selection-height); - background: var(--slider-selection-color); -} - -.saltSliderHandle-box:focus-visible { - outline-style: var(--slider-handle-outlineStyle); - outline-width: var(--slider-handle-outlineWidth); - outline-color: var(--slider-handle-outlineColor); - outline-offset: var(--slider-handle-outlineOffset); -} - -.saltSliderHandle { - margin-left: calc(var(--slider-arrow-width) * -0.5); - grid-row: 1; - width: 0; - height: 0; - border-left: calc(var(--slider-arrow-width) * 0.5) solid transparent; - border-right: calc(var(--slider-arrow-width) * 0.5) solid transparent; - border-bottom: calc(var(--slider-arrow-height)) solid var(--slider-arrow-color); - position: relative; + align-items: center; } -.saltSliderHandle-min { - border-left: none; - margin-left: 0; -} - -.saltSliderHandle-max { - border-right: none; +.saltSliderMarks { + position: relative; } -.saltSlider-label { - color: var(--saltSlider-label-text-color, var(--salt-content-secondary-foreground)); - font-size: var(--slider-label-fontSize); - margin-top: var(--saltSlider-label-marginTop); - line-height: var(--saltSlider-label-lineHeight, var(--salt-text-lineHeight)); - - padding-left: var(--slider-label-paddingLeft); - padding-right: var(--slider-label-paddingRight); - white-space: nowrap; - text-overflow: ellipsis; +.saltSliderMarks-mark { + position: absolute; + transform: translate(-50%); + line-height: var(--salt-text-lineHeight); } diff --git a/packages/lab/src/slider/Slider.tsx b/packages/lab/src/slider/Slider.tsx index 851222e8363..68c95752a16 100644 --- a/packages/lab/src/slider/Slider.tsx +++ b/packages/lab/src/slider/Slider.tsx @@ -1,50 +1,49 @@ -import { makePrefixer, useControlled } from "@salt-ds/core"; +import { Label, makePrefixer, useControlled } from "@salt-ds/core"; import { clsx } from "clsx"; -import { - type CSSProperties, - type HTMLAttributes, - forwardRef, - useMemo, - useRef, -} from "react"; -import { SliderHandle } from "./internal/SliderHandle"; -import { SliderMarkLabels } from "./internal/SliderMarkLabels"; -import { SliderRail } from "./internal/SliderRail"; -import { type SliderMark, SliderRailMarks } from "./internal/SliderRailMarks"; -import { SliderSelection } from "./internal/SliderSelection"; -import { createHandleStyles, createTrackStyle } from "./internal/styles"; -import { useSliderKeyDown } from "./internal/useSliderKeyDown"; -import { useSliderMouseDown } from "./internal/useSliderMouseDown"; -import { useValueUpdater } from "./internal/utils"; -import type { SliderChangeHandler, SliderValue } from "./types"; - -import { useComponentCssInjection } from "@salt-ds/styles"; +import { forwardRef, HTMLAttributes } from "react"; import { useWindow } from "@salt-ds/window"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { SliderTrack, SliderMarks, SliderContext } from "./internal"; import sliderCss from "./Slider.css"; +import { SliderChangeHandler, SliderValue } from "./types"; const withBaseName = makePrefixer("saltSlider"); const defaultMin = 0; -const defaultMax = 100; +const defaultMax = 10; const defaultStep = 1; export interface SliderProps extends Omit, "onChange" | "defaultValue"> { - label?: string; + /** + * Minimum slider value + */ min?: number; + /** + * Maximum slider value + */ max?: number; + /** + * Minimum interval the slider thumb can move + */ step?: number; - pageStep?: number; - value?: SliderValue; + /** + * Initial value of the slider + */ defaultValue?: SliderValue; - pushable?: boolean; - pushDistance?: number; - disabled?: boolean; + /** + * The markings the slider is displayed with + */ + marks?: "inline" | "bottom" | "all"; + /** + * Value of the slider, to be used when in a controlled state + */ + value?: SliderValue; + /** + * Change handler to be used when in a controlled state + */ onChange?: SliderChangeHandler; - marks?: SliderMark[]; - hideMarks?: boolean; - hideMarkLabels?: boolean; } export const Slider = forwardRef(function Slider( @@ -52,19 +51,13 @@ export const Slider = forwardRef(function Slider( min = defaultMin, max = defaultMax, step = defaultStep, - pageStep = step, value: valueProp, defaultValue = defaultMin, onChange, - label, className, - pushable, - pushDistance = 0, - disabled, - marks, - hideMarks, - hideMarkLabels, - ...restProps + ["aria-label"]: ariaLabel, + marks = "inline", + ...rest }, ref, ) { @@ -75,8 +68,6 @@ export const Slider = forwardRef(function Slider( window: targetWindow, }); - const trackRef = useRef(null); - const [value, setValue] = useControlled({ controlled: valueProp, default: defaultValue, @@ -84,90 +75,52 @@ export const Slider = forwardRef(function Slider( state: "Value", }); - const updateValueItem = useValueUpdater(pushable, pushDistance, min, max); - - const trackStyle = useMemo( - () => createTrackStyle(min, max, value), - [min, max, value], - ); - - const valueLength = Array.isArray(value) ? value.length : 1; - - const handleStyles: CSSProperties[] = useMemo( - () => createHandleStyles(valueLength), - [valueLength], - ); - - const onMouseDown = useSliderMouseDown( - trackRef, - value, - min, - max, - step, - updateValueItem, - setValue, - onChange, - ); - - const onKeyDown = useSliderKeyDown( - value, - min, - max, - pageStep, - step, - updateValueItem, - setValue, - onChange, - ); + const handleSliderChange = (value: SliderValue) => { + setValue(value); + onChange?.(value); + }; return ( -
- {label !== undefined ? ( -
{label}
- ) : null}
-
- - {marks && !hideMarks ? ( - - ) : null} - {marks && !hideMarkLabels ? ( - - ) : null} - - {(Array.isArray(value) ? value : [value]).map((v, i) => ( - - ))} -
+ {marks !== "all" && ( + + )} + + {marks !== "all" && ( + + )} + {marks === "all" && }
-
+ ); }); diff --git a/packages/lab/src/slider/internal/SliderContext.ts b/packages/lab/src/slider/internal/SliderContext.ts new file mode 100644 index 00000000000..c951d5061df --- /dev/null +++ b/packages/lab/src/slider/internal/SliderContext.ts @@ -0,0 +1,23 @@ +import { useContext, createContext } from "react"; +import { SliderChangeHandler, SliderValue } from "../types"; +export interface SliderContextValue { + min: number; + max: number; + step: number; + value: SliderValue; + onChange: SliderChangeHandler; + ariaLabel: string | undefined; +} + +export const SliderContext = createContext({ + min: 0, + max: 10, + step: 1, + value: 0, + onChange: () => null, + ariaLabel: "slider", +}); + +export function useSliderContext() { + return useContext(SliderContext); +} diff --git a/packages/lab/src/slider/internal/SliderHandle.tsx b/packages/lab/src/slider/internal/SliderHandle.tsx deleted file mode 100644 index 08ae865b11a..00000000000 --- a/packages/lab/src/slider/internal/SliderHandle.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Tooltip, makePrefixer } from "@salt-ds/core"; -import { clsx } from "clsx"; -import type { CSSProperties } from "react"; -import { getSliderAriaLabel } from "./utils"; - -import { useComponentCssInjection } from "@salt-ds/styles"; -import { useWindow } from "@salt-ds/window"; -import sliderCss from "../Slider.css"; - -const withBaseName = makePrefixer("saltSliderHandle"); - -export interface SliderHandleProps { - min: number; - max: number; - value: number; - index: number; - disabled: boolean; - valueLength: number; - style: CSSProperties; -} - -export function SliderHandle(props: SliderHandleProps): JSX.Element { - const { min, max, value, disabled, valueLength, index, style } = props; - - const targetWindow = useWindow(); - useComponentCssInjection({ - testId: "salt-slider", - css: sliderCss, - window: targetWindow, - }); - - return ( - -
- - ); -} diff --git a/packages/lab/src/slider/internal/SliderMarkLabels.tsx b/packages/lab/src/slider/internal/SliderMarkLabels.tsx deleted file mode 100644 index d6d4fe7f0ed..00000000000 --- a/packages/lab/src/slider/internal/SliderMarkLabels.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { makePrefixer } from "@salt-ds/core"; -import { useMemo } from "react"; -import type { SliderMark } from "./SliderRailMarks"; -import { - createSliderMarkLabelStyles, - createSliderMarkLabelsStyle, -} from "./styles"; -import { isLabeledMark } from "./utils"; - -import { useComponentCssInjection } from "@salt-ds/styles"; -import { useWindow } from "@salt-ds/window"; -import sliderCss from "../Slider.css"; - -const withBaseName = makePrefixer("saltSliderMarkLabels"); - -export interface SliderMarkLabelsProps { - min: number; - max: number; - marks: SliderMark[]; -} - -export function SliderMarkLabels(props: SliderMarkLabelsProps) { - const { min, max, marks } = props; - - const targetWindow = useWindow(); - useComponentCssInjection({ - testId: "salt-slider", - css: sliderCss, - window: targetWindow, - }); - - const style = useMemo( - () => createSliderMarkLabelsStyle(min, max, marks), - [min, max, marks], - ); - const labelStyles = useMemo( - () => createSliderMarkLabelStyles(marks), - [marks], - ); - return ( -
- {marks.map((mark, i) => { - return ( -
- {isLabeledMark(mark) ? mark.label : `${mark}`} -
- ); - })} -
- ); -} diff --git a/packages/lab/src/slider/internal/SliderMarks.tsx b/packages/lab/src/slider/internal/SliderMarks.tsx new file mode 100644 index 00000000000..1ea983d46e2 --- /dev/null +++ b/packages/lab/src/slider/internal/SliderMarks.tsx @@ -0,0 +1,35 @@ +import { Label, makePrefixer } from "@salt-ds/core"; +import { ComponentPropsWithoutRef } from "react"; +import { getMarkStyles } from "./utils"; + +const withBaseName = makePrefixer("saltSliderMarks"); + +export interface SliderMarksProps extends ComponentPropsWithoutRef<"div"> { + min: number; + max: number; + step: number; +} + +export function SliderMarks({ + min, + max, + step, + ...rest +}: SliderMarksProps): JSX.Element { + const marks = getMarkStyles(min, max, step); + return ( +
+ {marks.map((mark) => { + return ( + + ); + })} +
+ ); +} diff --git a/packages/lab/src/slider/internal/SliderRail.tsx b/packages/lab/src/slider/internal/SliderRail.tsx deleted file mode 100644 index b91e165242b..00000000000 --- a/packages/lab/src/slider/internal/SliderRail.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { makePrefixer } from "@salt-ds/core"; - -import { useComponentCssInjection } from "@salt-ds/styles"; -import { useWindow } from "@salt-ds/window"; -import sliderCss from "../Slider.css"; - -const withBaseName = makePrefixer("saltSliderRail"); - -export function SliderRail() { - const targetWindow = useWindow(); - useComponentCssInjection({ - testId: "salt-slider", - css: sliderCss, - window: targetWindow, - }); - return
; -} diff --git a/packages/lab/src/slider/internal/SliderRailMarks.tsx b/packages/lab/src/slider/internal/SliderRailMarks.tsx deleted file mode 100644 index bb19d63a4ca..00000000000 --- a/packages/lab/src/slider/internal/SliderRailMarks.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { makePrefixer } from "@salt-ds/core"; -import { clsx } from "clsx"; -import { type ReactNode, useMemo } from "react"; -import { createHandleStyles, createSliderRailMarksStyle } from "./styles"; -import { isLabeledMark, isMarkAtMax } from "./utils"; - -import { useComponentCssInjection } from "@salt-ds/styles"; -import { useWindow } from "@salt-ds/window"; -import sliderCss from "../Slider.css"; - -const withBaseName = makePrefixer("saltSliderRailMarks"); - -export interface LabeledMark { - value: number; - label: ReactNode; -} - -export type SliderMark = number | LabeledMark; - -export interface SliderRailMarksProps { - min: number; - max: number; - marks: SliderMark[]; -} - -export function SliderRailMarks(props: SliderRailMarksProps) { - const { min, max, marks } = props; - - const targetWindow = useWindow(); - useComponentCssInjection({ - testId: "salt-slider", - css: sliderCss, - window: targetWindow, - }); - - const style = useMemo( - () => createSliderRailMarksStyle(min, max, marks), - [min, max, marks], - ); - const marksLength = marks.length; - const markStyles = useMemo( - () => createHandleStyles(marksLength), - [marksLength], - ); - - return ( -
- {marks.map((mark, i) => { - return ( -
- ); - })} -
- ); -} diff --git a/packages/lab/src/slider/internal/SliderSelection.tsx b/packages/lab/src/slider/internal/SliderSelection.tsx index b552cad1fd0..8e7f522357f 100644 --- a/packages/lab/src/slider/internal/SliderSelection.tsx +++ b/packages/lab/src/slider/internal/SliderSelection.tsx @@ -1,24 +1,40 @@ import { makePrefixer } from "@salt-ds/core"; - -import { useComponentCssInjection } from "@salt-ds/styles"; -import { useWindow } from "@salt-ds/window"; -import sliderCss from "../Slider.css"; +import { ComponentPropsWithoutRef } from "react"; +import { + getPercentage, + getPercentageDifference, + getPercentageOffset, +} from "./utils"; +import { useSliderContext } from "./SliderContext"; +import { clsx } from "clsx"; const withBaseName = makePrefixer("saltSliderSelection"); -export interface SliderSelectionProps { - valueLength: number; -} +export interface SliderSelectionProps extends ComponentPropsWithoutRef<"div"> {} + +export function SliderSelection({ + ...props +}: SliderSelectionProps): JSX.Element { + const { min, max, value } = useSliderContext(); + + const percentageDifference = Array.isArray(value) + ? getPercentageDifference(min, max, value) + : getPercentage(min, max, value); -export function SliderSelection({ valueLength }: SliderSelectionProps) { - const targetWindow = useWindow(); - useComponentCssInjection({ - testId: "salt-slider", - css: sliderCss, - window: targetWindow, - }); + const percentageOffset = Array.isArray(value) + ? getPercentageOffset(min, max, value) + : 0; return ( -
+
); } diff --git a/packages/lab/src/slider/internal/SliderThumb.tsx b/packages/lab/src/slider/internal/SliderThumb.tsx new file mode 100644 index 00000000000..bc86d6b44de --- /dev/null +++ b/packages/lab/src/slider/internal/SliderThumb.tsx @@ -0,0 +1,70 @@ +import { makePrefixer, Label } from "@salt-ds/core"; +import { clsx } from "clsx"; +import { getPercentage } from "./utils"; +import { ComponentPropsWithoutRef, RefObject } from "react"; +import { usePointerDownThumb } from "./usePointerDownThumb"; +import { useKeyDownThumb } from "./useKeyDownThumb"; +import { useSliderContext } from "./SliderContext"; + +const withBaseName = makePrefixer("saltSliderThumb"); + +export interface SliderThumbProps extends ComponentPropsWithoutRef<"div"> { + trackRef: RefObject; + index: number; +} + +export function SliderThumb(props: SliderThumbProps): JSX.Element { + const { trackRef, index, ...rest } = props; + + const { min, max, step, value, onChange, ariaLabel } = useSliderContext(); + + const onKeyDown = useKeyDownThumb(min, max, step, value, onChange, index); + + const { thumbProps, thumbActive } = usePointerDownThumb( + trackRef, + min, + max, + step, + value, + onChange, + index + ); + + const percentage = Array.isArray(value) + ? index + ? getPercentage(min, max, value[1]) + : getPercentage(min, max, value[0]) + : getPercentage(min, max, value); + + return ( +
+
+ {Array.isArray(value) && } + {!Array.isArray(value) && } +
+
+
+ ); +} diff --git a/packages/lab/src/slider/internal/SliderTrack.tsx b/packages/lab/src/slider/internal/SliderTrack.tsx new file mode 100644 index 00000000000..7f749bcdd0e --- /dev/null +++ b/packages/lab/src/slider/internal/SliderTrack.tsx @@ -0,0 +1,38 @@ +import { makePrefixer } from "@salt-ds/core"; +import { useRef, ComponentPropsWithoutRef } from "react"; + +import { SliderSelection } from "./SliderSelection"; +import { SliderThumb } from "./SliderThumb"; +import { useSliderContext } from "./SliderContext"; +import { usePointerDownTrack } from "./usePointerDownTrack"; + +export interface SliderTrackProps extends ComponentPropsWithoutRef<"div"> {} + +const withBaseName = makePrefixer("saltSliderTrack"); + +export const SliderTrack = ({ ...props }: SliderTrackProps) => { + const { min, max, step, value, onChange } = useSliderContext(); + + const trackRef = useRef(null); + + const { trackProps } = usePointerDownTrack( + trackRef, + min, + max, + step, + value, + onChange + ); + + const thumbs = Array.isArray(value) ? value : [value]; + + return ( +
+
+ + {thumbs.map((value, i) => { + return ; + })} +
+ ); +}; diff --git a/packages/lab/src/slider/internal/index.ts b/packages/lab/src/slider/internal/index.ts new file mode 100644 index 00000000000..6ba1b1e0f3e --- /dev/null +++ b/packages/lab/src/slider/internal/index.ts @@ -0,0 +1,3 @@ +export * from "./SliderTrack"; +export * from "./SliderMarks"; +export * from "./SliderContext"; diff --git a/packages/lab/src/slider/internal/styles.ts b/packages/lab/src/slider/internal/styles.ts deleted file mode 100644 index b8ae3ad725e..00000000000 --- a/packages/lab/src/slider/internal/styles.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { CSSProperties } from "react"; -import type { SliderValue } from "../types"; -import type { SliderMark } from "./SliderRailMarks"; -import { isLabeledMark } from "./utils"; - -function widthToPercentage(w: number, range: number) { - return `${Math.round((1000 * w) / range) * 0.1}%`; -} - -function createGridTemplateColumns( - min: number, - max: number, - values: number[], -): string { - const range = max - min; - const colWidths: number[] = []; - let prev = min; - for (const v of values) { - colWidths.push(v - prev); - prev = v; - } - colWidths.push(max - prev); - let auto = false; - const colTemplates = colWidths.map((w) => { - if (w === 0) { - return "0"; - } - if (!auto) { - auto = true; - return "auto"; - } - return widthToPercentage(w, range); - }); - return colTemplates.join(" "); -} - -export function createTrackStyle( - min: number, - max: number, - value: SliderValue, -): CSSProperties { - const values = Array.isArray(value) ? value : [value]; - return { - gridTemplateColumns: createGridTemplateColumns(min, max, values), - }; -} - -export function createHandleStyles(count: number) { - return [...Array(count).keys()].map((i) => ({ - gridColumnStart: `${i + 2}`, - })); -} - -export function createSliderRailMarksStyle( - min: number, - max: number, - marks: SliderMark[], -): CSSProperties { - return { - gridTemplateColumns: createGridTemplateColumns( - min, - max, - marks.map((mark) => (isLabeledMark(mark) ? mark.value : mark)), - ), - }; -} - -export function createSliderMarkLabelsStyle( - min: number, - max: number, - marks: SliderMark[], -): CSSProperties { - const range = max - min; - const colWidths: number[] = []; - let prev = min; - for (const m of marks) { - const w = isLabeledMark(m) ? m.value : m; - colWidths.push(w - prev); - colWidths.push(0); - prev = w; - } - colWidths.push(max - prev); - let auto = false; - const colTemplates = colWidths.map((w) => { - if (w === 0) { - return "0"; - } - if (!auto) { - auto = true; - return "auto"; - } - return widthToPercentage(w, range); - }); - - return { - gridTemplateColumns: colTemplates.join(" "), - }; -} - -export function createSliderMarkLabelStyles( - marks: SliderMark[], -): CSSProperties[] { - const styles: CSSProperties[] = []; - marks.forEach((mark, i) => { - styles.push({ - gridColumnStart: 2 * i + 2, - }); - }); - if (marks.length > 0) { - styles[0].justifySelf = "left"; - } - if (marks.length > 1) { - styles[marks.length - 1].justifySelf = "right"; - } - return styles; -} diff --git a/packages/lab/src/slider/internal/useKeyDownThumb.ts b/packages/lab/src/slider/internal/useKeyDownThumb.ts new file mode 100644 index 00000000000..dc626033c9b --- /dev/null +++ b/packages/lab/src/slider/internal/useKeyDownThumb.ts @@ -0,0 +1,44 @@ +import { SliderChangeHandler, SliderValue } from "../types"; +import { clampValue, roundToStep, roundToTwoDp, setRangeValue } from "./utils"; + +export function useKeyDownThumb( + min: number, + max: number, + step: number, + value: SliderValue, + onChange: SliderChangeHandler, + index: number +) { + return (event: React.KeyboardEvent) => { + let valueItem: number = Array.isArray(value) + ? index + ? value[1] + : value[0] + : value; + switch (event.key) { + case "Home": + valueItem = min; + break; + case "End": + valueItem = max; + break; + case "ArrowUp": + case "ArrowRight": + valueItem += step; + break; + case "ArrowDown": + case "ArrowLeft": + valueItem -= step; + break; + default: + return; + } + valueItem = roundToStep(valueItem, step); + valueItem = roundToTwoDp(valueItem); + valueItem = clampValue(valueItem, min, max); + + Array.isArray(value) + ? setRangeValue(value, valueItem, onChange, index, step) + : onChange?.(valueItem); + }; +} diff --git a/packages/lab/src/slider/internal/usePointerDownThumb.ts b/packages/lab/src/slider/internal/usePointerDownThumb.ts new file mode 100644 index 00000000000..eae4cb201f2 --- /dev/null +++ b/packages/lab/src/slider/internal/usePointerDownThumb.ts @@ -0,0 +1,59 @@ +import { RefObject, useState } from "react"; +import { SliderValue, SliderChangeHandler } from "../types"; +import { getValue, setRangeValue } from "./utils"; + +export function usePointerDownThumb( + trackRef: RefObject, + min: number, + max: number, + step: number, + value: SliderValue, + onChange: SliderChangeHandler, + index: number +) { + const [thumbActive, setThumbActive] = useState(false); + + const onDownThumb = () => { + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + setThumbActive(true); + }; + + const onPointerUp = (event: PointerEvent) => { + event.preventDefault(); + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + setThumbActive(false); + }; + + const onPointerMove = (event: PointerEvent): void => { + const newValue: number | undefined = getValue( + trackRef, + min, + max, + step, + event + ); + Array.isArray(value) + ? setRangeValue(value, newValue, onChange, index, step) + : onChange?.(newValue); + }; + + return { + thumbProps: { + onPointerDown() { + onDownThumb(); + }, + onFocus() { + setThumbActive(true); + }, + onPointerUp() { + setThumbActive(false); + }, + onBlur() { + setThumbActive(false); + }, + }, + thumbActive, + }; +} diff --git a/packages/lab/src/slider/internal/usePointerDownTrack.ts b/packages/lab/src/slider/internal/usePointerDownTrack.ts new file mode 100644 index 00000000000..5d308a7854f --- /dev/null +++ b/packages/lab/src/slider/internal/usePointerDownTrack.ts @@ -0,0 +1,33 @@ +import { RefObject, MouseEvent } from "react"; +import { getValue } from "./utils"; +import { SliderChangeHandler, SliderValue } from "../types"; + +export function usePointerDownTrack( + trackRef: RefObject, + min: number, + max: number, + step: number, + value: SliderValue, + onChange: SliderChangeHandler | undefined +) { + return { + trackProps: { + onPointerDown(event: MouseEvent) { + //@ts-ignore - React MouseEvent not compatible with global mouse event, causing type error on SliderTrack + const newValue: number = getValue(trackRef, min, max, step, event); + if (Array.isArray(value)) { + const nearestThumb = + Math.abs(value[0] - newValue) < Math.abs(value[1] - newValue) + ? 0 + : 1; + + nearestThumb + ? onChange?.([value[0], newValue]) + : onChange?.([newValue, value[1]]); + } else { + onChange?.(newValue); + } + }, + }, + }; +} diff --git a/packages/lab/src/slider/internal/useSliderMouseDown.ts b/packages/lab/src/slider/internal/useSliderMouseDown.ts deleted file mode 100644 index 9c3e7283a9a..00000000000 --- a/packages/lab/src/slider/internal/useSliderMouseDown.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - type MouseEvent as ReactMouseEvent, - type RefObject, - useCallback, - useEffect, - useRef, -} from "react"; -import type { SliderChangeHandler, SliderValue } from "../types"; -import { type UpdateValueItem, clampValue, roundValue } from "./utils"; - -interface MouseContext { - min: number; - max: number; - step: number; - value: SliderValue; - trackRef: RefObject; - handleIndex?: number; - updateValueItem: UpdateValueItem; - setValue: SliderChangeHandler; - onChange?: SliderChangeHandler; -} - -const valueFromClientX = (context: MouseContext, x: number) => { - const { min, max, step, trackRef } = context; - const rect = trackRef.current!.getBoundingClientRect(); - const localX = x - rect.x; - let v = (localX / rect.width) * (max - min) + min; - v = roundValue(v, step); - v = clampValue(v, min, max); - return v; -}; - -function getNearestHandle(value: SliderValue, clickValue: number): number { - if (!Array.isArray(value)) { - return 0; - } - let minDistance = Number.MAX_VALUE; - let handleIndex = -1; - value.forEach((v, i) => { - const d = Math.abs(clickValue - v); - if (d < minDistance) { - minDistance = d; - handleIndex = i; - } - }); - return handleIndex; -} - -export function useSliderMouseDown( - trackRef: RefObject, - value: SliderValue, - min: number, - max: number, - step: number, - updateValueItem: UpdateValueItem, - setValue: SliderChangeHandler, - onChange?: SliderChangeHandler, -) { - const mouseContext = useRef({ - min, - max, - step, - value, - trackRef, - updateValueItem, - setValue, - onChange, - }); - - useEffect(() => { - const c = mouseContext.current; - c.min = min; - c.max = max; - c.step = step; - c.value = value; - c.updateValueItem = updateValueItem; - c.onChange = onChange; - c.setValue = setValue; - }, [min, max, step, value, setValue, updateValueItem, onChange]); - - const onMouseMove = useCallback((event: MouseEvent) => { - const { handleIndex, value, updateValueItem, setValue, onChange } = - mouseContext.current; - if (handleIndex === undefined) { - return; - } - const { clientX } = event; - const clickValue = valueFromClientX(mouseContext.current, clientX); - const newValue = updateValueItem(value, handleIndex, clickValue); - if (newValue !== value) { - setValue(newValue); - if (onChange) { - onChange(newValue); - } - } - }, []); - - const onMouseUp = useCallback(() => { - document.removeEventListener("mouseup", onMouseUp); - document.removeEventListener("mousemove", onMouseMove); - mouseContext.current.handleIndex = undefined; - }, [onMouseMove]); - - return useCallback( - (event: ReactMouseEvent) => { - const { value, setValue, onChange } = mouseContext.current; - document.addEventListener("mouseup", onMouseUp); - document.addEventListener("mousemove", onMouseMove); - - const { clientX } = event; - const clickValue = valueFromClientX(mouseContext.current, clientX); - - const handleIndex = getNearestHandle(value, clickValue); - mouseContext.current.handleIndex = handleIndex; - const newValue = updateValueItem(value, handleIndex, clickValue); - - if (newValue !== value) { - setValue(newValue); - if (onChange) { - onChange(newValue); - } - } - - event.preventDefault(); - }, - [onMouseMove, onMouseUp], - ); -} diff --git a/packages/lab/src/slider/internal/utils.ts b/packages/lab/src/slider/internal/utils.ts index 20e3144d821..0f9f2f661c5 100644 --- a/packages/lab/src/slider/internal/utils.ts +++ b/packages/lab/src/slider/internal/utils.ts @@ -1,158 +1,83 @@ -import { useMemo } from "react"; -import type { SliderValue } from "../types"; -import type { LabeledMark, SliderMark } from "./SliderRailMarks"; +import { SliderChangeHandler } from "../types"; +import { RefObject } from "react"; -const updateValueItemNotPushable = ( - oldValue: number[], - index: number, - valueItem: number, - min: number, - max: number, -) => { - const newValue = [...oldValue]; - if (valueItem < oldValue[index]) { - const constraint = index === 0 ? min : newValue[index - 1]; - newValue[index] = Math.max(constraint, valueItem); - } else { - const constraint = - index === newValue.length - 1 ? max : newValue[index + 1]; - newValue[index] = Math.min(constraint, valueItem); - } - return newValue; -}; - -const updateValueItemPushable = ( - oldValue: number[], - index: number, - valueItem: number, +export function getValue( + trackRef: RefObject, min: number, max: number, - pushDistance: number, -) => { - const newValue = [...oldValue]; - newValue[index] = valueItem; - if (valueItem < oldValue[index]) { - for (let i = index - 1; i >= 0; --i) { - if (newValue[i + 1] - newValue[i] < pushDistance) { - newValue[i] = newValue[i + 1] - pushDistance; - } else { - break; - } - } - const distToMin = newValue[0] - min; - if (distToMin < 0) { - for (let i = index; i >= 0; --i) { - newValue[i] -= distToMin; - } - } - } else { - for (let i = index + 1; i < newValue.length; ++i) { - if (newValue[i] - newValue[i - 1] < pushDistance) { - newValue[i] = newValue[i - 1] + pushDistance; - } else { - break; - } - } - const distToMax = max - newValue[newValue.length - 1]; - if (distToMax < 0) { - for (let i = index; i < newValue.length; ++i) { - newValue[i] += distToMax; - } - } - } - return newValue; -}; + step: number, + event: MouseEvent +) { + const { clientX } = event; + const { width, x } = trackRef.current!.getBoundingClientRect(); + const localX = clientX - x; + const normaliseBetweenValues = (localX / width) * (max - min) + min; + let value = roundToStep(normaliseBetweenValues, step); + value = roundToTwoDp(value); + value = clampValue(value, min, max); + return value; +} -export type UpdateValueItem = ( - oldValue: SliderValue, +export function setRangeValue( + value: number[], + newValue: number, + onChange: SliderChangeHandler, index: number, - valueItem: number, -) => SliderValue; - -export function useValueUpdater( - pushable: boolean | undefined, - pushDistance: number, - min: number, - max: number, -): UpdateValueItem { - return useMemo(() => { - const updater = pushable - ? (oldValue: number[], index: number, valueItem: number) => - updateValueItemPushable( - oldValue, - index, - valueItem, - min, - max, - pushDistance, - ) - : (oldValue: number[], index: number, valueItem: number) => - updateValueItemNotPushable(oldValue, index, valueItem, min, max); - return (oldValue: SliderValue, index: number, valueItem: number) => { - if (!Array.isArray(oldValue)) { - return valueItem; - } - if (oldValue[index] === valueItem) { - return oldValue; - } - const newValue = updater(oldValue, index, valueItem); - if (-1 === newValue.findIndex((v, i) => oldValue[i] !== v)) { - return oldValue; - } - return newValue; - }; - }, [pushable, pushDistance, min, max]); + step: number +) { + if ( + Math.abs(value[0] - newValue) < step || + Math.abs(value[1] - newValue) < step + ) + return; + if (index === 0 && newValue > value[1]) + return onChange([value[1] - step, value[1]]); + if (index === 1 && newValue < value[0]) + return onChange([value[0], value[0] + step]); + index ? onChange?.([value[0], newValue]) : onChange?.([newValue, value[1]]); } -export const roundValue = (v: number, step: number) => - Math.round(v / step) * step; +export const roundToTwoDp = (value: number) => Math.round(value * 100) / 100; -export const clampValue = (v: number, min: number, max: number) => { - if (v < min) { - return min; - } - if (v > max) { +export const roundToStep = (value: number, step: number) => + Math.round(value / step) * step; + +export const clampValue = (value: number, min: number, max: number) => { + if (value > max) { return max; } - return v; + if (value < min) { + return min; + } + return value; }; -export function getSliderAriaLabel(count: number, index: number) { - if (count < 2) { - return; - } - if (count === 2) { - return index === 0 ? "Min" : "Max"; - } - if (index >= 0 && index < 10) { - return [ - "First", - "Second", - "Third", - "Fourth", - "Fifth", - "Sixth", - "Seventh", - "Eighth", - "Ninth", - "Tenth", - ][index]; - } - return; +export function getPercentage(min: number, max: number, value: number) { + const percentage = ((value - min) / (max - min)) * 100; + return `${Math.min(Math.max(percentage, 0), 100)}%`; } -export function getHandleIndex(element: HTMLElement): number { - const handleIndexAttribute = element.getAttribute("data-handle-index"); - if (handleIndexAttribute) { - return Number.parseInt(handleIndexAttribute, 10); - } - return getHandleIndex(element.parentElement as HTMLElement); +export function getPercentageDifference( + min: number, + max: number, + value: number[] +) { + const valueDiff = value[1] - value[0]; + const percentage = ((valueDiff - min) / (max - min)) * 100; + return `${Math.min(Math.max(percentage, 0), 100)}%`; } -export function isLabeledMark(mark: SliderMark): mark is LabeledMark { - return typeof mark !== "number"; +export function getPercentageOffset(min: number, max: number, value: number[]) { + const offsetLeft = ((value[0] - min) / (max - min)) * 100; + return `${Math.min(Math.max(offsetLeft, 0), 100)}%`; } -export function isMarkAtMax(max: number, mark: SliderMark) { - return max === (isLabeledMark(mark) ? mark.value : mark); +export function getMarkStyles(min: number, max: number, step: number) { + const marks = []; + for (let i = min; i <= max; i = i + step) { + const MarkPosition = getPercentage(min, max, i); + const MarkLabel = roundToTwoDp(i); + marks.push({ index: MarkLabel, position: MarkPosition }); + } + return marks; } diff --git a/packages/lab/stories/slider/slider.qa.stories.tsx b/packages/lab/stories/slider/slider.qa.stories.tsx new file mode 100644 index 00000000000..ce3da9dd3b2 --- /dev/null +++ b/packages/lab/stories/slider/slider.qa.stories.tsx @@ -0,0 +1,96 @@ +import { Slider } from "@salt-ds/lab"; +import { StoryFn, Meta } from "@storybook/react"; +import { QAContainer, QAContainerProps } from "docs/components"; + +export default { + title: "Lab/Slider/Slider QA", + component: Slider, +} as Meta; + +export const Default: StoryFn = (props) => { + return ( + + + + ); +}; + +Default.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const BottomLabel: StoryFn = (props) => { + return ( + + + + ); +}; + +BottomLabel.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const WithMarks: StoryFn = (props) => { + return ( + + + + ); +}; + +WithMarks.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const Range: StoryFn = (props) => { + return ( + + + + ); +}; + +Range.parameters = { + chromatic: { disableSnapshot: false }, +}; diff --git a/packages/lab/stories/slider/slider.stories.tsx b/packages/lab/stories/slider/slider.stories.tsx index 2f4b969f633..097783f77e1 100644 --- a/packages/lab/stories/slider/slider.stories.tsx +++ b/packages/lab/stories/slider/slider.stories.tsx @@ -1,60 +1,184 @@ -import { Button, Card, Checkbox } from "@salt-ds/core"; -import { Slider, type SliderProps } from "@salt-ds/lab"; -import type { StoryFn } from "@storybook/react"; +import { Input, FormField, FormFieldLabel, FlexLayout } from "@salt-ds/core"; +import { Slider, SliderProps, SliderValue } from "@salt-ds/lab"; +import { useState, ChangeEvent } from "react"; +import { StoryFn } from "@storybook/react"; export default { title: "Lab/Slider", component: Slider, }; -const SliderTemplate: StoryFn = (args) => { - return ; +const Template: StoryFn = ({ ...args }) => { + return ; }; -const SliderOnACardTemplate: StoryFn = (props) => { +export const Default = Template.bind({}); +Default.args = { + min: 0, + max: 10, + "aria-label": "default", +}; + +export const NonZeroInput = Template.bind({}); +NonZeroInput.args = { + min: -5, + max: 5, + "aria-label": "NonZeroInput", +}; + +export const CustomStep = Template.bind({}); +CustomStep.args = { + min: -1, + max: 1, + step: 0.2, + marks: "all", + "aria-label": "CustomStep", +}; + +export const BottomLabel = Template.bind({}); +BottomLabel.args = { + marks: "bottom", + "aria-label": "CustomStep", +}; + +export const WithMarks = Template.bind({}); +WithMarks.args = { + min: -5, + max: 5, + marks: "all", + "aria-label": "withMarks", +}; + +export const WithInput = () => { + const [value, setValue] = useState(5); + + const handleInputChange = (event: ChangeEvent) => { + const inputValue = event.target.value as unknown; + setValue(inputValue as SliderValue); + }; + + const handleChange = (value: SliderValue) => { + setValue(value); + }; + return ( - - - - - - - - - + + Slider with Input +
+ + +
+
); }; -export const Simple = SliderTemplate.bind({}); - -export const Range = SliderTemplate.bind({}); +const RangeTemplate: StoryFn = ({ ...args }) => { + return ; +}; -export const Stacked = SliderOnACardTemplate.bind({}); +export const Range = RangeTemplate.bind({}); +Range.args = { + min: 0, + max: 100, + defaultValue: [20, 80], +}; -Simple.args = { - defaultValue: 30, +export const RangeWithMarks = RangeTemplate.bind({}); +RangeWithMarks.args = { min: 0, - max: 60, - label: "Simple slider", + max: 100, + step: 10, + defaultValue: [20, 80], + marks: "all", }; -Range.args = { - defaultValue: [-30, 0, 30], - pushable: true, - pushDistance: 10, - min: -50, - max: 50, - step: 5, - pageStep: 25, - label: "Range slider", -}; - -Stacked.args = { - hideMarks: false, - hideMarkLabels: false, +function validate(minValue: number, maxValue: number) { + if (minValue > maxValue) return false; + else return true; +} + +export const RangeWithInput = () => { + const [value, setValue] = useState([0, 50]); + const [minValue, setMinValue] = useState(`${value[0]}`); + const [maxValue, setMaxValue] = useState(`${value[1]}`); + const [validationStatus, setValidationStatus] = useState( + undefined + ); + + const handleMinInputChange = (event: ChangeEvent) => { + const inputValue = event.target.value; + setMinValue(inputValue); + }; + + const handleMaxInputChange = (event: ChangeEvent) => { + const inputValue = event.target.value; + setMaxValue(inputValue); + }; + + const handleInputBlur = () => { + const minNumVal = parseFloat(minValue); + const maxNumVal = parseFloat(maxValue); + const validated = validate(minNumVal, maxNumVal); + validated + ? (setValue([minNumVal, maxNumVal]), setValidationStatus(undefined)) + : setValidationStatus("error"); + }; + + const handleSliderChange = (value: number[]) => { + setValue(value); + setMinValue(`${value[0]}`); + setMaxValue(`${value[1]}`); + }; + + return ( + + Slider with Input + + event.key === "Enter" && handleInputBlur()} + validationStatus={validationStatus} + /> + + event.key === "Enter" && handleInputBlur()} + validationStatus={validationStatus} + /> + + + ); };