Skip to content

Commit

Permalink
feat(slider): add package
Browse files Browse the repository at this point in the history
  • Loading branch information
TheSisb committed Aug 4, 2023
1 parent 4d1f7c6 commit 4057b25
Show file tree
Hide file tree
Showing 20 changed files with 694 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .changeset/healthy-pigs-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/slider': major
'@twilio-paste/core': minor
---

[Slider] add new Slider package. A Slider is a draggable input that allows a user to select an imprecise numerical value within a range.
1 change: 1 addition & 0 deletions .codesandbox/ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"/packages/paste-core/components/side-modal",
"/packages/paste-core/components/sidebar",
"/packages/paste-core/components/skeleton-loader",
"/packages/paste-core/components/slider",
"/packages/paste-core/components/spinner",
"/packages/paste-core/layout/stack",
"/packages/paste-core/components/status",
Expand Down
1 change: 1 addition & 0 deletions packages/paste-codemods/tools/.cache/mappings.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@
"SidebarPushContentWrapper": "@twilio-paste/core/sidebar",
"useSidebarNavigationDisclosureState": "@twilio-paste/core/sidebar",
"SkeletonLoader": "@twilio-paste/core/skeleton-loader",
"Slider": "@twilio-paste/core/slider",
"Spinner": "@twilio-paste/core/spinner",
"StatusBadge": "@twilio-paste/core/status",
"StatusMenu": "@twilio-paste/core/status",
Expand Down
Empty file.
141 changes: 141 additions & 0 deletions packages/paste-core/components/slider/__tests__/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import * as React from 'react';
import {render, screen, fireEvent} from '@testing-library/react';
import {Theme} from '@twilio-paste/theme';
import {CustomizationProvider} from '@twilio-paste/customization';

import {Slider} from '../src';

const onChangeMock: jest.Mock = jest.fn();
const onChangeEndMock: jest.Mock = jest.fn();

const PercentFormatter = new Intl.NumberFormat('en-US', {style: 'percent'});

describe('Slider', () => {
describe('base usage', () => {
it('should render correctly', () => {
const {rerender} = render(
<Theme.Provider theme="twilio">
<Slider
id="test-slider"
value={0.5}
minValue={0}
maxValue={1}
step={0.01}
onChange={onChangeMock}
onChangeEnd={onChangeEndMock}
numberFormatter={PercentFormatter}
/>
</Theme.Provider>
);

// wrapper
expect(screen.getByRole('group')).toBeInTheDocument();
// Range
expect(screen.getByRole('presentation')).toBeInTheDocument();

// Slider input
const input = screen.getByRole('slider');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('aria-labelledby', 'test-slider');
expect(input).toHaveAttribute('min', '0');
expect(input).toHaveAttribute('max', '1');
expect(input).toHaveAttribute('step', '0.01');
expect(input).toHaveAttribute('value', '0.5');
expect(input).toHaveAttribute('type', 'range');
expect(input).toHaveAttribute('aria-valuetext', '50%');
expect(input).toHaveAttribute('tabindex', '0');
expect(input).toHaveAttribute('id', 'test-slider-0');

// Fires events correctly
fireEvent.keyDown(input, {key: 'ArrowDown', code: 'ArrowDown'});
expect(onChangeMock).toHaveBeenCalledTimes(1);
expect(onChangeEndMock).toHaveBeenCalledTimes(1);

// New render to test other conditions
rerender(
<Theme.Provider theme="twilio">
<Slider
disabled
hideRangeLabels
id="test-slider"
value={0.5}
minValue={0}
maxValue={1}
step={0.01}
onChange={onChangeMock}
onChangeEnd={onChangeEndMock}
numberFormatter={PercentFormatter}
/>
</Theme.Provider>
);

/*
* Check that range isn't rendering
* queryByRole returns null if no element is found, getByRole throws an error
*/
expect(screen.queryByRole('presentation')).toBe(null);

fireEvent.keyDown(input, {key: 'ArrowDown', code: 'ArrowDown'});
// Disabled so unchanged in call count
expect(onChangeMock).toHaveBeenCalledTimes(1);
expect(onChangeEndMock).toHaveBeenCalledTimes(1);
});
});

describe('Customization', () => {
it('should set default data-paste-element attribute on Slider and its children', (): void => {
render(
<CustomizationProvider baseTheme="default" theme={TestTheme}>
<Slider
id="test-slider"
value={0.5}
minValue={0}
maxValue={1}
step={0.01}
onChange={onChangeMock}
onChangeEnd={onChangeEndMock}
numberFormatter={PercentFormatter}
/>
</CustomizationProvider>
);

const slider = screen.getByRole('group');
expect(slider).toHaveAttribute('data-paste-element', 'SLIDER');

expect(slider.querySelector('[data-paste-element="SLIDER_RANGE_LABELS"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="SLIDER_RANGE_LABELS_MIN"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="SLIDER_RANGE_LABELS_MAX"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="SLIDER_TRACK_CONTAINER"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="SLIDER_TRACK"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="SLIDER_THUMB"]')).toBeInTheDocument();
});

it('should set custom data-paste-element attribute on Slider and its children', (): void => {
render(
<CustomizationProvider baseTheme="default" theme={TestTheme}>
<Slider
element="PASTA"
id="test-slider"
value={0.5}
minValue={0}
maxValue={1}
step={0.01}
onChange={onChangeMock}
onChangeEnd={onChangeEndMock}
numberFormatter={PercentFormatter}
/>
</CustomizationProvider>
);

const slider = screen.getByRole('group');
expect(slider).toHaveAttribute('data-paste-element', 'PASTA');

expect(slider.querySelector('[data-paste-element="PASTA_RANGE_LABELS"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="PASTA_RANGE_LABELS_MIN"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="PASTA_RANGE_LABELS_MAX"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="PASTA_TRACK_CONTAINER"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="PASTA_TRACK"]')).toBeInTheDocument();
expect(slider.querySelector('[data-paste-element="PASTA_THUMB"]')).toBeInTheDocument();
});
});
});
3 changes: 3 additions & 0 deletions packages/paste-core/components/slider/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const {build} = require('../../../../tools/build/esbuild');

