import { Children, cloneElement, forwardRef, isValidElement, useId, useMemo, type ReactElement, type ReactNode, } from 'react'; import { Platform, StyleSheet, Text, View, type ViewProps, type ViewStyle } from 'react-native'; import { SliderRoot as SliderRootPrimitive, SliderRail as SliderRailPrimitive, SliderThumb as SliderThumbPrimitive, Slot, Portal, createPortalFn, useAnchorPosition, dataAttributes, getMarkerPositionStyle, getMarkerStepValues, mergeDataAttributes, percentToFillExtent, useSliderContext, valueToPercent, type ISliderProps, type ISliderRailProps, type ISliderThumbProps, type SliderOrientation, } from '@cdx-ui/primitives'; import { cn, ParentContext, useParentContext, useStyleContext } from '@cdx-ui/utils'; import { sliderMarkerVariants, sliderPopupBaseVariants, sliderRootVariants, sliderRailVariants, sliderRailColorVariants, sliderTrackColorVariants, sliderTrackVariants, sliderThumbVariants, type SliderColor, type SliderVariantProps, } from './styles'; import { getSliderPopupAnchorStyle, SLIDER_POPUP_Z_INDEX } from './getSliderPopupPosition'; const SCOPE = 'SLIDER'; const useSliderStyleContext = () => { const { color, orientation } = useStyleContext(SCOPE) as Pick< SliderVariantProps, 'color' | 'orientation' >; return { color, orientation: orientation ?? 'horizontal', }; }; export interface SliderProps extends Omit { className?: string; /** * Semantic color token for the track fill and thumb border. * @default `action` */ color?: SliderColor; orientation?: SliderOrientation; children?: ReactNode; } const SliderRoot = forwardRef( ( { className, style, color = 'action', orientation = 'horizontal', children, isDisabled, ...props }, ref, ) => { const parent = useParentContext(); const ctx = useMemo( () => ({ ...parent, [SCOPE]: { color, orientation }, }), [color, orientation, parent], ); const computedClassName = cn(sliderRootVariants({ orientation }), className); return ( {children} ); }, ); SliderRoot.displayName = 'Slider'; export interface SliderRailProps extends ISliderRailProps { className?: string; children?: ReactNode; } const SliderRail = forwardRef( ({ className, style, children, ...props }, ref) => { const { orientation, color } = useSliderStyleContext(); const computedClassName = cn( sliderRailVariants({ orientation }), sliderRailColorVariants({ color }), className, ); return ( {children} ); }, ); SliderRail.displayName = 'Slider.Rail'; export interface SliderTrackProps extends ViewProps { asChild?: boolean; className?: string; children?: ReactNode; } const SliderTrack = forwardRef( ({ asChild, className, style, children, testID, ...props }, ref) => { const { color, orientation } = useSliderStyleContext(); const { percent, isDragging, trackLayout, thumbSize } = useSliderContext(); const fillStyle = useMemo((): ViewStyle => { const trackSize = trackLayout == null ? 0 : orientation === 'horizontal' ? trackLayout.width : trackLayout.height; if (trackSize <= 0) { return orientation === 'vertical' ? { position: 'absolute', left: 0, right: 0, bottom: 0, height: 0 } : { position: 'absolute', top: 0, bottom: 0, left: 0, width: 0 }; } const extent = percentToFillExtent(percent, trackSize, thumbSize); if (orientation === 'vertical') { return { position: 'absolute', left: 0, right: 0, bottom: 0, height: extent, }; } return { position: 'absolute', top: 0, bottom: 0, left: 0, width: extent, }; }, [orientation, percent, thumbSize, trackLayout]); const computedClassName = cn( sliderTrackVariants({ orientation }), sliderTrackColorVariants({ color }), className, ); const Comp = asChild ? Slot : View; return ( {children} ); }, ); SliderTrack.displayName = 'Slider.Track'; export interface SliderThumbProps extends ISliderThumbProps { className?: string; children?: ReactNode; } const SliderThumb = forwardRef( ({ className, style, children, ...props }, ref) => { const { color } = useSliderStyleContext(); const computedClassName = cn(sliderThumbVariants({ color }), className); return ( {children} ); }, ); SliderThumb.displayName = 'Slider.Thumb'; function dataOrientation(orientation: 'horizontal' | 'vertical' | null | undefined) { return { 'data-orientation': orientation ?? 'horizontal' } as const; } function renderPopupLabel(children: ReactNode, getValueLabel: () => string) { if (children == null || typeof children === 'string' || typeof children === 'number') { return ( {children ?? getValueLabel()} ); } return children; } export interface SliderPopupProps extends ViewProps { asChild?: boolean; className?: string; children?: ReactNode; } const SliderPopup = forwardRef( ({ asChild, className, style, children, ...props }, ref) => { const { orientation } = useSliderStyleContext(); const { isDragging, isHovered, isFocused, getValueLabel, thumbRef, percent } = useSliderContext(); const portalName = useId(); const visible = isDragging || isHovered || isFocused; const anchorLayout = useAnchorPosition(thumbRef, visible, { remeasureKey: percent, trackWhileOpen: true, }); if (!visible || anchorLayout == null) { return null; } const label = renderPopupLabel(children, getValueLabel); const computedClassName = cn(sliderPopupBaseVariants(), className); const Comp = asChild ? Slot : View; const anchorStyle = getSliderPopupAnchorStyle(anchorLayout, orientation); const popup = ( {label} ); if (Platform.OS === 'web' && createPortalFn && typeof document !== 'undefined') { return createPortalFn(popup, document.body); } return ( {popup} ); }, ); SliderPopup.displayName = 'Slider.Popup'; export interface SliderMarkerProps extends ViewProps { asChild?: boolean; className?: string; value?: number; children?: ReactNode; } const SliderMarker = forwardRef( ({ asChild, className, style, value, testID, ...props }, ref) => { const { value: currentValue, min, max, orientation, trackLayout, thumbSize, } = useSliderContext(); const markerStyle = useMemo(() => { if (value == null) { return undefined; } const percent = valueToPercent(value, min, max); return getMarkerPositionStyle(percent, orientation, trackLayout, thumbSize); }, [max, min, orientation, thumbSize, trackLayout, value]); const isFilled = value != null && value <= currentValue; const computedClassName = cn(sliderMarkerVariants({ filled: isFilled }), className); const Comp = asChild ? Slot : View; return ( ); }, ); SliderMarker.displayName = 'Slider.Marker'; function isSliderMarkerElement(child: unknown): child is ReactElement { if (!isValidElement(child)) { return false; } const type = child.type as { displayName?: string }; return type.displayName === 'Slider.Marker'; } export interface SliderMarkerGroupProps extends ViewProps { asChild?: boolean; className?: string; children?: ReactNode; } const SliderMarkerGroup = forwardRef( ({ asChild, className, style, children, testID, ...props }, ref) => { const { min, max, step } = useSliderContext(); const markers = useMemo(() => { if (step <= 0 || max <= min) { return null; } const childList = Children.toArray(children); if (childList.length === 0) { return null; } const templateMarker = childList.find( (child): child is ReactElement => isSliderMarkerElement(child) && child.props.value == null, ); if (templateMarker) { return getMarkerStepValues(min, max, step).map((markerValue) => cloneElement(templateMarker, { key: markerValue, value: markerValue }), ); } return childList; }, [children, max, min, step]); if (!markers) { return null; } const Comp = asChild ? Slot : View; return ( {markers} ); }, ); SliderMarkerGroup.displayName = 'Slider.MarkerGroup'; type SliderCompoundComponent = typeof SliderRoot & { Rail: typeof SliderRail; Track: typeof SliderTrack; Thumb: typeof SliderThumb; Popup: typeof SliderPopup; MarkerGroup: typeof SliderMarkerGroup; Marker: typeof SliderMarker; }; export const Slider = Object.assign(SliderRoot, { Rail: SliderRail, Track: SliderTrack, Thumb: SliderThumb, Popup: SliderPopup, MarkerGroup: SliderMarkerGroup, Marker: SliderMarker, }) as SliderCompoundComponent; export type { SliderColor, SliderVariantProps };