Design system.
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 

520 rindas
15 KiB

  1. import * as React from 'react';
  2. import clsx from 'clsx';
  3. import plugin from 'tailwindcss/plugin';
  4. import { useBrowser } from '@modal-sh/react-utils';
  5. const filterOptions = (children: React.ReactNode): React.ReactNode => {
  6. const childrenArray = Array.isArray(children) ? children : [children];
  7. return childrenArray.reduce(
  8. (optionChildren, child) => {
  9. if (!(typeof child === 'object' && child)) {
  10. return optionChildren;
  11. }
  12. if (child.type === 'option') {
  13. return [
  14. ...optionChildren,
  15. child,
  16. ];
  17. }
  18. if (child.type === 'optgroup') {
  19. return [
  20. ...optionChildren,
  21. ...filterOptions(child.props.children) as unknown[],
  22. ];
  23. }
  24. return optionChildren;
  25. },
  26. [],
  27. ) as React.ReactNode;
  28. };
  29. /*
  30. * Caveat for slider:
  31. *
  32. * Since sliders are not as customizable as other components, especially with orientations,
  33. * prefer using sliders where using horizontal sliders would be as acceptable as vertical
  34. * sliders and vv.
  35. */
  36. export const AVAILABLE_SLIDER_ORIENTATIONS = ['horizontal', 'vertical'] as const;
  37. /**
  38. * Orientation of the {@link Slider} component.
  39. */
  40. export type SliderOrientation = typeof AVAILABLE_SLIDER_ORIENTATIONS[number];
  41. /**
  42. * Derived HTML element of the {@link Slider} component.
  43. */
  44. export type SliderDerivedElement = HTMLInputElement;
  45. /**
  46. * Props of the {@link Slider} component.
  47. */
  48. export interface SliderProps extends Omit<React.HTMLProps<HTMLInputElement>, 'type'> {
  49. /**
  50. * Orientation of the component.
  51. */
  52. orient?: SliderOrientation;
  53. /**
  54. * Options of the component.
  55. */
  56. children?: React.ReactNode;
  57. /**
  58. * Length of the component.
  59. */
  60. length?: React.CSSProperties['width'];
  61. }
  62. export const sliderPlugin = plugin(({ addComponents }) => {
  63. addComponents({
  64. '.slider': {
  65. '& > input': {
  66. appearance: 'none',
  67. cursor: 'pointer',
  68. position: 'relative',
  69. overflow: 'hidden',
  70. height: '1em',
  71. 'box-sizing': 'border-box',
  72. color: 'rgb(var(--color-primary))',
  73. },
  74. '& > input::-webkit-slider-container': {
  75. width: '100%',
  76. height: '100%',
  77. 'min-block-size': '0',
  78. 'background-color': 'rgb(var(--color-primary) / 50%)',
  79. 'border-radius': '9999px',
  80. display: 'block',
  81. 'box-sizing': 'border-box',
  82. 'background-clip': 'content-box',
  83. padding: '0.25em',
  84. appearance: 'none',
  85. },
  86. '& > input::-webkit-slider-runnable-track': {
  87. appearance: 'none',
  88. 'border-radius': '9999px',
  89. display: 'block',
  90. width: '100%',
  91. height: '100%',
  92. margin: '-0.25em',
  93. 'box-sizing': 'border-box',
  94. 'background-clip': 'content-box',
  95. },
  96. '& > input::-webkit-slider-thumb': {
  97. width: '1em',
  98. height: '1em',
  99. margin: '-0.25em 0 0 0',
  100. 'border-radius': '9999px',
  101. 'background-color': 'currentColor',
  102. appearance: 'none',
  103. 'aspect-ratio': '1 / 1',
  104. 'z-index': '1',
  105. position: 'relative',
  106. 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-primary) / 50%)',
  107. },
  108. '& > input:focus::-webkit-slider-container': {
  109. 'background-color': 'rgb(var(--color-secondary) / 50%)',
  110. },
  111. '& > input:active::-webkit-slider-container': {
  112. 'background-color': 'rgb(var(--color-tertiary) / 50%)',
  113. },
  114. '& > input:focus::-webkit-slider-thumb': {
  115. 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-secondary) / 50%)',
  116. },
  117. '& > input:active::-webkit-slider-thumb': {
  118. 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-tertiary) / 50%)',
  119. },
  120. '&[data-orient="horizontal"]': {
  121. 'flex-direction': 'column',
  122. },
  123. '&[data-firefox]': {
  124. 'flex-direction': 'column',
  125. },
  126. '&[data-firefox="vertical"]': {
  127. 'flex-direction': 'row',
  128. },
  129. '&[data-orient="vertical"]': {
  130. 'flex-direction': 'column',
  131. 'rotate': '-90deg',
  132. 'translate': 'calc(-100% + 0.5em * 2)',
  133. 'transform-origin': 'calc(100% - 0.5em) 0.5em',
  134. },
  135. '& > input::-moz-range-track': {
  136. appearance: 'none',
  137. 'border-radius': '9999px',
  138. 'content': '""',
  139. display: 'block',
  140. position: 'absolute',
  141. 'background-color': 'currentColor',
  142. 'opacity': '0.5',
  143. width: 'calc(100% - 0.5em)',
  144. height: '50%',
  145. top: '25%',
  146. margin: '-0.25em',
  147. },
  148. '& > input::-moz-range-thumb': {
  149. height: '100%',
  150. outline: '0',
  151. border: '0',
  152. 'border-radius': '9999px',
  153. 'background-color': 'currentColor',
  154. appearance: 'none',
  155. 'aspect-ratio': '1 / 1',
  156. 'z-index': '1',
  157. position: 'relative',
  158. 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-primary) / 50%)',
  159. },
  160. '& > input:focus::-moz-range-thumb': {
  161. 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-secondary) / 50%)',
  162. },
  163. '& > input:active::-moz-range-thumb': {
  164. 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-tertiary) / 50%)',
  165. },
  166. '& > input[orient="vertical"]': {
  167. width: '1em',
  168. height: '16em',
  169. },
  170. '& > input[orient="vertical"]::-moz-range-track': {
  171. width: '50%',
  172. height: 'calc(100% - 0.5em)',
  173. },
  174. '& > input[orient="vertical"]::-moz-range-thumb': {
  175. width: '100%',
  176. height: '1em',
  177. 'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-primary) / 50%)',
  178. },
  179. '& > input[orient="vertical"]:focus::-moz-range-thumb': {
  180. 'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-secondary) / 50%)',
  181. },
  182. '& > input[orient="vertical"]:active::-moz-range-thumb': {
  183. 'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-tertiary) / 50%)',
  184. },
  185. '&[data-chrome] > input + *': {
  186. 'padding': '0 0.5em',
  187. 'height': '1.5em',
  188. },
  189. '&[data-firefox="horizontal"] > input + *': {
  190. 'padding': '0 0.5em',
  191. 'height': '1.5em',
  192. },
  193. '&[data-firefox="vertical"] > input + *': {
  194. 'padding': '0.5em 0',
  195. 'width': '1.5em',
  196. },
  197. },
  198. });
  199. });
  200. /**
  201. * Component for inputting continuous numeric values.
  202. */
  203. export const Slider = React.forwardRef<SliderDerivedElement, SliderProps>((
  204. {
  205. className,
  206. style,
  207. children,
  208. orient = 'horizontal',
  209. length,
  210. min = 0,
  211. max = 100,
  212. ...etcProps
  213. },
  214. forwardedRef,
  215. ) => {
  216. const browser = useBrowser();
  217. const defaultRef = React.useRef<HTMLInputElement>(null);
  218. const ref = forwardedRef ?? defaultRef;
  219. const tickMarkId = React.useId();
  220. React.useEffect(() => {
  221. const setupGecko = (theOrient: string, theChildren: unknown, theBrowser?: string) => {
  222. if (!(typeof ref === 'object' && ref)) {
  223. return;
  224. }
  225. const { current: slider } = ref;
  226. if (!slider) {
  227. return;
  228. }
  229. const isFirefox = theBrowser === 'firefox';
  230. const wrapper = slider?.parentElement as HTMLElement;
  231. const parent = wrapper?.parentElement as HTMLElement;
  232. const grandParent = parent?.parentElement as HTMLElement;
  233. if (isFirefox) {
  234. slider.setAttribute('orient', theOrient);
  235. wrapper.dataset[theBrowser] = theOrient;
  236. wrapper.removeAttribute('data-orient');
  237. grandParent.style.width = theChildren ? '2.5em' : '1em';
  238. }
  239. };
  240. setupGecko(orient, children, browser);
  241. return () => {
  242. if (!(typeof ref === 'object' && ref)) {
  243. return;
  244. }
  245. const { current: slider } = ref;
  246. if (!slider) {
  247. return;
  248. }
  249. const isFirefox = browser === 'firefox';
  250. const wrapper = slider?.parentElement as HTMLElement;
  251. const parent = wrapper?.parentElement as HTMLElement;
  252. const grandParent = parent?.parentElement as HTMLElement;
  253. if (slider && isFirefox && parent && grandParent && wrapper) {
  254. grandParent.style.width = 'auto';
  255. wrapper.removeAttribute(`data-${browser}`);
  256. wrapper.dataset.orient = slider.getAttribute(orient) ?? undefined;
  257. slider.removeAttribute('orient');
  258. }
  259. };
  260. }, [ref, orient, children, browser]);
  261. React.useEffect(() => {
  262. let shouldEffectExecute: boolean;
  263. const setupNonGecko = (theOrient: string, theBrowser?: string) => {
  264. if (!(typeof ref === 'object' && ref)) {
  265. return;
  266. }
  267. const { current: slider } = ref;
  268. if (!slider) {
  269. return;
  270. }
  271. if (!theBrowser) {
  272. return;
  273. }
  274. const wrapper = slider?.parentElement as HTMLElement;
  275. const parent = wrapper?.parentElement as HTMLElement;
  276. const grandParent = parent?.parentElement as HTMLElement;
  277. const isNotFirefox = theBrowser !== 'firefox';
  278. if (isNotFirefox) {
  279. wrapper.dataset[theBrowser] = theOrient;
  280. }
  281. shouldEffectExecute = isNotFirefox && theOrient === 'vertical' && Boolean(slider) && Boolean(parent) && Boolean(grandParent);
  282. if (shouldEffectExecute) {
  283. const trueHeight = parent.clientWidth;
  284. const trueWidth = parent.clientHeight;
  285. grandParent.dataset.height = grandParent.clientHeight.toString();
  286. grandParent.dataset.width = grandParent.clientWidth.toString();
  287. grandParent.style.height = `${trueHeight}px`;
  288. grandParent.style.width = `${trueWidth}px`;
  289. }
  290. };
  291. setupNonGecko(orient, browser);
  292. return () => {
  293. if (!(typeof ref === 'object' && ref)) {
  294. return;
  295. }
  296. const { current: slider } = ref;
  297. if (!slider) {
  298. return;
  299. }
  300. const wrapper = slider?.parentElement as HTMLElement;
  301. const parent = wrapper?.parentElement as HTMLElement;
  302. const grandParent = parent?.parentElement as HTMLElement;
  303. if (shouldEffectExecute) {
  304. grandParent.style.height = `${grandParent.dataset.height ?? 0}px`;
  305. grandParent.style.width = `${grandParent.dataset.width ?? 0}px`;
  306. grandParent.removeAttribute('data-height');
  307. grandParent.removeAttribute('data-width');
  308. }
  309. wrapper.removeAttribute(`data-${browser ?? 'unknown'}`);
  310. };
  311. }, [ref, orient, browser]);
  312. React.useEffect(() => {
  313. if (!(typeof ref === 'object' && ref)) {
  314. return;
  315. }
  316. const { current: slider } = ref;
  317. if (!slider) {
  318. return;
  319. }
  320. const isFirefox = browser === 'firefox';
  321. const isNotFirefox = typeof browser === 'string' && browser !== 'firefox';
  322. const tickMarkContainer = slider.nextElementSibling;
  323. if (tickMarkContainer) {
  324. const tickMarks = tickMarkContainer.children[0].children;
  325. Array.from(tickMarks).forEach((tickMarkRaw) => {
  326. const tickMark = tickMarkRaw as HTMLElement;
  327. const offset = tickMark.dataset.offset as string;
  328. const tickMarkWrapper = tickMark.children[0] as HTMLElement;
  329. const tickMarkLine = tickMarkWrapper.children[0] as HTMLElement;
  330. const tickMarkLabel = tickMarkLine.nextElementSibling as HTMLElement | null;
  331. if (isNotFirefox) {
  332. tickMark.style.left = offset;
  333. tickMark.style.bottom = '';
  334. tickMarkWrapper.style.translate = '-50%';
  335. tickMarkWrapper.style.flexDirection = 'column';
  336. tickMarkLine.style.width = '1px';
  337. tickMarkLine.style.height = '0.5em';
  338. if (tickMarkLabel) {
  339. tickMarkLabel.style.writingMode = 'initial';
  340. }
  341. } else if (isFirefox && orient === 'horizontal') {
  342. tickMark.style.left = offset;
  343. tickMark.style.bottom = '';
  344. tickMarkWrapper.style.translate = '-50%';
  345. tickMarkWrapper.style.flexDirection = 'column';
  346. tickMarkLine.style.width = '1px';
  347. tickMarkLine.style.height = '0.5em';
  348. if (tickMarkLabel) {
  349. tickMarkLabel.style.writingMode = 'initial';
  350. }
  351. } else {
  352. tickMark.style.bottom = offset;
  353. tickMark.style.left = '';
  354. tickMarkWrapper.style.translate = '0 50%';
  355. tickMarkWrapper.style.flexDirection = 'row';
  356. tickMarkLine.style.width = '0.5em';
  357. tickMarkLine.style.height = '1px';
  358. if (tickMarkLabel) {
  359. tickMarkLabel.style.writingMode = 'sideways-lr';
  360. }
  361. }
  362. });
  363. }
  364. }, [ref, orient, browser, children]);
  365. const block = typeof length === 'string' && length.trim() === '100%';
  366. const minValue = Number(min);
  367. const maxValue = Number(max);
  368. if (minValue >= maxValue) {
  369. return null;
  370. }
  371. const childrenValues = children
  372. ? filterOptions(children)
  373. : undefined;
  374. return (
  375. <>
  376. {children
  377. && (
  378. <datalist
  379. id={tickMarkId}
  380. >
  381. {children}
  382. </datalist>
  383. )}
  384. <div
  385. className={clsx(
  386. block && orient !== 'vertical' && 'w-full',
  387. !block && 'align-middle',
  388. className,
  389. orient !== 'vertical' && 'inline-block min-h-[1rem]',
  390. orient === 'vertical' && 'inline-block min-w-[1rem]',
  391. orient !== 'vertical' && !block && 'min-w-[16rem]',
  392. )}
  393. style={style}
  394. >
  395. <div
  396. style={{
  397. width: orient === 'vertical' ? (length ?? '16rem') : undefined,
  398. position: 'relative',
  399. }}
  400. >
  401. <div
  402. className="flex slider"
  403. data-orient={orient}
  404. >
  405. <input
  406. {...etcProps}
  407. ref={ref}
  408. min={minValue}
  409. max={maxValue}
  410. type="range"
  411. list={childrenValues ? tickMarkId : undefined}
  412. className={clsx(
  413. 'w-full h-full bg-inherit block text-primary ring-secondary/50 rounded-full',
  414. 'focus:text-secondary focus:outline-0 focus:ring-4',
  415. 'active:text-tertiary active:ring-tertiary/50',
  416. )}
  417. />
  418. {Array.isArray(childrenValues) && (
  419. <div className="select-none">
  420. <div className="relative w-full h-full">
  421. {
  422. childrenValues
  423. .filter((c) => {
  424. const value = Number(c.props.value);
  425. return minValue <= value && value <= maxValue;
  426. })
  427. .map((c) => {
  428. const value = Number(c.props.value);
  429. return (
  430. <div
  431. key={c.props.value}
  432. className="absolute w-full h-full box-border"
  433. data-offset={`${((value - minValue) / (maxValue - minValue)) * 100}%`}
  434. >
  435. <div
  436. className="flex h-full text-xs items-center justify-between"
  437. >
  438. <div
  439. className="bg-current"
  440. />
  441. <div
  442. className={c.props.className}
  443. >
  444. {c.props.children}
  445. </div>
  446. </div>
  447. </div>
  448. );
  449. })
  450. }
  451. </div>
  452. </div>
  453. )}
  454. </div>
  455. </div>
  456. </div>
  457. </>
  458. );
  459. });
  460. Slider.displayName = 'Slider';
  461. Slider.defaultProps = {
  462. orient: 'horizontal',
  463. length: undefined,
  464. children: undefined,
  465. };