build(require('./package.json'));
66 changes: 66 additions & 0 deletions packages/paste-core/components/slider/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@twilio-paste/slider",
"version": "0.0.0",
"category": "user input",
"status": "production",
"description": "A Slider is a draggable input that allows a user to select an imprecise numerical value within a range.",
"author": "Twilio Inc.",
"license": "MIT",
"main:dev": "src/index.tsx",
"main": "dist/index.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "yarn clean && NODE_ENV=production node build.js && tsc",
"build:js": "NODE_ENV=development node build.js",
"clean": "rm -rf ./dist",
"tsc": "tsc"
},
"peerDependencies": {
"@twilio-paste/animation-library": "^1.0.0",
"@twilio-paste/box": "^9.0.0",
"@twilio-paste/color-contrast-utils": "^4.0.0",
"@twilio-paste/customization": "^7.0.0",
"@twilio-paste/design-tokens": "^9.0.0",
"@twilio-paste/icons": "^11.0.0",
"@twilio-paste/react-spectrum-library": "^0.0.0",
"@twilio-paste/screen-reader-only": "12.0.0",
"@twilio-paste/style-props": "^8.0.0",
"@twilio-paste/styling-library": "^2.0.0",
"@twilio-paste/theme": "^10.0.0",
"@twilio-paste/types": "^5.0.0",
"@twilio-paste/uid-library": "^1.0.0",
"@types/react": "^16.8.6 || ^17.0.2 || ^18.0.27",
"@types/react-dom": "^16.8.6 || ^17.0.2 || ^18.0.10",
"prop-types": "^15.7.2",
"react": "^16.8.6 || ^17.0.2 || ^18.0.0",
"react-dom": "^16.8.6 || ^17.0.2 || ^18.0.0"
},
"devDependencies": {
"@twilio-paste/animation-library": "^1.0.0",
"@twilio-paste/box": "^9.0.0",
"@twilio-paste/color-contrast-utils": "^4.0.0",
"@twilio-paste/customization": "^7.0.0",
"@twilio-paste/design-tokens": "^9.0.2",
"@twilio-paste/icons": "^11.0.0",
"@twilio-paste/react-spectrum-library": "^0.0.0",
"@twilio-paste/screen-reader-only": "12.0.0",
"@twilio-paste/style-props": "^8.0.0",
"@twilio-paste/styling-library": "^2.0.0",
"@twilio-paste/theme": "^10.0.0",
"@twilio-paste/types": "^5.0.0",
"@twilio-paste/uid-library": "^1.0.0",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"prop-types": "^15.7.2",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
140 changes: 140 additions & 0 deletions packages/paste-core/components/slider/src/Slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import * as React from 'react';
import {Box, type BoxProps} from '@twilio-paste/box';
import {ScreenReaderOnly} from '@twilio-paste/screen-reader-only';
import {useSliderState, useSlider, useSliderThumb} from '@twilio-paste/react-spectrum-library';

import {SliderThumb} from './SliderThumb';
import {StyledTrack} from './StyledTrack';

export interface SliderProps {
element?: BoxProps['element'];
id: string;
disabled?: boolean;
hasError?: boolean;
hideRangeLabels?: boolean;
minValue?: number;
maxValue?: number;
step?: number;
/**
* Used to adjust how the numbers are rendered and interpreted.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat \
*/
numberFormatter: Intl.NumberFormat;
value?: number;
onChange?: (value: number) => void;
/** Fired when the slider stops moving, due to being let go. */
onChangeEnd?: (value: number) => void;
}

export const Slider: React.FC<SliderProps> = (props) => {
const inputRef = React.useRef(null);
const trackRef = React.useRef(null);
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);

/*
* We deconstruct the props here to get the values we need
* and to preserve the underlying props object variable
*/
const {
element = 'SLIDER',
minValue = 0,
maxValue = 100,
hideRangeLabels = false,
disabled: isDisabled = false,
id,
} = props;

// Our API differs from the underlying library, so we need to remap props
const remappedProps = {
...props,
id,
isDisabled,
'aria-labelledby': id, // needed to silence react-aria a11y guardrails
};

// These hooks manage the state of the slider
const state = useSliderState(remappedProps);
const {trackProps} = useSlider(remappedProps, state, trackRef);
const {thumbProps, inputProps, isDragging} = useSliderThumb(
{
index: 0,
trackRef,
inputRef,
},
state
);

// Used to determine the UI styling of the track and thumb
const uiStateProps = React.useMemo(() => {
return {
disabled: isDisabled,
error: props.hasError,
hovered,
focused,
dragging: isDragging,
};
}, [isDisabled, props.hasError, hovered, focused, isDragging]);

return (
<Box role="group" element={element}>
{/* Create a container for the optional min and max values */}
{!hideRangeLabels && (
<Box
role="presentation"
element={`${element}_RANGE_LABELS`}
display="flex"
justifyContent="space-between"
fontSize="fontSize30"
lineHeight="lineHeight30"
fontWeight="fontWeightSemibold"
color="colorTextWeak"
>
<Box as="span" element={`${element}_RANGE_LABELS_MIN`}>
{props.numberFormatter.format(minValue)}
</Box>
<Box as="span" element={`${element}_RANGE_LABELS_MAX`}>
{props.numberFormatter.format(maxValue)}
</Box>
</Box>
)}
{/* The track element holds the visible track line and the thumb */}
<Box
{...trackProps}
element={`${element}_TRACK_CONTAINER`}
ref={trackRef}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
display="flex"
alignItems="center"
height="20px"
width="100%"
cursor="pointer"
>
<StyledTrack
{...uiStateProps}
element={`${element}_TRACK`}
left={thumbProps.style?.left}
height="4px"
width="100%"
borderRadius="borderRadius20"
cursor="pointer"
>
<SliderThumb {...thumbProps} {...uiStateProps} element={`${element}_THUMB`}>
<ScreenReaderOnly>
<input
ref={inputRef}
{...inputProps}
aria-labelledby={id}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
</ScreenReaderOnly>
</SliderThumb>
</StyledTrack>
</Box>
</Box>
);
};

Slider.displayName = 'Slider';

0 comments on commit 4057b25

Please sign in to comment.