-
Notifications
You must be signed in to change notification settings - Fork 112
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
20 changed files
with
694 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
141 changes: 141 additions & 0 deletions
141
packages/paste-core/components/slider/__tests__/index.spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
const {build} = require('../../../../tools/build/esbuild'); | ||
|
||
build(require('./package.json')); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.