-
Notifications
You must be signed in to change notification settings - Fork 352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(Slider): Added new slider component #5358
Merged
Merged
Changes from 11 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
e90e075
feat(Slider): Added new slider component
62318a6
update slider so that thumb is focused on click
630b638
updated slider to update on click
2d6a63c
fix linting
d300b3b
fix demos and keyboard interaction issues
b958f8f
update demo to only show 2 decimal points
60d0491
update examples so discrete slider inputs snap to closest value
66d940f
Updates from review comments
12b2d7e
revert snap change from drawer conflict
ec0fbf2
fix broken thumb exampl and on;yt show 2 decimal places inputs
e98b3d9
fix example
240cb29
fix comment
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,311 @@ | ||
import * as React from 'react'; | ||
import { useState } from 'react'; | ||
import styles from '@patternfly/react-styles/css/components/Slider/slider'; | ||
import { css } from '@patternfly/react-styles'; | ||
import { SliderStep } from './SliderStep'; | ||
import { InputGroup, InputGroupText } from '../InputGroup'; | ||
import { TextInput } from '../TextInput'; | ||
|
||
export interface SliderStepObject { | ||
/** Value of the step. This value is a percentage of the slider where the tick is drawn. */ | ||
value: number; | ||
/** The display label for the step value. THis is also used for the aria-valuetext */ | ||
label: string; | ||
/** Flag to hide the label */ | ||
isLabelHidden?: boolean; | ||
} | ||
|
||
export interface SliderProps extends Omit<React.HTMLProps<HTMLDivElement>, 'onChange'> { | ||
/** Additional classes added to the spinner. */ | ||
className?: string; | ||
/** Current value */ | ||
currentValue?: number; | ||
/** Flag indicating if the slider is is discrete */ | ||
isDiscrete?: boolean; | ||
/** Array of slider step objects (value and label of each step) for the slider. If this is provided, the numSteps prop will be ignored.*/ | ||
steps?: SliderStepObject[]; | ||
/** Flag to show value input field */ | ||
isInputVisible?: boolean; | ||
/** Value displayed in the input field */ | ||
inputValue?: number; | ||
/** Aria label for the input field */ | ||
inputAriaLabel?: string; | ||
/* Aria label for the thumb */ | ||
thumbAriaLabel?: string; | ||
/** Label that is place after the input field */ | ||
inputLabel?: string | number; | ||
/** Position of the input */ | ||
inputPosition?: 'aboveThumb' | 'right'; | ||
/** Flag indicating input is disabled */ | ||
isInputDisabled?: boolean; | ||
/** Value input callback. Called when enter is hit while in input filed or focus shifts from input field */ | ||
onChange?: (value: number) => void; | ||
/** Value change callback. This is called when the slider value changes */ | ||
onValueChange?: (value: number) => void; | ||
/** Actions placed to the left of the slider */ | ||
leftActions?: React.ReactNode; | ||
/** Actions placed to the right of the slider */ | ||
rightActions?: React.ReactNode; | ||
} | ||
|
||
const getPercentage = (current: number, max: number) => (100 * current) / max; | ||
|
||
export const Slider: React.FunctionComponent<SliderProps> = ({ | ||
className, | ||
currentValue = 0, | ||
steps, | ||
isDiscrete = false, | ||
isInputVisible = false, | ||
inputValue = 0, | ||
inputLabel, | ||
inputAriaLabel = 'Slider value input', | ||
thumbAriaLabel = 'Value', | ||
inputPosition = 'right', | ||
isInputDisabled, | ||
onChange, | ||
onValueChange, | ||
leftActions, | ||
rightActions, | ||
...props | ||
}: SliderProps) => { | ||
const sliderRailRef = React.useRef<HTMLDivElement>(); | ||
const thumbRef = React.useRef<HTMLDivElement>(); | ||
|
||
const [value, setValue] = useState(currentValue); | ||
const [localInputValue, setLocalInputValue] = useState(inputValue); | ||
|
||
React.useEffect(() => { | ||
setValue(currentValue); | ||
}, [currentValue]); | ||
|
||
React.useEffect(() => { | ||
setLocalInputValue(inputValue); | ||
}, [inputValue]); | ||
|
||
let diff = 0; | ||
let snapValue: number; | ||
|
||
const style = { '--pf-c-slider--value': `${value}%` } as React.CSSProperties; | ||
|
||
const onChangeHandler = (value: string) => { | ||
setLocalInputValue(Number(value)); | ||
}; | ||
|
||
const handleKeyPressOnInput = (event: React.KeyboardEvent) => { | ||
if (event.key === 'Enter') { | ||
event.preventDefault(); | ||
if (onChange) { | ||
onChange(localInputValue); | ||
} | ||
} | ||
}; | ||
|
||
const onInputFocus = (e: any) => { | ||
e.stopPropagation(); | ||
}; | ||
|
||
const onThumbClick = () => { | ||
thumbRef.current.focus(); | ||
}; | ||
|
||
const onBlur = () => { | ||
if (onChange) { | ||
onChange(localInputValue); | ||
} | ||
}; | ||
|
||
const findAriaTextValue = () => { | ||
if (steps && isDiscrete) { | ||
const step = steps.find(step => step.value === value); | ||
if (step) { | ||
return step.label; | ||
} | ||
} | ||
return undefined; | ||
}; | ||
|
||
const handleMouseup = () => { | ||
if (snapValue && isDiscrete && steps) { | ||
thumbRef.current.style.setProperty('--pf-c-slider--value', `${snapValue}%`); | ||
setValue(snapValue); | ||
if (onValueChange) { | ||
onValueChange(snapValue); | ||
} | ||
} | ||
|
||
document.removeEventListener('mousemove', callbackMouseMove); | ||
document.removeEventListener('mouseup', callbackMouseUp); | ||
}; | ||
|
||
const handleMousedown = (e: React.MouseEvent) => { | ||
e.stopPropagation(); | ||
e.preventDefault(); | ||
diff = e.clientX - thumbRef.current.getBoundingClientRect().left; | ||
|
||
document.addEventListener('mousemove', callbackMouseMove); | ||
document.addEventListener('mouseup', callbackMouseUp); | ||
}; | ||
|
||
const onSliderRailClick = (e: any) => { | ||
handleMousemove(e); | ||
if (snapValue && isDiscrete && steps) { | ||
thumbRef.current.style.setProperty('--pf-c-slider--value', `${snapValue}%`); | ||
setValue(snapValue); | ||
if (onValueChange) { | ||
onValueChange(snapValue); | ||
} | ||
} | ||
}; | ||
|
||
const handleMousemove = (e: any) => { | ||
let newPosition = e.clientX - diff - sliderRailRef.current.getBoundingClientRect().left; | ||
|
||
const end = sliderRailRef.current.offsetWidth - thumbRef.current.offsetWidth; | ||
|
||
const start = 0; | ||
|
||
if (newPosition < start) { | ||
newPosition = 0; | ||
} | ||
|
||
if (newPosition > end) { | ||
newPosition = end; | ||
} | ||
|
||
const newPercentage = getPercentage(newPosition, end); | ||
|
||
thumbRef.current.style.setProperty('--pf-c-slider--value', `${newPercentage}%`); | ||
setValue(newPercentage); | ||
|
||
/* If discrete, snap to closest step value */ | ||
if (isDiscrete && steps) { | ||
const stepIndex = steps.findIndex(step => step.value >= newPercentage); | ||
if (steps[stepIndex].value === newPercentage) { | ||
snapValue = steps[stepIndex].value; | ||
} else { | ||
const midpoint = (steps[stepIndex].value + steps[stepIndex - 1].value) / 2; | ||
if (midpoint > newPercentage) { | ||
snapValue = steps[stepIndex - 1].value; | ||
} else { | ||
snapValue = steps[stepIndex].value; | ||
} | ||
} | ||
} | ||
|
||
// Call value change callback | ||
if (onValueChange && !isDiscrete && !steps) { | ||
onValueChange(newPercentage); | ||
} | ||
}; | ||
|
||
const callbackMouseMove = React.useCallback(handleMousemove, []); | ||
const callbackMouseUp = React.useCallback(handleMouseup, []); | ||
|
||
const handleThumbKeys = (e: React.KeyboardEvent) => { | ||
const key = e.key; | ||
if (key !== 'ArrowLeft' && key !== 'ArrowRight') { | ||
return; | ||
} | ||
e.preventDefault(); | ||
let newValue: number = value; | ||
if (isDiscrete) { | ||
const stepIndex = steps.findIndex(step => step.value === value); | ||
if (key === 'ArrowRight') { | ||
if (stepIndex + 1 < steps.length) { | ||
{ | ||
newValue = steps[stepIndex + 1].value; | ||
} | ||
} | ||
} else if (key === 'ArrowLeft') { | ||
if (stepIndex - 1 >= 0) { | ||
newValue = steps[stepIndex - 1].value; | ||
} | ||
} | ||
} else { | ||
if (key === 'ArrowRight') { | ||
newValue = value + 1 <= 100 ? value + 1 : 100; | ||
} else if (key === 'ArrowLeft') { | ||
newValue = value - 1 >= 0 ? value - 1 : 0; | ||
} | ||
} | ||
|
||
if (newValue !== value) { | ||
thumbRef.current.style.setProperty('--pf-c-slider--value', `${newValue}%`); | ||
setValue(newValue); | ||
if (onValueChange) { | ||
onValueChange(newValue); | ||
} | ||
} | ||
}; | ||
|
||
const displayInput = () => { | ||
const textInput = ( | ||
<TextInput | ||
className={css(styles.formControl)} | ||
isDisabled={isInputDisabled} | ||
type="number" | ||
value={localInputValue} | ||
aria-label={inputAriaLabel} | ||
onKeyDown={handleKeyPressOnInput} | ||
onChange={onChangeHandler} | ||
onClick={onInputFocus} | ||
onFocus={onInputFocus} | ||
onBlur={onBlur} | ||
/> | ||
); | ||
if (inputLabel) { | ||
return ( | ||
<InputGroup> | ||
{textInput} | ||
<InputGroupText className={css('pf-m-plain')}>{inputLabel}</InputGroupText> | ||
</InputGroup> | ||
); | ||
} else { | ||
return textInput; | ||
} | ||
}; | ||
|
||
return ( | ||
<div className={css(styles.slider, className)} style={style} {...props}> | ||
{leftActions && <div className={css(styles.sliderActions)}>{leftActions}</div>} | ||
<div className={css(styles.sliderMain)}> | ||
<div className={css(styles.sliderRail)} ref={sliderRailRef} onClick={onSliderRailClick}> | ||
<div className={css(styles.sliderRailTrack)} /> | ||
</div> | ||
{steps && ( | ||
<div className={css(styles.sliderSteps)} aria-hidden="true"> | ||
{steps.map(step => ( | ||
<SliderStep | ||
key={step.value} | ||
value={step.value} | ||
label={step.label} | ||
isLabelHidden={step.isLabelHidden} | ||
isActive={step.value <= value} | ||
/> | ||
))} | ||
</div> | ||
)} | ||
<div | ||
className={css(styles.sliderThumb)} | ||
ref={thumbRef} | ||
tabIndex={0} | ||
role="slider" | ||
aria-valuemin={steps ? steps[0].value : 0} | ||
aria-valuemax={steps ? steps[steps.length - 1].value : 100} | ||
aria-valuenow={value} | ||
aria-valuetext={findAriaTextValue()} | ||
aria-label={thumbAriaLabel} | ||
onMouseDown={handleMousedown} | ||
onKeyDown={handleThumbKeys} | ||
onClick={onThumbClick} | ||
/> | ||
{isInputVisible && inputPosition === 'aboveThumb' && ( | ||
<div className={css(styles.sliderValue, styles.modifiers.floating)}>{displayInput()}</div> | ||
)} | ||
</div> | ||
{isInputVisible && inputPosition === 'right' && <div className={css(styles.sliderValue)}>{displayInput()}</div>} | ||
{rightActions && <div className={css(styles.sliderActions)}>{rightActions}</div>} | ||
</div> | ||
); | ||
}; | ||
Slider.displayName = 'Slider'; |
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,34 @@ | ||
import * as React from 'react'; | ||
import styles from '@patternfly/react-styles/css/components/Slider/slider'; | ||
import { css } from '@patternfly/react-styles'; | ||
|
||
export interface SliderStepProps extends Omit<React.HTMLProps<HTMLDivElement>, 'label'> { | ||
/** Additional classes added to the slider steps. */ | ||
className?: string; | ||
/** Step value **/ | ||
value?: number; | ||
/** Step label **/ | ||
label?: string; | ||
/** Flag indicating that the label should be hidden */ | ||
isLabelHidden?: boolean; | ||
/** Flag indicating the step is active */ | ||
isActive?: boolean; | ||
} | ||
|
||
export const SliderStep: React.FunctionComponent<SliderStepProps> = ({ | ||
className, | ||
label, | ||
value, | ||
isLabelHidden = false, | ||
isActive = false, | ||
...props | ||
}: SliderStepProps) => { | ||
const style = { '--pf-c-slider__step--Left': `${value}%` } as React.CSSProperties; | ||
return ( | ||
<div className={css(styles.sliderStep, isActive && styles.modifiers.active, className)} style={style} {...props}> | ||
<div className={css(styles.sliderStepTick)} /> | ||
{!isLabelHidden && label && <div className={css(styles.sliderStepLabel)}>{label}</div>} | ||
</div> | ||
); | ||
}; | ||
SliderStep.displayName = 'SliderStep'; |
51 changes: 51 additions & 0 deletions
51
packages/react-core/src/components/Slider/_tests_/Slider.test.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,51 @@ | ||
import React from 'react'; | ||
import { mount } from 'enzyme'; | ||
import { Slider } from '../Slider'; | ||
import { Button } from '../../Button'; | ||
|
||
describe('slider', () => { | ||
test('renders continuous slider', () => { | ||
const view = mount(<Slider currentValue={50} isInputVisible inputValue={50} />); | ||
expect(view).toMatchSnapshot(); | ||
}); | ||
|
||
test('renders discrete slider', () => { | ||
const view = mount( | ||
<Slider | ||
isDiscrete | ||
currentValue={50} | ||
steps={[ | ||
{ value: 0, label: '0%' }, | ||
{ value: 25, label: '25%', isLabelHidden: true }, | ||
{ value: 50, label: '50%' }, | ||
{ value: 75, label: '75%', isLabelHidden: true }, | ||
{ value: 100, label: '100%' } | ||
]} | ||
/> | ||
); | ||
expect(view).toMatchSnapshot(); | ||
}); | ||
|
||
test('renders slider with input', () => { | ||
const view = mount(<Slider currentValue={50} isInputVisible inputValue={50} inputLabel="%" inputPosition="left" />); | ||
expect(view).toMatchSnapshot(); | ||
}); | ||
|
||
test('renders slider with input above thumb', () => { | ||
const view = mount( | ||
<Slider currentValue={50} isInputVisible inputValue={50} inputLabel="%" inputPosition="aboveThumb" /> | ||
); | ||
expect(view).toMatchSnapshot(); | ||
}); | ||
|
||
test('renders slider with input actions', () => { | ||
const view = mount( | ||
<Slider | ||
currentValue={50} | ||
leftActions={<Button variant="plain" aria-label="Minus" />} | ||
rightActions={<Button variant="plain" aria-label="Plus" />} | ||
/> | ||
); | ||
expect(view).toMatchSnapshot(); | ||
}); | ||
}); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there still a
numSteps
prop? I dont see it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nope. that was removed