Common front-end components for Web using the Tesseract design system, written for React. https://make.modal.sh/tesseract/web/react/common
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Slider.tsx 9.4 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import * as React from 'react'
  2. import * as PropTypes from 'prop-types'
  3. import * as ReachSlider from '@reach/slider'
  4. import styled from 'styled-components'
  5. import stringify from '../../services/stringify'
  6. const Wrapper = styled('div')({
  7. position: 'relative',
  8. display: 'inline-block',
  9. verticalAlign: 'top',
  10. ':focus-within': {
  11. '--color-accent': 'var(--color-active, Highlight)',
  12. },
  13. })
  14. Wrapper.displayName = 'div'
  15. const Base = styled(ReachSlider.SliderInput)({
  16. boxSizing: 'border-box',
  17. position: 'absolute',
  18. bottom: 0,
  19. right: 0,
  20. ':active': {
  21. cursor: 'grabbing',
  22. },
  23. })
  24. Base.displayName = 'input'
  25. const Track = styled(ReachSlider.SliderTrack)({
  26. borderRadius: '0.125rem',
  27. position: 'relative',
  28. width: '100%',
  29. height: '100%',
  30. '::before': {
  31. display: 'block',
  32. position: 'absolute',
  33. content: "''",
  34. opacity: 0.25,
  35. width: '100%',
  36. height: '100%',
  37. backgroundColor: 'currentColor',
  38. borderRadius: '0.125rem',
  39. },
  40. })
  41. const Handle = styled(ReachSlider.SliderHandle)({
  42. cursor: 'grab',
  43. width: '1.25rem',
  44. height: '1.25rem',
  45. backgroundColor: 'var(--color-accent, blue)',
  46. borderRadius: '50%',
  47. zIndex: 1,
  48. transformOrigin: 'center',
  49. outline: 0,
  50. position: 'relative',
  51. '::before': {
  52. position: 'absolute',
  53. top: 0,
  54. left: 0,
  55. width: '100%',
  56. height: '100%',
  57. content: "''",
  58. borderRadius: '50%',
  59. opacity: 0.5,
  60. pointerEvents: 'none',
  61. },
  62. ':focus::before': {
  63. boxShadow: '0 0 0 0.25rem var(--color-accent, blue)',
  64. },
  65. })
  66. const Highlight = styled(ReachSlider.SliderTrackHighlight)({
  67. backgroundColor: 'var(--color-accent, blue)',
  68. borderRadius: '0.125rem',
  69. })
  70. const SizingWrapper = styled('span')({
  71. width: '100%',
  72. display: 'inline-block',
  73. verticalAlign: 'top',
  74. position: 'relative',
  75. })
  76. SizingWrapper.displayName = 'SizingWrapper'
  77. const TransformWrapper = styled('span')({
  78. display: 'inline-block',
  79. verticalAlign: 'top',
  80. transformOrigin: 'top left',
  81. })
  82. TransformWrapper.displayName = 'TransformWrapper'
  83. const LabelWrapper = styled('span')({
  84. color: 'var(--color-accent, blue)',
  85. boxSizing: 'border-box',
  86. fontSize: '0.75em',
  87. maxWidth: '100%',
  88. overflow: 'hidden',
  89. textOverflow: 'ellipsis',
  90. whiteSpace: 'nowrap',
  91. fontWeight: 'bolder',
  92. zIndex: 2,
  93. pointerEvents: 'none',
  94. transformOrigin: 'top left',
  95. position: 'absolute',
  96. top: 0,
  97. left: 0,
  98. transitionProperty: 'color',
  99. })
  100. LabelWrapper.displayName = 'span'
  101. const FallbackTrack = styled('span')({
  102. padding: '1.875rem 0.75rem 0.875rem',
  103. boxSizing: 'border-box',
  104. position: 'absolute',
  105. top: 0,
  106. left: 0,
  107. pointerEvents: 'none',
  108. '::before': {
  109. display: 'block',
  110. content: "''",
  111. opacity: 0.25,
  112. width: '100%',
  113. height: '100%',
  114. backgroundColor: 'currentColor',
  115. borderRadius: '0.125rem',
  116. },
  117. })
  118. const FallbackSlider = styled('input')({
  119. boxSizing: 'border-box',
  120. backgroundColor: 'transparent',
  121. verticalAlign: 'top',
  122. margin: 0,
  123. width: '100%',
  124. height: '2rem',
  125. marginTop: '1rem',
  126. appearance: 'none',
  127. outline: 0,
  128. position: 'absolute',
  129. left: 0,
  130. '::-moz-focus-inner': {
  131. outline: 0,
  132. border: 0,
  133. },
  134. '::-webkit-slider-thumb': {
  135. cursor: 'grab',
  136. width: '1.25rem',
  137. height: '1.25rem',
  138. backgroundColor: 'var(--color-accent, blue)',
  139. borderRadius: '50%',
  140. zIndex: 1,
  141. transformOrigin: 'center',
  142. outline: 0,
  143. position: 'relative',
  144. appearance: 'none',
  145. },
  146. '::-moz-range-thumb': {
  147. cursor: 'grab',
  148. border: 0,
  149. width: '1.25rem',
  150. height: '1.25rem',
  151. backgroundColor: 'var(--color-accent, blue)',
  152. borderRadius: '50%',
  153. zIndex: 1,
  154. transformOrigin: 'center',
  155. outline: 0,
  156. position: 'relative',
  157. appearance: 'none',
  158. },
  159. })
  160. FallbackSlider.displayName = 'input'
  161. const ClickArea = styled('label')({
  162. verticalAlign: 'top',
  163. width: '100%',
  164. height: '100%',
  165. })
  166. ClickArea.displayName = 'label'
  167. export type Orientation = 'vertical' | 'horizontal'
  168. type Dimension = 'width' | 'height'
  169. const propTypes = {
  170. /**
  171. * The component orientation.
  172. */
  173. orientation: PropTypes.oneOf<Orientation>(['vertical', 'horizontal']),
  174. /**
  175. * Short textual description indicating the nature of the component's value.
  176. */
  177. label: PropTypes.any,
  178. /**
  179. * CSS size for the component length.
  180. */
  181. length: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  182. /**
  183. * Is the component active?
  184. */
  185. disabled: PropTypes.bool,
  186. /**
  187. * Event handler triggered when the component changes value.
  188. */
  189. onChange: PropTypes.func,
  190. }
  191. type Props = PropTypes.InferProps<typeof propTypes>
  192. /**
  193. * Component for inputting numeric values in a graphical manner.
  194. *
  195. * The component is styled using client-side scripts. When the component is rendered server-side,
  196. * the component falls back into the original `<input type="range">` element.
  197. *
  198. * @see {@link //reacttraining.com/reach-ui/slider/#sliderinput|Reach UI Slider} for the client-side implementation.
  199. * @param {'vertical' | 'horizontal'} [orientation] - The component orientation.
  200. * @param {*} [label] - Short textual description indicating the nature of the component's value.
  201. * @param {string | number} [length] - CSS size for the component length.
  202. * @param {boolean} [disabled] - Is the component active?
  203. * @returns {React.ReactElement} The component elements.
  204. */
  205. const Slider: React.FC<Props> = ({
  206. orientation = 'horizontal',
  207. label = '',
  208. length = '16rem',
  209. disabled = false,
  210. onChange,
  211. }) => {
  212. const [isClient, setIsClient] = React.useState(false)
  213. React.useEffect(() => {
  214. window.document.body.style.setProperty('--reach-slider', '1')
  215. setIsClient(true)
  216. }, [])
  217. const parallelDimension: Dimension = orientation === 'horizontal' ? 'width' : 'height'
  218. const perpendicularDimension: Dimension = orientation === 'horizontal' ? 'height' : 'width'
  219. const perpendicularReference = orientation === 'horizontal' ? 'top' : 'left'
  220. const perpendicularTransform = orientation === 'horizontal' ? 'translateY' : 'translateX'
  221. const customBaseProps: any = {
  222. orientation,
  223. }
  224. const customFallbackProps: any = {
  225. orient: orientation,
  226. }
  227. if (isClient) {
  228. return (
  229. <Wrapper
  230. style={{
  231. opacity: disabled ? 0.5 : undefined,
  232. [parallelDimension]: length!,
  233. }}
  234. >
  235. <ClickArea>
  236. <SizingWrapper
  237. style={{
  238. [parallelDimension]: length!,
  239. [perpendicularDimension]: '3rem',
  240. }}
  241. >
  242. <TransformWrapper
  243. style={{
  244. [parallelDimension]: length!,
  245. [perpendicularDimension]: '100%',
  246. transform:
  247. orientation === 'horizontal'
  248. ? undefined
  249. : `rotate(-90deg) translateX(calc(${
  250. typeof length! === 'number' ? `${length as number}px` : (length as string)
  251. } * -1))`,
  252. }}
  253. >
  254. <LabelWrapper
  255. style={{
  256. maxWidth: length!,
  257. }}
  258. >
  259. {stringify(label)}
  260. </LabelWrapper>
  261. </TransformWrapper>
  262. </SizingWrapper>
  263. <Base
  264. {...customBaseProps}
  265. disabled={disabled!}
  266. style={{
  267. [parallelDimension]: length,
  268. [perpendicularDimension]: '2rem',
  269. padding: orientation === 'horizontal' ? '0.875rem 0.75rem' : '0.75rem 0.875rem',
  270. cursor: disabled ? 'not-allowed' : undefined,
  271. }}
  272. onChange={onChange!}
  273. >
  274. <Track>
  275. <Highlight
  276. style={{
  277. [perpendicularDimension]: '100%',
  278. }}
  279. />
  280. <Handle
  281. style={{
  282. [perpendicularReference]: '50%',
  283. transform: `${perpendicularTransform}(-50%)`,
  284. cursor: disabled ? 'not-allowed' : undefined,
  285. }}
  286. />
  287. </Track>
  288. </Base>
  289. </ClickArea>
  290. </Wrapper>
  291. )
  292. }
  293. return (
  294. <Wrapper
  295. style={{
  296. opacity: disabled ? 0.5 : undefined,
  297. [parallelDimension]: length!,
  298. }}
  299. >
  300. <SizingWrapper
  301. style={{
  302. [parallelDimension]: length!,
  303. [perpendicularDimension]: '3rem',
  304. }}
  305. >
  306. <TransformWrapper
  307. style={{
  308. [parallelDimension]: length!,
  309. [perpendicularDimension]: '100%',
  310. transform:
  311. orientation === 'horizontal'
  312. ? undefined
  313. : `rotate(-90deg) translateX(calc(${typeof length! === 'number' ? `${length}px` : length} * -1))`,
  314. }}
  315. >
  316. <FallbackTrack
  317. style={{
  318. width: length!,
  319. height: '3rem',
  320. padding: '1.875rem 0.75rem 0.875rem',
  321. }}
  322. />
  323. <ClickArea>
  324. <LabelWrapper
  325. style={{
  326. maxWidth: length!,
  327. }}
  328. >
  329. {stringify(label)}
  330. </LabelWrapper>
  331. {stringify(label).length > 0 && ' '}
  332. <FallbackSlider
  333. {...customFallbackProps}
  334. style={{
  335. width: length!,
  336. }}
  337. disabled={disabled!}
  338. onChange={onChange!}
  339. type="range"
  340. />
  341. </ClickArea>
  342. </TransformWrapper>
  343. </SizingWrapper>
  344. </Wrapper>
  345. )
  346. }
  347. Slider.propTypes = propTypes
  348. Slider.displayName = 'Slider'
  349. export default Slider