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.5 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  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. * Class name used for styling.
  184. */
  185. className: PropTypes.string,
  186. /**
  187. * Is the component active?
  188. */
  189. disabled: PropTypes.bool,
  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 {string} [className] - Class name used for styling.
  203. * @param {boolean} [disabled] - Is the component active?
  204. * @param {object} etcProps - The component props.
  205. * @returns {React.ReactElement} The component elements.
  206. */
  207. const Slider: React.FC<Props> = ({
  208. orientation = 'horizontal',
  209. label = '',
  210. length = '16rem',
  211. className = '',
  212. disabled = false,
  213. ...etcProps
  214. }) => {
  215. const [isClient, setIsClient] = React.useState(false)
  216. React.useEffect(() => {
  217. window.document.body.style.setProperty('--reach-slider', '1')
  218. setIsClient(true)
  219. }, [])
  220. const parallelDimension: Dimension = orientation === 'horizontal' ? 'width' : 'height'
  221. const perpendicularDimension: Dimension = orientation === 'horizontal' ? 'height' : 'width'
  222. const perpendicularReference = orientation === 'horizontal' ? 'top' : 'left'
  223. const perpendicularTransform = orientation === 'horizontal' ? 'translateY' : 'translateX'
  224. if (isClient) {
  225. return (
  226. <Wrapper
  227. className={className!}
  228. style={{
  229. opacity: disabled ? 0.5 : undefined,
  230. [parallelDimension]: length!,
  231. }}
  232. >
  233. <ClickArea>
  234. <SizingWrapper
  235. style={{
  236. [parallelDimension]: length!,
  237. [perpendicularDimension]: '3rem',
  238. }}
  239. >
  240. <TransformWrapper
  241. style={{
  242. [parallelDimension]: length!,
  243. [perpendicularDimension]: '100%',
  244. transform:
  245. orientation === 'horizontal'
  246. ? undefined
  247. : `rotate(-90deg) translateX(calc(${
  248. typeof length! === 'number' ? `${length as number}px` : (length as string)
  249. } * -1))`,
  250. }}
  251. >
  252. <LabelWrapper
  253. style={{
  254. maxWidth: length!,
  255. }}
  256. >
  257. {stringify(label)}
  258. </LabelWrapper>
  259. </TransformWrapper>
  260. </SizingWrapper>
  261. <Base
  262. {...etcProps}
  263. orientation={orientation!}
  264. disabled={disabled!}
  265. style={{
  266. [parallelDimension]: length,
  267. [perpendicularDimension]: '2rem',
  268. padding: orientation === 'horizontal' ? '0.875rem 0.75rem' : '0.75rem 0.875rem',
  269. cursor: disabled ? 'not-allowed' : undefined,
  270. }}
  271. >
  272. <Track>
  273. <Highlight
  274. style={{
  275. [perpendicularDimension]: '100%',
  276. }}
  277. />
  278. <Handle
  279. style={{
  280. [perpendicularReference]: '50%',
  281. transform: `${perpendicularTransform}(-50%)`,
  282. cursor: disabled ? 'not-allowed' : undefined,
  283. }}
  284. />
  285. </Track>
  286. </Base>
  287. </ClickArea>
  288. </Wrapper>
  289. )
  290. }
  291. return (
  292. <Wrapper
  293. className={className!}
  294. style={{
  295. opacity: disabled ? 0.5 : undefined,
  296. [parallelDimension]: length!,
  297. }}
  298. >
  299. <SizingWrapper
  300. style={{
  301. [parallelDimension]: length!,
  302. [perpendicularDimension]: '3rem',
  303. }}
  304. >
  305. <TransformWrapper
  306. style={{
  307. [parallelDimension]: length!,
  308. [perpendicularDimension]: '100%',
  309. transform:
  310. orientation === 'horizontal'
  311. ? undefined
  312. : `rotate(-90deg) translateX(calc(${typeof length! === 'number' ? `${length}px` : length} * -1))`,
  313. }}
  314. >
  315. <FallbackTrack
  316. style={{
  317. width: length!,
  318. height: '3rem',
  319. padding: '1.875rem 0.75rem 0.875rem',
  320. }}
  321. />
  322. <ClickArea>
  323. <LabelWrapper
  324. style={{
  325. maxWidth: length!,
  326. }}
  327. >
  328. {stringify(label)}
  329. </LabelWrapper>
  330. {stringify(label).length > 0 && ' '}
  331. <FallbackSlider
  332. {...etcProps}
  333. style={{
  334. width: length!,
  335. }}
  336. disabled={disabled!}
  337. type="range"
  338. />
  339. </ClickArea>
  340. </TransformWrapper>
  341. </SizingWrapper>
  342. </Wrapper>
  343. )
  344. }
  345. Slider.propTypes = propTypes
  346. Slider.displayName = 'Slider'
  347. export default Slider