import { color, colorStyle, lineHeight, shadow, space, textStyle } from '@repay/cactus-theme'
import PropTypes from 'prop-types'
import React from 'react'
import { CSSObject } from 'styled-components'
import { compose, margin, MarginProps } from 'styled-system'
import { omitProps } from '../helpers/omit'
import { getStatusStyles, Status, StatusPropType } from '../helpers/status'
import {
flexItem,
FlexItemProps,
sizing,
SizingProps,
styledUnpoly,
styledWithClass,
} from '../helpers/styled'
// TODO For the tickmarks (future ticket), I'd suggest the following API:
//
//
//
//
//
// It's similar to the `list` attribute used by pure HTML, but cuts out the middleman.
// There are other invalid props for `range` but these are the most common, I think.
type InvalidProps = 'type' | 'height' | 'width' | 'list' | 'placeholder' | 'readOnly' | 'required'
interface InputProps extends Omit, InvalidProps> {
showValue?: 'focus' | 'hover' | boolean
status?: Status
}
export type RangeProps = InputProps & MarginProps & SizingProps & FlexItemProps
const VALUE_SIZE = 24
const NULL_ZONE = VALUE_SIZE / 2
const parse = (value: string, fallback: number): number => {
const num = parseFloat(value)
return isNaN(num) ? fallback : num
}
const setValuePosition = (wrapper: HTMLElement) => {
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const input = wrapper.querySelector('.range-input')!
const lowerBar = wrapper.querySelector('.range-low')!
const indicator = wrapper.querySelector('.range-value')!
const text = indicator.querySelector('span.range-tooltip')
/* eslint-enable @typescript-eslint/no-non-null-assertion */
const min = parse(input.min, 0)
const range = Math.max(0, parse(input.max, 100) - min)
const value = parse(input.value, min)
const normalizedValue = Math.max(0, value - min)
const slidableWidth = wrapper.getBoundingClientRect().width - VALUE_SIZE
const offset = Math.round((slidableWidth * normalizedValue) / range)
indicator.style.left = `${offset}px`
lowerBar.style.width = `${offset + NULL_ZONE}px`
if (text) {
text.innerHTML = value.toLocaleString()
}
}
const formResetEffect = (wrapper: HTMLElement) => {
const form = wrapper.querySelector('.range-input')?.form
if (form) {
// Reset event fires before the values actually change, so we delay the visual reset.
const onReset = () => setTimeout(() => setValuePosition(wrapper))
form.addEventListener('reset', onReset)
return () => form.removeEventListener('reset', onReset)
}
}
const setPositionOnChange = (e: React.ChangeEvent) => {
setValuePosition(e.target.parentElement as HTMLElement)
}
const BaseRange = React.forwardRef(
({ className, style, showValue, ...props }, inputRef) => {
const ref = React.useRef(null)
React.useLayoutEffect(() => {
if (ref.current) {
setValuePosition(ref.current)
}
})
const isUncontrolled = props.value === undefined
React.useEffect(() => {
setNullZone()
if (ref.current && isUncontrolled) {
return formResetEffect(ref.current)
}
}, [props.form, isUncontrolled])
const { onChange } = props
if (isUncontrolled) {
props.onChange = !onChange
? setPositionOnChange
: (e) => {
setPositionOnChange(e)
onChange(e)
}
}
return (
{showValue && (
<>
>
)}
)
}
)
BaseRange.displayName = 'Range'
// All browsers have a "null zone" on each end, half the size of the value indicator,
// so that when the indicator is at max/min the value is centered without overflowing.
// We have a null zone as well, so we adjust the range input so the actual
// endpoints (ignoring the null zone) match up with the visible endpoints.
const NULL_ZONE_STYLES: CSSObject = { position: 'relative', top: '0', left: '0', width: '100%' }
const setNullZone = () => {
// Use `position` as a sentinel value to check if we've already done this calculation.
if (NULL_ZONE_STYLES.position === 'relative') {
const input = document.createElement('input')
input.setAttribute('type', 'range')
input.style.opacity = '0'
input.style.position = 'fixed'
input.style.zIndex = '-1000'
input.style.pointerEvents = 'none'
input.style.fontSize = '16px'
input.style.padding = '0'
document.body.appendChild(input)
const browserNullZone = input.getBoundingClientRect().height / 2
document.body.removeChild(input)
setNullZoneStyles(NULL_ZONE - browserNullZone)
const sliders = document.querySelectorAll(`${Range} input`)
for (let i = 0; i < sliders.length; i++) {
Object.assign(sliders[i].style, NULL_ZONE_STYLES)
}
}
}
const setNullZoneStyles = (nullZoneDiff: number) => {
NULL_ZONE_STYLES.position = 'absolute'
NULL_ZONE_STYLES.left = `${nullZoneDiff.toFixed(3)}px`
NULL_ZONE_STYLES.width = `calc(100% - ${(nullZoneDiff * 2).toFixed(3)}px)`
}
const getShowValueStyles = ({ showValue }: RangeProps) => {
const styles: any = { '.range-tooltip': { display: 'none' } }
if (showValue === true || showValue === 'focus') {
styles['.range-input:enabled:focus + .range-slider .range-tooltip'] = { display: 'block' }
}
if (showValue === true || showValue === 'hover') {
styles['.range-input:enabled:hover + .range-slider .range-tooltip'] = { display: 'block' }
}
return styles
}
const applyStatusStyles = (styles: CSSObject | undefined) => {
if (styles) {
return {
'.range-indicator': styles,
'.range-low': { backgroundColor: styles.borderColor },
'.range-high': { backgroundColor: styles.backgroundColor },
}
}
}
export const Range = styledUnpoly(BaseRange).withConfig(
omitProps(margin, sizing, flexItem)
)`
display: inline-flex;
flex-direction: column;
position: relative;
min-height: ${VALUE_SIZE}px;
height: 1.3em;
min-width: ${VALUE_SIZE * 2}px;
width: 10em;
${getShowValueStyles}
.range-slider {
${(p) => applyStatusStyles(getStatusStyles(p))}
}
&&& {
${compose(margin, sizing, flexItem)}
}
`
Range.propTypes = {
showValue: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.oneOf<'hover' | 'focus'>(['hover', 'focus']),
]),
status: StatusPropType,
}
const RangeInput = styledWithClass('input', 'range-input').attrs({ type: 'range' })`
${() => NULL_ZONE_STYLES}
height: 100%;
margin: 0;
padding: 0;
opacity: 0;
z-index: 1;
cursor: grab;
:active {
cursor: grabbing;
+ .range-slider .range-indicator {
${shadow(0)}
}
}
:focus-visible + .range-slider {
outline: 1px solid ${color('callToAction')};
}
:disabled {
cursor: not-allowed;
+ .range-slider span {
border-color: ${color('lightGray')};
background-color: ${color('lightGray')};
}
}
`
const RangeSlider = styledWithClass('span', 'range-slider')`
flex: 1 0 ${VALUE_SIZE}px;
display: flex;
position: relative;
align-items: center;
.range-low {
background-color: ${color('callToAction')};
flex: 0 0 auto;
}
.range-high {
background-color: ${color('lightCallToAction')};
flex: 1 1 auto;
}
.range-bar {
height: 4px;
border-radius: 2px;
min-width: ${NULL_ZONE}px;
}
.range-value {
display: flex;
flex-direction: column-reverse;
align-items: center;
overflow: visible;
width: ${VALUE_SIZE}px;
height: ${VALUE_SIZE}px;
border-radius: 50%;
position: absolute;
top: calc(50% - ${NULL_ZONE}px);
}
.range-indicator {
flex-shrink: 0;
box-sizing: border-box;
height: 100%;
width: 100%;
border-radius: 50%;
border: solid 1px ${color('callToAction')};
background-color: ${color('white')};
}
svg.range-tooltip {
flex-shrink: 0;
width: 100%;
height: 5px;
fill: ${color('callToAction')};
}
span.range-tooltip {
flex-shrink: 0;
box-sizing: border-box;
${colorStyle('callToAction')}
${textStyle('small')}
${lineHeight('small', 'height')}
padding: ${space(1)} ${space(2)};
border-radius: ${space(1)};
}
`
export default Range