|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523 |
- import * as React from 'react';
- import clsx from 'clsx';
- import { PluginCreator } from 'tailwindcss/types/config';
- import { useBrowser } from '@modal-sh/react-utils';
-
- const filterOptions = (children: React.ReactNode): React.ReactNode => {
- const childrenArray = Array.isArray(children) ? children : [children];
- return childrenArray.reduce(
- (optionChildren, child) => {
- if (!(typeof child === 'object' && child)) {
- return optionChildren;
- }
- if (child.type === 'option') {
- return [
- ...optionChildren,
- child,
- ];
- }
- if (child.type === 'optgroup') {
- return [
- ...optionChildren,
- ...filterOptions(child.props.children) as unknown[],
- ];
- }
- return optionChildren;
- },
- [],
- ) as React.ReactNode;
- };
-
- /*
- * Caveat for slider:
- *
- * Since sliders are not as customizable as other components, especially with orientations,
- * prefer using sliders where using horizontal sliders would be as acceptable as vertical
- * sliders and vv.
- */
-
- export const AVAILABLE_SLIDER_ORIENTATIONS = ['horizontal', 'vertical'] as const;
-
- /**
- * Orientation of the {@link Slider} component.
- */
- export type SliderOrientation = typeof AVAILABLE_SLIDER_ORIENTATIONS[number];
-
- const SliderDerivedElementComponent = 'input';
-
- /**
- * Derived HTML element of the {@link Slider} component.
- */
- export type SliderDerivedElement = HTMLElementTagNameMap[
- typeof SliderDerivedElementComponent
- ];
-
- /**
- * Props of the {@link Slider} component.
- */
- export interface SliderProps extends Omit<React.HTMLProps<SliderDerivedElement>, 'type'> {
- /**
- * Orientation of the component.
- */
- orient?: SliderOrientation;
- /**
- * Options of the component.
- */
- children?: React.ReactNode;
- /**
- * Length of the component.
- */
- length?: React.CSSProperties['width'];
- }
-
- export const sliderPlugin: PluginCreator = ({ addComponents }) => {
- addComponents({
- '.slider': {
- '& > input': {
- appearance: 'none',
- cursor: 'pointer',
- position: 'relative',
- overflow: 'hidden',
- height: '1em',
- 'box-sizing': 'border-box',
- color: 'rgb(var(--color-primary))',
- },
-
- '& > input::-webkit-slider-container': {
- width: '100%',
- height: '100%',
- 'min-block-size': '0',
- 'background-color': 'rgb(var(--color-primary) / 50%)',
- 'border-radius': '9999px',
- display: 'block',
- 'box-sizing': 'border-box',
- 'background-clip': 'content-box',
- padding: '0.25em',
- appearance: 'none',
- },
-
- '& > input::-webkit-slider-runnable-track': {
- appearance: 'none',
- 'border-radius': '9999px',
- display: 'block',
- width: '100%',
- height: '100%',
- margin: '-0.25em',
- 'box-sizing': 'border-box',
- 'background-clip': 'content-box',
- },
-
- '& > input::-webkit-slider-thumb': {
- width: '1em',
- height: '1em',
- margin: '-0.25em 0 0 0',
- 'border-radius': '9999px',
- 'background-color': 'currentColor',
- appearance: 'none',
- 'aspect-ratio': '1 / 1',
- 'z-index': '1',
- position: 'relative',
- 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-primary) / 50%)',
- },
-
- '& > input:focus::-webkit-slider-container': {
- 'background-color': 'rgb(var(--color-secondary) / 50%)',
- },
-
- '& > input:active::-webkit-slider-container': {
- 'background-color': 'rgb(var(--color-tertiary) / 50%)',
- },
-
- '& > input:focus::-webkit-slider-thumb': {
- 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-secondary) / 50%)',
- },
-
- '& > input:active::-webkit-slider-thumb': {
- 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-tertiary) / 50%)',
- },
-
- '&[data-orient="horizontal"]': {
- 'flex-direction': 'column',
- },
-
- '&[data-firefox]': {
- 'flex-direction': 'column',
- },
-
- '&[data-firefox="vertical"]': {
- 'flex-direction': 'row',
- },
-
- '&[data-orient="vertical"]': {
- 'flex-direction': 'column',
- 'rotate': '-90deg',
- 'translate': 'calc(-100% + 0.5em * 2)',
- 'transform-origin': 'calc(100% - 0.5em) 0.5em',
- },
-
- '& > input::-moz-range-track': {
- appearance: 'none',
- 'border-radius': '9999px',
- 'content': '""',
- display: 'block',
- position: 'absolute',
- 'background-color': 'currentColor',
- 'opacity': '0.5',
- width: 'calc(100% - 0.5em)',
- height: '50%',
- top: '25%',
- margin: '-0.25em',
- },
-
- '& > input::-moz-range-thumb': {
- height: '100%',
- outline: '0',
- border: '0',
- 'border-radius': '9999px',
- 'background-color': 'currentColor',
- appearance: 'none',
- 'aspect-ratio': '1 / 1',
- 'z-index': '1',
- position: 'relative',
- 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-primary) / 50%)',
- },
-
- '& > input:focus::-moz-range-thumb': {
- 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-secondary) / 50%)',
- },
-
- '& > input:active::-moz-range-thumb': {
- 'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-tertiary) / 50%)',
- },
-
- '& > input[orient="vertical"]': {
- width: '1em',
- height: '16em',
- },
-
- '& > input[orient="vertical"]::-moz-range-track': {
- width: '50%',
- height: 'calc(100% - 0.5em)',
- },
-
- '& > input[orient="vertical"]::-moz-range-thumb': {
- width: '100%',
- height: '1em',
- 'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-primary) / 50%)',
- },
-
- '& > input[orient="vertical"]:focus::-moz-range-thumb': {
- 'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-secondary) / 50%)',
- },
-
- '& > input[orient="vertical"]:active::-moz-range-thumb': {
- 'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-tertiary) / 50%)',
- },
-
- '&[data-chrome] > input + *': {
- 'padding': '0 0.5em',
- 'height': '1.5em',
- },
-
- '&[data-firefox="horizontal"] > input + *': {
- 'padding': '0 0.5em',
- 'height': '1.5em',
- },
-
- '&[data-firefox="vertical"] > input + *': {
- 'padding': '0.5em 0',
- 'width': '1.5em',
- },
- },
- });
- };
-
- /**
- * Component for inputting continuous numeric values.
- */
- export const Slider = React.forwardRef<SliderDerivedElement, SliderProps>((
- {
- className,
- style,
- children,
- orient = 'horizontal',
- length,
- min = 0,
- max = 100,
- ...etcProps
- },
- forwardedRef,
- ) => {
- const browser = useBrowser();
- const defaultRef = React.useRef<SliderDerivedElement>(null);
- const ref = forwardedRef ?? defaultRef;
- const tickMarkId = React.useId();
-
- React.useEffect(() => {
- const setupGecko = (theOrient: string, theChildren: unknown, theBrowser?: string) => {
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: slider } = ref;
- if (!slider) {
- return;
- }
-
- const isFirefox = theBrowser === 'firefox';
- const wrapper = slider?.parentElement as HTMLElement;
- const parent = wrapper?.parentElement as HTMLElement;
- const grandParent = parent?.parentElement as HTMLElement;
- if (isFirefox) {
- slider.setAttribute('orient', theOrient);
- wrapper.dataset[theBrowser] = theOrient;
- wrapper.removeAttribute('data-orient');
- grandParent.style.width = theChildren ? '2.5em' : '1em';
- }
- };
-
- setupGecko(orient, children, browser);
-
- return () => {
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: slider } = ref;
- if (!slider) {
- return;
- }
-
- const isFirefox = browser === 'firefox';
- const wrapper = slider?.parentElement as HTMLElement;
- const parent = wrapper?.parentElement as HTMLElement;
- const grandParent = parent?.parentElement as HTMLElement;
- if (slider && isFirefox && parent && grandParent && wrapper) {
- grandParent.style.width = 'auto';
- wrapper.removeAttribute(`data-${browser}`);
- wrapper.dataset.orient = slider.getAttribute(orient) ?? undefined;
- slider.removeAttribute('orient');
- }
- };
- }, [ref, orient, children, browser]);
-
- React.useEffect(() => {
- let shouldEffectExecute: boolean;
- const setupNonGecko = (theOrient: string, theBrowser?: string) => {
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: slider } = ref;
- if (!slider) {
- return;
- }
-
- if (!theBrowser) {
- return;
- }
-
- const wrapper = slider?.parentElement as HTMLElement;
- const parent = wrapper?.parentElement as HTMLElement;
- const grandParent = parent?.parentElement as HTMLElement;
-
- const isNotFirefox = theBrowser !== 'firefox';
- if (isNotFirefox) {
- wrapper.dataset[theBrowser] = theOrient;
- }
-
- shouldEffectExecute = isNotFirefox && theOrient === 'vertical' && Boolean(slider) && Boolean(parent) && Boolean(grandParent);
- if (shouldEffectExecute) {
- const trueHeight = parent.clientWidth;
- const trueWidth = parent.clientHeight;
-
- grandParent.dataset.height = grandParent.clientHeight.toString();
- grandParent.dataset.width = grandParent.clientWidth.toString();
- grandParent.style.height = `${trueHeight}px`;
- grandParent.style.width = `${trueWidth}px`;
- }
- };
-
- setupNonGecko(orient, browser);
-
- return () => {
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: slider } = ref;
- if (!slider) {
- return;
- }
-
- const wrapper = slider?.parentElement as HTMLElement;
- const parent = wrapper?.parentElement as HTMLElement;
- const grandParent = parent?.parentElement as HTMLElement;
-
- if (shouldEffectExecute) {
- grandParent.style.height = `${grandParent.dataset.height ?? 0}px`;
- grandParent.style.width = `${grandParent.dataset.width ?? 0}px`;
- grandParent.removeAttribute('data-height');
- grandParent.removeAttribute('data-width');
- }
- wrapper.removeAttribute(`data-${browser ?? 'unknown'}`);
- };
- }, [ref, orient, browser]);
-
- React.useEffect(() => {
- if (!(typeof ref === 'object' && ref)) {
- return;
- }
- const { current: slider } = ref;
- if (!slider) {
- return;
- }
-
- const isFirefox = browser === 'firefox';
- const isNotFirefox = typeof browser === 'string' && browser !== 'firefox';
- const tickMarkContainer = slider.nextElementSibling;
- if (tickMarkContainer) {
- const tickMarks = tickMarkContainer.children[0].children;
- Array.from(tickMarks).forEach((tickMarkRaw) => {
- const tickMark = tickMarkRaw as HTMLElement;
- const offset = tickMark.dataset.offset as string;
- const tickMarkWrapper = tickMark.children[0] as HTMLElement;
- const tickMarkLine = tickMarkWrapper.children[0] as HTMLElement;
- const tickMarkLabel = tickMarkLine.nextElementSibling as HTMLElement | null;
- if (isNotFirefox) {
- tickMark.style.left = offset;
- tickMark.style.bottom = '';
- tickMarkWrapper.style.translate = '-50%';
- tickMarkWrapper.style.flexDirection = 'column';
- tickMarkLine.style.width = '1px';
- tickMarkLine.style.height = '0.5em';
- if (tickMarkLabel) {
- tickMarkLabel.style.writingMode = 'initial';
- }
- } else if (isFirefox && orient === 'horizontal') {
- tickMark.style.left = offset;
- tickMark.style.bottom = '';
- tickMarkWrapper.style.translate = '-50%';
- tickMarkWrapper.style.flexDirection = 'column';
- tickMarkLine.style.width = '1px';
- tickMarkLine.style.height = '0.5em';
- if (tickMarkLabel) {
- tickMarkLabel.style.writingMode = 'initial';
- }
- } else {
- tickMark.style.bottom = offset;
- tickMark.style.left = '';
- tickMarkWrapper.style.translate = '0 50%';
- tickMarkWrapper.style.flexDirection = 'row';
- tickMarkLine.style.width = '0.5em';
- tickMarkLine.style.height = '1px';
- if (tickMarkLabel) {
- tickMarkLabel.style.writingMode = 'sideways-lr';
- }
- }
- });
- }
- }, [ref, orient, browser, children]);
-
- const block = typeof length === 'string' && length.trim() === '100%';
- const minValue = Number(min);
- const maxValue = Number(max);
-
- if (minValue >= maxValue) {
- return null;
- }
-
- const childrenValues = children
- ? filterOptions(children)
- : undefined;
-
- return (
- <>
- {children
- && (
- <datalist
- id={tickMarkId}
- >
- {children}
- </datalist>
- )}
- <div
- className={clsx(
- block && orient !== 'vertical' && 'w-full',
- !block && 'align-middle',
- className,
- orient !== 'vertical' && 'inline-block min-h-[1rem]',
- orient === 'vertical' && 'inline-block min-w-[1rem]',
- orient !== 'vertical' && !block && 'min-w-[16rem]',
- )}
- style={style}
- >
- <div
- style={{
- width: orient === 'vertical' ? (length ?? '16rem') : undefined,
- position: 'relative',
- }}
- >
- <div
- className="flex slider"
- data-orient={orient}
- >
- <SliderDerivedElementComponent
- {...etcProps}
- ref={ref}
- min={minValue}
- max={maxValue}
- type="range"
- list={childrenValues ? tickMarkId : undefined}
- className={clsx(
- 'w-full h-full bg-inherit block text-primary ring-secondary/50 rounded-full',
- 'focus:text-secondary focus:outline-0 focus:ring-4',
- 'active:text-tertiary active:ring-tertiary/50',
- )}
- />
- {Array.isArray(childrenValues) && (
- <div className="select-none">
- <div className="relative w-full h-full">
- {
- childrenValues
- .filter((c) => {
- const value = Number(c.props.value);
- return minValue <= value && value <= maxValue;
- })
- .map((c) => {
- const value = Number(c.props.value);
- return (
- <div
- key={c.props.value}
- className="absolute w-full h-full box-border"
- data-offset={`${((value - minValue) / (maxValue - minValue)) * 100}%`}
- >
- <div
- className="flex h-full text-xs items-center justify-between"
- >
- <div
- className="bg-current"
- />
- <div
- className={c.props.className}
- >
- {c.props.children}
- </div>
- </div>
- </div>
- );
- })
- }
- </div>
- </div>
- )}
- </div>
- </div>
- </div>
- </>
- );
- });
-
- Slider.displayName = 'Slider';
-
- Slider.defaultProps = {
- orient: 'horizontal',
- length: undefined,
- children: undefined,
- };
|