import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import isNull from 'lodash/isNull'; import once from 'lodash/once'; import { css, cx } from '@leafygreen-ui/emotion'; import { useDynamicRefs, useIdAllocator } from '@leafygreen-ui/hooks'; import { useDarkMode, useUsingKeyboardContext, } from '@leafygreen-ui/leafygreen-provider'; import { isComponentType } from '@leafygreen-ui/lib'; import { Overline } from '@leafygreen-ui/typography'; import { SegmentedControlContext } from '../SegmentedControlContext'; import { useEffectOnceOnMount } from '../useEffectOnceOnMount'; import { getLabelStyles, hoverIndicatorStyle, optionsWrapperStyle, selectionIndicatorStyle, wrapperStyle, } from './SegmentedControl.styles'; import { SegmentedControlProps, Size } from './SegmentedControl.types'; /** * Segmented controls act as a toggle between a current state and related states, often changing the view of information within a single page. */ export const SegmentedControl = forwardRef< HTMLDivElement, SegmentedControlProps >(function SegmentedControl( { children, name: nameProp, size = Size.Default, darkMode: darkModeProp, defaultValue, value: controlledValue, onChange, className, label, followFocus = true, 'aria-controls': ariaControls, ...rest }: SegmentedControlProps, forwardedRef, ) { // TODO: log warning if defaultValue is set but does not match any child value const { usingKeyboard } = useUsingKeyboardContext(); const segmentedContainerRef = useRef(null); const [isFocusInComponent, setIsfocusInComponent] = useState(false); const { theme } = useDarkMode(darkModeProp); const getOptionRef = useDynamicRefs({ prefix: 'option' }); const name = useIdAllocator({ prefix: 'segmented-control', id: nameProp, }); // If a value is given, then it's controlled const isControlled = useMemo( () => controlledValue != null, [controlledValue], ); // Keep track of the value internally const [internalValue, setInternalValue] = useState( defaultValue ?? controlledValue, ); const [focusedOptionValue, setFocusedOptionValue] = useState< string | undefined >(defaultValue ?? controlledValue); // If no default or controlled value is given, set it to the first option useEffectOnceOnMount(() => { const firstChild = React.Children.toArray(children)[0]; if ( !internalValue && isComponentType(firstChild, 'SegmentedControlOption') ) { setInternalValue(firstChild.props.value); setFocusedOptionValue(firstChild.props.value); } }); // Check if the organic focus is inside of this component. We'll use this to check if the focus should be programmatically set in SegmentedControlOption. const handleFocusIn = useCallback(() => { if ( segmentedContainerRef.current?.contains( document.activeElement as HTMLElement, ) ) { setIsfocusInComponent(true); } else { setIsfocusInComponent(false); } }, []); useEffect(() => { document.addEventListener('focusin', handleFocusIn); return () => { document.removeEventListener('focusin', handleFocusIn); }; }, [handleFocusIn]); useEffect(() => { if (isControlled) { setInternalValue(controlledValue); } }, [controlledValue, isControlled]); // Handle value updates const updateValue = useCallback( (value: string) => { if (internalValue !== value) { setInternalValue(value); onChange?.(value); } }, [internalValue, onChange], ); /** * Main render function. * Add internal props to children passed in */ const renderedChildren: React.ReactNode = useMemo( () => React.Children.map(children, (child, index) => { if (child == null) { return child; } if (!isComponentType(child, 'SegmentedControlOption')) { errorOnce( `Error in Segmented Control: ${child} is not a SegmentedControlOption`, ); return child; } // Ensure `aria-controls` is set if (!ariaControls && !child.props['aria-controls']) { warnOnce( `The property \`aria-controls\` is required on each Segmented Control option, or on the Segmented Control parent.`, ); } const _id = child.props.id ?? `${name}-${index}`; const _checked: boolean = isControlled ? child.props.value === controlledValue || !!child.props.checked : child.props.value === internalValue; const _focused: boolean = child.props.value === focusedOptionValue; const _onHover = (hovered: boolean) => { if (hovered) setHoveredIndex(index); else setHoveredIndex(null); }; return React.cloneElement(child, { _id, _checked, _focused, _index: index, 'aria-controls': child.props['aria-controls'] ?? ariaControls, _onClick: updateValue, _onHover, ref: getOptionRef(`${index}`), isFocusInComponent, }); }), [ children, isControlled, controlledValue, internalValue, focusedOptionValue, name, ariaControls, updateValue, getOptionRef, isFocusInComponent, ], ); // Maintain a list of child `id`s to link the `tablist` to individual `tab` elements // See https://www.w3.org/TR/wai-aria-1.1/#tab const childrenIdList: string = useMemo(() => { if (renderedChildren) { const ids = React.Children.map(renderedChildren, child => { return (child as React.ReactElement)?.props?._id; })?.join(' '); return ids ?? ''; } return ''; }, [renderedChildren]); // Keep track of which element is hovered const [hoveredIndex, setHoveredIndex] = useState(null); // Keep track of the index of the selected value const selectedIndex = useMemo( () => ( React.Children.toArray(renderedChildren) as Array ).findIndex(child => isControlled ? child.props.value === controlledValue : child.props.value === internalValue, ), [controlledValue, isControlled, renderedChildren, internalValue], ); /** * Focus Management */ // Keep track of the index of the focused value const focusedIndex = useMemo( () => ( React.Children.toArray(renderedChildren) as Array ).findIndex(child => child.props.value === focusedOptionValue), [renderedChildren, focusedOptionValue], ); const updateFocusedIndex = (newIndex: number): void => { const children = ( React.Children.toArray(renderedChildren) as Array ).filter(child => !child.props.disabled); const length = children.length; newIndex = newIndex >= length ? newIndex % length : newIndex < 0 ? length + newIndex : newIndex; const { value } = children[newIndex].props; setFocusedOptionValue(value); }; // When the value changes via click, we update the internal focus tracker so the correct element gets focused on tab press useEffect(() => { if (!usingKeyboard) { setFocusedOptionValue(internalValue); } }, [internalValue, usingKeyboard]); const handleKeyDown = (e: React.KeyboardEvent) => { // Note: Arrow keys don't fire a keyPress event — need to use keyDown e.stopPropagation(); // We only handle right and left arrow keys // Up & down should be left to control scroll switch (e.key) { case 'ArrowRight': updateFocusedIndex(focusedIndex + 1); break; case 'ArrowLeft': updateFocusedIndex(focusedIndex - 1); break; default: break; } }; /** * Dynamic Styles. * Dynamically set the size & position of the selection indicator */ const getIndicatorDynamicStyles = useCallback( (index: number | null = 0) => { if (isNull(index)) return css` width: 0; `; const count = React.Children.count(renderedChildren); const widthPct = (1 / count) * 100; const transformPct = index * 100; return css` opacity: 1; width: calc(${widthPct}% - 2 * var(--wrapper-padding)); transform: translateX( calc(${transformPct}% + ${2 * index + 1} * var(--wrapper-padding)) ); `; }, [renderedChildren], ); /** * Return */ return (
{label && ( {label} )}
{renderedChildren} {/** * The selection and hover indicators are absolutely positioned elements that move underneath the text. * This allows us to achieve the sliding effect. */}
); }); SegmentedControl.displayName = 'SegmentedControl'; const errorOnce = once(console.error); const warnOnce = once(console.warn);