Design system.
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 

525 wiersze
16 KiB

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