diff --git a/src/components/Slider/Slider.module.css b/src/components/Slider/Slider.module.css new file mode 100644 index 000000000..7e0919c32 --- /dev/null +++ b/src/components/Slider/Slider.module.css @@ -0,0 +1,168 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ + # SLIDER +\*------------------------------------*/ + +/** + * Slider wrapping Label and Input + */ +.slider { + --slider-track-height: var(--eds-size-1); + --slider-thumb-size: var(--eds-size-3); + + display: flex; + flex-direction: column; + gap: var(--eds-size-1-and-half); +} + +/** + * Slider label disabled + */ +.slider__label--disabled { + color: var(--eds-theme-color-text-disabled); +} + +/** + * Slider Input + */ +.slider__input { + /* increases vertical hitbox for target size accessibility */ + padding-top: 22px; + padding-bottom: 22px; + /* fills left side of track as a percentage of the input value */ + --slider-track-background: linear-gradient( + /* fill from left to right */ to right, + /* fill color */ var(--eds-theme-color-background-brand-primary-strong) + /* percent to fill */ calc(var(--ratio) * 100%), + /* unfilled color */ var(--eds-theme-color-background-neutral-medium) + /* don't blend the colors */ 0 + ); + + appearance: none; + background: transparent; + + height: var(--slider-thumb-size); +} +.slider__input:focus { + outline: none; +} +.slider__input:disabled { + --slider-track-background: linear-gradient( + /* fill from left to right */ to right, + /* fill color */ var(--eds-theme-color-background-disabled) + /* percent to fill */ calc(var(--ratio) * 100%), + /* unfilled color */ var(--eds-theme-color-background-neutral-medium) + /* don't blend the colors */ 0 + ); + cursor: not-allowed; +} + +/* + * Chrome, Safari, Edge Chromium + * Although redundant with Firefox, has to be separate or else Chrome ignores + */ +.slider__input::-webkit-slider-runnable-track { + background: var(--slider-track-background); + height: var(--slider-track-height); + border-radius: var(--eds-border-radius-full); +} +/** + * Slider Input Track + */ +/* Firefox */ +.slider__input::-moz-range-track { + background: var(--slider-track-background); + height: var(--slider-track-height); + border-radius: var(--eds-border-radius-full); +} + +/* Chrome, Safari, Edge Chromium */ +.slider__input::-webkit-slider-thumb { + appearance: none; + + height: var(--slider-thumb-size); + width: var(--slider-thumb-size); + background: var(--eds-theme-color-text-neutral-default-inverse); + border: var(--eds-border-width-md) solid + var(--eds-theme-color-border-neutral-default); + border-radius: var(--eds-border-radius-full); + + margin-top: calc( + var(--slider-track-height) / 2 - var(--slider-thumb-size) / 2 + ); /* Centers thumb on the track */ +} +.slider__input:not(:disabled)::-webkit-slider-thumb { + cursor: grab; +} +.slider__input:not(:disabled)::-webkit-slider-thumb:active { + cursor: grabbing; +} +/* Chrome, Safari, Edge Chromium Focus */ +.slider__input:focus-visible::-webkit-slider-thumb { + @mixin focus; +} +@supports not selector(:focus-visible) { + .slider__input:focus::-webkit-slider-thumb { + @mixin focus; + } +} + +/* + * Slider Input Thumb + */ +/* Firefox */ +.slider__input::-moz-range-thumb { + box-sizing: border-box; + + height: var(--slider-thumb-size); + width: var(--slider-thumb-size); + background: var(--eds-theme-color-text-neutral-default-inverse); + border: var(--eds-border-width-md) solid + var(--eds-theme-color-border-neutral-default); + border-radius: var(--eds-border-radius-full); +} +.slider__input:not(:disabled)::-moz-range-thumb { + cursor: grab; +} +.slider__input:not(:disabled)::-moz-range-thumb:active { + cursor: grabbing; +} +/* Firefox Focus */ +.slider__input:focus-visible::-moz-range-thumb { + @mixin focus; +} +@supports not selector(:focus-visible) { + .slider__input:focus::-moz-range-thumb { + @mixin focus; + } +} + +/** + * Slider Markers wrapper below the track + */ +.slider__markers { + display: flex; + align-items: center; + justify-content: space-between; + + /* Calculates offset of the markers to align with actual values */ + padding-left: calc(var(--slider-thumb-size) / 2); + padding-right: calc(var(--slider-thumb-size) / 2); +} + +/** + * Slider Marker + */ +.slider__marker { + @mixin eds-theme-typography-caption-text-sm; + + /* Centers the text to the marker location */ + width: 0px; + display: flex; + justify-content: center; +} + +.slider__marker--disabled { + color: var(--eds-theme-color-text-disabled); +} diff --git a/src/components/Slider/Slider.stories.tsx b/src/components/Slider/Slider.stories.tsx new file mode 100644 index 000000000..05610548b --- /dev/null +++ b/src/components/Slider/Slider.stories.tsx @@ -0,0 +1,143 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { StoryObj, Meta } from '@storybook/react'; +import { userEvent } from '@storybook/testing-library'; +import React, { useState } from 'react'; + +import { Slider } from './Slider'; + +export default { + title: 'Components/Slider', + component: Slider, + parameters: { + layout: 'centered', + badges: [BADGE.BETA], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + render: (args) => , +} as Meta; + +const InteractiveSlider = ({ + min = 0, + max = 100, + step = 1, + value = 50, + ...args +}: Args) => { + const [sliderValue, setSliderValue] = useState(value); + return ( + setSliderValue(Number(e.target.value))} + value={sliderValue} + /> + ); +}; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + args: { + label: 'Slider Label', + }, +}; + +export const NoVisibleLabel: StoryObj = { + args: { + 'aria-label': 'Not visible slider label', + }, +}; + +export const GeneratedMarkers: StoryObj = { + args: { + label: 'Slider Label', + min: 1, + max: 5, + value: 3, + step: 1, + markers: 'number', + }, +}; + +export const NegativeNonIntegerMarkers: StoryObj = { + args: { + label: 'Slider Label', + min: -1, + max: 1, + value: 0, + step: 0.5, + markers: 'number', + }, +}; + +export const Disabled: StoryObj = { + args: { + label: 'Slider Label', + min: 1, + max: 5, + value: 3, + step: 1, + markers: 'number', + disabled: true, + }, +}; + +export const MarkersSmallValues: StoryObj = { + args: { + label: 'Slider Label', + min: 1, + max: 5, + value: 3, + markers: ['1', '2', '3', '4', '5'], + }, +}; + +export const MarkersLargeValues: StoryObj = { + args: { + label: 'Slider Label', + min: 0, + max: 10000, + value: 5000, + step: 2500, + markers: 'number', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const FieldNote: StoryObj = { + args: { + label: 'Slider Label', + fieldNote: 'This is a fieldnote. It overrides the markers', + markers: 'number', + }, +}; + +// For visual regression test +export const Focus: StoryObj = { + args: { + label: 'Slider Label', + }, + parameters: { + /** + * No point snapping the button as this story is testing visual regression on the focus state (snap no difference than Default story). + */ + snapshot: { skip: true }, + }, + play: () => { + userEvent.tab(); + }, +}; diff --git a/src/components/Slider/Slider.test.tsx b/src/components/Slider/Slider.test.tsx new file mode 100644 index 000000000..48ea1963c --- /dev/null +++ b/src/components/Slider/Slider.test.tsx @@ -0,0 +1,36 @@ +import { generateSnapshots } from '@chanzuckerberg/story-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; +import * as stories from './Slider.stories'; +import Slider from './'; + +describe('', () => { + generateSnapshots(stories); + describe('error throws', () => { + // expect console error from react, suppressed. + const consoleErrorMock = jest.spyOn(console, 'error'); + consoleErrorMock.mockImplementation(); + it('throws an error if no label or aria-label', () => { + expect(() => { + render(); + }).toThrow(/You must provide a visible label or aria-label/); + }); + it('throws an error if told to generate markers, but steps are not integers', () => { + expect(() => { + render( + , + ); + }).toThrow( + /Number of markers is not an integer. Change step or supply custom markers/, + ); + }); + consoleErrorMock.mockRestore(); + }); +}); diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx new file mode 100644 index 000000000..32cb9e23c --- /dev/null +++ b/src/components/Slider/Slider.tsx @@ -0,0 +1,182 @@ +import clsx from 'clsx'; +import React, { + useId, + type ChangeEventHandler, + type CSSProperties, +} from 'react'; +import { findLowestTenMultiplier } from '../../util/findLowestTenMultiplier'; +import FieldNote from '../FieldNote'; +import Label from '../Label'; +import Text from '../Text'; +import styles from './Slider.module.css'; + +export type Props = React.InputHTMLAttributes & { + /** + * Aria-label to provide an accesible name for the text input if no visible label is provided. + */ + 'aria-label'?: string; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + /** + * Disables the slider and prevents change. + */ + disabled?: boolean; + /** + * Text under the text input used to describe the slider. + * Will override the markers if present. + */ + fieldNote?: React.ReactNode; + /** + * HTML id for the component + */ + id?: string; + /** + * HTML label text + */ + label?: string; + /** + * List of markers to imply slider value. + * As 'number', will automatically generate markers based on min, max, and step. + * As an array of strings, will space the strings apart evenly. + */ + markers?: 'number' | string[]; + /** + * Maximum value allowed for the slider. + */ + max: number; + /** + * Minimum value allowed for the slider. + */ + min: number; + /** + * Function that runs on change of the input + */ + onChange?: ChangeEventHandler; + /** + * Amount to increment each step by. + */ + step: number; + /** + * Value denoted by the slider. + */ + value: number; +}; + +/** + * BETA: This component is still a work in progress and is subject to change. + * + * `import {Slider} from "@chanzuckerberg/eds";` + * + * Allows input of a value via dragging a thumb along a track. + * Strict: This slider requires a visual indicator of value/markers. + * Please check out our recipes for possible ideas. + */ +export const Slider = ({ + className, + disabled, + fieldNote, + id, + label, + markers, + max, + min, + step, + value, + ...other +}: Props) => { + if (process.env.NODE_ENV !== 'production' && !label && !other['aria-label']) { + throw new Error('You must provide a visible label or aria-label'); + } + + // Required due to 0.1 + 0.2 != 0.3 + const multiplier = findLowestTenMultiplier([max, min, step]); + const markersCount = + (max * multiplier - min * multiplier) / (step * multiplier) + 1; + if ( + process.env.NODE_ENV !== 'production' && + markers === 'number' && + !Number.isInteger(markersCount) + ) { + throw new Error( + 'Number of markers is not an integer. Change step or supply custom markers', + ); + } + + const componentClassName = clsx(styles['slider'], className); + const labelClassName = clsx(disabled && styles['slider__label--disabled']); + const markerClassName = clsx( + styles['slider__marker'], + disabled && styles['slider__marker--disabled'], + ); + + const generatedId = useId(); + const sliderId = id || generatedId; + + const generatedAriaDescribedById = useId(); + const ariaDescribedByVar = fieldNote + ? other['aria-describedby'] || generatedAriaDescribedById + : undefined; + + return ( +
+ {label && ( +
+ ); +}; diff --git a/src/components/Slider/__snapshots__/Slider.test.tsx.snap b/src/components/Slider/__snapshots__/Slider.test.tsx.snap new file mode 100644 index 000000000..d7ac5e9a3 --- /dev/null +++ b/src/components/Slider/__snapshots__/Slider.test.tsx.snap @@ -0,0 +1,381 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Default story renders snapshot 1`] = ` +
+
+ + +
+
+`; + +exports[` Disabled story renders snapshot 1`] = ` +
+
+ + + +
+
+`; + +exports[` FieldNote story renders snapshot 1`] = ` +
+
+ + +
+ This is a fieldnote. It overrides the markers +
+
+
+`; + +exports[` GeneratedMarkers story renders snapshot 1`] = ` +
+
+ + + +
+
+`; + +exports[` MarkersLargeValues story renders snapshot 1`] = ` +
+
+
+ + + +
+
+
+`; + +exports[` MarkersSmallValues story renders snapshot 1`] = ` +
+
+ + + +
+
+`; + +exports[` NegativeNonIntegerMarkers story renders snapshot 1`] = ` +
+
+ + + +
+
+`; + +exports[` NoVisibleLabel story renders snapshot 1`] = ` +
+
+ +
+
+`; diff --git a/src/components/Slider/index.ts b/src/components/Slider/index.ts new file mode 100644 index 000000000..01cc6813b --- /dev/null +++ b/src/components/Slider/index.ts @@ -0,0 +1 @@ +export { Slider as default } from './Slider'; diff --git a/src/index.ts b/src/index.ts index 1314922f4..2963e859a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ export { default as SearchField } from './components/SearchField'; export { default as Section } from './components/Section'; export { default as Select } from './components/Select'; export { default as Skeleton } from './components/Skeleton'; +export { default as Slider } from './components/Slider'; export { default as Tab } from './components/Tab'; export { default as Table } from './components/Table'; export { default as TableBody } from './components/TableBody'; diff --git a/src/util/findLowestTenMultiplier.test.ts b/src/util/findLowestTenMultiplier.test.ts new file mode 100644 index 000000000..4439e3c07 --- /dev/null +++ b/src/util/findLowestTenMultiplier.test.ts @@ -0,0 +1,29 @@ +import { findLowestTenMultiplier } from './findLowestTenMultiplier'; + +describe('findLowestTenMultiplier', () => { + describe('error throws', () => { + // expect console error from react, suppressed. + const consoleErrorMock = jest.spyOn(console, 'error'); + consoleErrorMock.mockImplementation(); + it('throws an error with NaN', () => { + () => { + expect(findLowestTenMultiplier([NaN])).toThrow( + /Number should be a real finite number/, + ); + }; + }); + it('throws an error with Infinity', () => { + () => { + expect(findLowestTenMultiplier([Infinity])).toThrow( + /Number should be a real finite number/, + ); + }; + }); + consoleErrorMock.mockRestore(); + }); + it('works with negative numbers, positive numbers, zero, and numbers between negative one and one', () => { + expect( + findLowestTenMultiplier([-2.212, -0.01, 0.1, 2.0, 100, 1000.01]), + ).toBe(1000); + }); +}); diff --git a/src/util/findLowestTenMultiplier.ts b/src/util/findLowestTenMultiplier.ts new file mode 100644 index 000000000..f9f08c388 --- /dev/null +++ b/src/util/findLowestTenMultiplier.ts @@ -0,0 +1,24 @@ +/** + * Returns the lowest multiple of 10 that multiplies with all numbers in a list to make them integers. + * Useful for floating point math. + * @example + * findLowestTenMultiplier([-2.212, 0.1, 2.0, 100, 1000.01]) + * // returns 1000 + * @param numbers + * @returns {number} Lowest multiple of 10. + */ +export function findLowestTenMultiplier(numbers: number[]): number { + if ( + process.env.NODE_ENV !== 'production' && + numbers.some((number) => !Number.isFinite(number)) + ) { + throw 'Number should be a real finite number'; + } + let multiplier = 1; + while ( + numbers.some((number) => !Number.isInteger((number * multiplier) % 1)) + ) { + multiplier *= 10; + } + return multiplier; +}