/* * Copyright 2018 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { mount, type ReactWrapper } from "enzyme"; import { expectPropValidationError } from "@blueprintjs/test-commons"; import { afterEach, assert, beforeEach, describe, expect, it, vi } from "@blueprintjs/test-commons/vitest"; import { Classes } from "../../common"; import { Handle } from "./handle"; import { MultiSlider, MultiSliderHandle, type MultiSliderProps } from "./multiSlider"; import { mouseUpHorizontal, simulateMovement } from "./sliderTestUtils"; const STEP_SIZE = 20; describe("", () => { let containerElement: HTMLElement; const onChange = vi.fn(); const onRelease = vi.fn(); beforeEach(() => { // need an element in the document for tickSize to be a real number containerElement = document.createElement("div"); // default min-max is 0-10 so there are 10 steps containerElement.style.width = `${STEP_SIZE * 10}px`; document.body.appendChild(containerElement); onChange.mockReset(); onRelease.mockReset(); }); afterEach(() => { containerElement.remove(); }); describe("handles", () => { it.skip("handle values are automatically sorted", () => { const slider = renderSlider({ onRelease, values: [5, 10, 0] }); slider.find(Handle).first().simulate("mousedown", { clientX: 0 }); mouseUpHorizontal(0); expect(onRelease).toHaveBeenCalledOnce(); expect(onRelease.mock.calls[0][0]).toEqual([0, 5, 10]); }); it("propagates className to the handles", () => { const slider = mount( , { attachTo: containerElement }, ); assert.lengthOf(slider.find("span.testClass"), 1); }); it.skip("moving mouse on the first handle updates the first value", () => { const slider = renderSlider({ onChange }); simulateMovement(slider, { dragSize: STEP_SIZE, dragTimes: 4, handleIndex: 0 }); // called 3 times for the move to 1, 2, 3, and 4 expect(onChange).toHaveBeenCalledTimes(4); expect(onChange.mock.calls.map(arg => arg[0])).toEqual([ [1, 5, 10], [2, 5, 10], [3, 5, 10], [4, 5, 10], ]); }); it.skip("moving mouse on the middle handle updates the middle value", () => { const slider = renderSlider({ onChange }); simulateMovement(slider, { dragSize: STEP_SIZE, dragTimes: 4, from: STEP_SIZE * 5, handleIndex: 1, }); // called 3 times for the move to 6, 7, 8, and 9 expect(onChange).toHaveBeenCalledTimes(4); expect(onChange.mock.calls.map(arg => arg[0])).toEqual([ [0, 6, 10], [0, 7, 10], [0, 8, 10], [0, 9, 10], ]); }); it.skip("moving mouse on the last handle updates the last value", () => { const slider = renderSlider({ onChange }); simulateMovement(slider, { dragSize: -STEP_SIZE, dragTimes: 4, from: STEP_SIZE * 10, handleIndex: 2, }); // called 3 times for the move to 9, 8, 7, and 6 expect(onChange).toHaveBeenCalledTimes(4); expect(onChange.mock.calls.map(arg => arg[0])).toEqual([ [0, 5, 9], [0, 5, 8], [0, 5, 7], [0, 5, 6], ]); }); it.skip("releasing mouse on a track value closer to the first handle moves the first handle", () => { const slider = renderSlider({ onChange }); slider.simulate("mousedown", { clientX: STEP_SIZE }); expect(onChange).toHaveBeenCalledOnce(); expect(onChange.mock.calls[0][0]).toEqual([1, 5, 10]); }); it.skip("releasing mouse on a track value slightly below the middle handle moves the middle handle", () => { const slider = renderSlider({ onChange }); slider.simulate("mousedown", { clientX: STEP_SIZE * 4 }); expect(onChange).toHaveBeenCalledOnce(); expect(onChange.mock.calls[0][0]).toEqual([0, 4, 10]); }); it.skip("releasing mouse on a track value slightly above the middle handle moves the middle handle", () => { const slider = renderSlider({ onChange }); slider.simulate("mousedown", { clientX: STEP_SIZE * 6 }); expect(onChange).toHaveBeenCalledOnce(); expect(onChange.mock.calls[0][0]).toEqual([0, 6, 10]); }); it.skip("releasing mouse on a track value closer to the last handle moves the last handle", () => { const slider = renderSlider({ onChange }); slider.simulate("mousedown", { clientX: STEP_SIZE * 9 }); expect(onChange).toHaveBeenCalledOnce(); expect(onChange.mock.calls[0][0]).toEqual([0, 5, 9]); }); it.skip("when values are equal, releasing mouse on a track still moves the nearest handle", () => { const slider = renderSlider({ onChange, values: [5, 5, 7] }); slider.simulate("mousedown", { clientX: STEP_SIZE * 1 }); expect(onChange).toHaveBeenCalledOnce(); expect(onChange.mock.calls[0][0]).toEqual([1, 5, 7]); onChange.mockClear(); slider.simulate("mousedown", { clientX: STEP_SIZE * 9 }); expect(onChange).toHaveBeenCalledOnce(); expect(onChange.mock.calls[0][0]).toEqual([5, 5, 9]); }); it("values outside of bounds are clamped", () => { const slider = renderSlider({ values: [-1, 5, 12] }); slider.find(`.${Classes.SLIDER_PROGRESS}`).forEach(progress => { const { left, right } = progress.prop("style")!; // CSS properties are percentage strings, but parsing will ignore trailing "%". // percentages should be in 0-100% range. assert.isAtLeast(parseFloat(left!.toString()), 0); assert.isAtMost(parseFloat(right!.toString()), 100); }); }); }); describe("labels", () => { it("renders label with labelStepSize fallback of 1 when not provided", () => { // [0 1 2 3 4 5] const wrapper = renderSlider({ max: 5, min: 0 }); assertLabelCount(wrapper, 6); }); it("renders label for value and for each labelStepSize", () => { // [0 10 20 30 40 50] const wrapper = renderSlider({ labelStepSize: 10, max: 50, min: 0 }); assertLabelCount(wrapper, 6); }); it("renders labels provided in labelValues prop", () => { const labelValues = [0, 30, 50, 60]; const wrapper = renderSlider({ labelValues, max: 50, min: 0 }); assertLabelCount(wrapper, 4); }); it("renders all labels even when floating point approx would cause the last one to be skipped", () => { // [0 0.14 0.28 0.42 0.56 0.70] const wrapper = renderSlider({ labelStepSize: 0.14, max: 0.7, min: 0 }); assertLabelCount(wrapper, 6); }); it("renders result of labelRenderer() in each label", () => { const labelRenderer = (val: number) => val + "#"; const wrapper = renderSlider({ labelRenderer, labelStepSize: 10, max: 50, min: 0 }); assert.strictEqual(wrapper.find(`.${Classes.SLIDER}-axis`).text(), "0#10#20#30#40#50#"); }); it("renders result of labelRenderer() in each label with labelValues", () => { const labelRenderer = (val: number) => val + "#"; const wrapper = renderSlider({ labelRenderer, labelValues: [20, 40, 50], max: 50, min: 0 }); assert.strictEqual(wrapper.find(`.${Classes.SLIDER}-axis`).text(), "20#40#50#"); }); it("default labelRenderer() fixes decimal places to labelPrecision", () => { const wrapper = renderSlider({ labelPrecision: 1, values: [0.99 / 10, 1, 1] }); const firstHandle = wrapper.find(Handle).first(); assert.strictEqual(firstHandle.text(), "0.1"); }); it("infers precision of default labelRenderer from stepSize", () => { const wrapper = renderSlider({ stepSize: 0.01 }); assert.strictEqual(wrapper.state("labelPrecision"), 2); }); it("labelRenderer={false} removes all labels", () => { const wrapper = renderSlider({ labelRenderer: false }); assertLabelCount(wrapper, 0); }); function assertLabelCount(wrapper: ReactWrapper, expected: number) { assert.lengthOf(wrapper.find(`.${Classes.SLIDER}-axis`).find(`.${Classes.SLIDER_LABEL}`), expected); } }); describe("track", () => { let slider: ReactWrapper; beforeEach(() => { slider = mount( , { attachTo: containerElement }, ); }); it("progress bars are rendered between all handles", () => { // N values = N+1 track segments assert.lengthOf(slider.find(`.${Classes.SLIDER_PROGRESS}`), 4); }); it("intentAfter beats intentBefore", () => { const intents = slider.find(`.${Classes.SLIDER_PROGRESS}`).map(segment => { const match = segment.prop("className")?.match(/-intent-(\w+)/) || []; return match[1]; }); // last segment has default intent assert.deepEqual(intents, ["primary", "danger", "danger", "warning"]); }); it("showTrackFill=false ignores track intents", () => { slider.setProps({ showTrackFill: false }); slider.find(`.${Classes.SLIDER_PROGRESS}`).map(segment => { // segments rendered but they nave no intent assert.isNull(segment.prop("className")?.match(/-intent-(\w+)/)); }); }); it("track section positioning is correct", () => { slider = mount( , ); const locations = slider.find(`.${Classes.SLIDER_PROGRESS}`).map(segment => { const match = segment.prop("style")!; return [match.left, match.right]; }); assert.deepEqual(locations, [ ["0.00%", "100.00%"], ["0.00%", "80.00%"], ["20.00%", "0.00%"], ]); }); it("trackStyleBefore and trackStyleAfter work as intended", () => { slider = mount( , ); const trackBackgrounds = slider .find(`.${Classes.SLIDER_PROGRESS}`) .map(segment => segment.prop("style")?.background); assert.equal(trackBackgrounds[0], "red"); assert.equal(trackBackgrounds[1], "yellow"); assert.equal(trackBackgrounds[2], "purple"); }); }); describe("validation", () => { it("throws an error if a child is not a slider handle", () => { expectPropValidationError(MultiSlider, { children: (Bad) as any }); }); it("throws error if stepSize <= 0", () => { [0, -10].forEach(stepSize => { expectPropValidationError(MultiSlider, { stepSize }, "greater than zero"); }); }); it("throws error if labelStepSize <= 0", () => { [0, -10].forEach(labelStepSize => { expectPropValidationError(MultiSlider, { labelStepSize }, "greater than zero"); }); }); it("throws an error if the min value is not finite", () => { expectPropValidationError( MultiSlider, { min: Number.NEGATIVE_INFINITY }, "min prop must be a finite number", ); }); it("throws an error if the max value is not finite", () => { expectPropValidationError( MultiSlider, { max: Number.POSITIVE_INFINITY }, "max prop must be a finite number", ); }); }); function renderSlider(joinedProps: MultiSliderProps & { values?: [number, number, number] } = {}) { const { values = [0, 5, 10], ...props } = joinedProps; return mount( , { attachTo: containerElement }, ); } });