src/segmented-control/segmented-control.tsx (364 lines of code) (raw):

/* Copyright (c) Uber Technologies, Inc. This source code is licensed under the MIT license found in the LICENSE file in the root directory of this source tree. */ /* global window */ import * as React from 'react'; import { useUID } from 'react-uid'; import { useStyletron } from '../styles'; import { getOverrides } from '../helpers/overrides'; import { isFocusVisible, forkFocus, forkBlur } from '../utils/focusVisible'; import { FILL } from './constants'; import { StyledRoot, StyledSegment, StyledArtworkContainer, StyledActive, StyledSegmentList, StyledLabel, StyledDescription, StyledLabelBlock, StyledBadge, StyledBadgeHint, } from './styled-components'; import { getSegmentId, isRTL } from './utils'; import type { SegmentedControlProps } from './types'; import type { SyntheticEvent } from 'react'; const KEYBOARD_ACTION = { next: 'next', previous: 'previous', } as const; const getLayoutParams = (el) => { if (!el) { return { length: 0, distance: 0, }; } let { width } = el.getBoundingClientRect(); width = Math.floor(width); // Note we're using getBoundingClientRect to take into account // the borders. return { length: width, distance: el.offsetLeft, }; }; const scrollParentToCentreTarget = (targetNode) => { const { x: parentX, y: parentY, width: parentWidth, height: parentHeight, } = targetNode.parentNode.getBoundingClientRect(); const { x: childX, y: childY, width: childWidth, height: childHeight, } = targetNode.getBoundingClientRect(); // get the position of the child centre, relative to parent const childCentre = { x: childX - parentX + childWidth / 2, y: childY - parentY + childHeight / 2, }; // aim for the centre of the child to be the centre of the parent const { scrollLeft, scrollTop } = targetNode.parentNode; const target = { x: scrollLeft + childCentre.x - parentWidth / 2, y: scrollTop + childCentre.y - parentHeight / 2, }; // ignore out of bounds, the browser will manage this for us targetNode.parentNode.scroll(target.x, target.y); }; export function SegmentedControl({ activeKey = '0', disabled = false, children, fill = FILL.intrinsic, activateOnFocus = true, onChange, overrides = {}, // @ts-expect-error todo(ts-migration) TS2322 Type 'null' is not assignable to type 'string'. uid: customUid = null, width, height, }: SegmentedControlProps) { // Create unique id prefix for this segments component const generatedUid = useUID(); const uid = customUid || generatedUid; // Unpack overrides const { Root: RootOverrides, Active: ActiveOverrides } = overrides; const [Root, RootProps] = getOverrides(RootOverrides, StyledRoot); const [Active, ActiveProps] = getOverrides(ActiveOverrides, StyledActive); const [SegmentList, SegmentListProps] = getOverrides(overrides.SegmentList, StyledSegmentList); // Count key updates // We disable a few things until after first mount: // - the highlight animation, avoiding an initial slide-in // - smooth scrolling active segment into view const [keyUpdated, setKeyUpdated] = React.useState(0); React.useEffect(() => { setKeyUpdated(keyUpdated + 1); }, [activeKey]); // Positioning the highlight. const activeSegmentRef = React.useRef<HTMLElement>(); const [highlightLayout, setHighlightLayout] = React.useState({ length: 0, distance: 0, }); // Create a shared, memoized callback for segments to call on resize. const updateHighlight = React.useCallback(() => { if (activeSegmentRef.current) { setHighlightLayout(getLayoutParams(activeSegmentRef.current)); } }, [activeSegmentRef.current]); // Update highlight on key React.useEffect(updateHighlight, [activeSegmentRef.current]); // Scroll active segment into view when the parent has scrollbar on mount and // on key change (smooth scroll). Note, if the active key changes while // the segment is not in view, the page will scroll it into view. // TODO: replace with custom scrolling logic. React.useEffect(() => { // Flow needs this condition pulled out. if (activeSegmentRef.current) { if ( // @ts-expect-error todo(flow->ts) maybe parentElement? activeSegmentRef.current.parentNode.scrollWidth > // @ts-expect-error todo(flow->ts) maybe parentElement? activeSegmentRef.current.parentNode.clientWidth ) { if (keyUpdated > 1) { activeSegmentRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest', }); } else { scrollParentToCentreTarget(activeSegmentRef.current); } } } }, [activeSegmentRef.current]); // Collect shared styling props const sharedStylingProps = { $fill: fill, }; // Helper for parsing directional keys // TODO(WPT-6473): move to universal keycode aliases const [, theme] = useStyletron(); const parseKeyDown = React.useCallback( (event: { keyCode: number }) => { if (isRTL(theme.direction)) { switch (event.keyCode) { case 39: return KEYBOARD_ACTION.previous; case 37: return KEYBOARD_ACTION.next; default: return null; } } else { switch (event.keyCode) { case 37: return KEYBOARD_ACTION.previous; case 39: return KEYBOARD_ACTION.next; default: return null; } } }, [theme.direction] ); return ( <Root {...sharedStylingProps} {...RootProps} $width={width} $height={height}> <SegmentList data-baseweb="segmented-list" role="listbox" aria-label="segmented control" {...SegmentListProps} > {React.Children.map(children, (child: React.ReactElement, index) => { if (!child) return; return ( <InternalSegment childKey={child.key} childIndex={index} activeKey={activeKey} activeSegmentRef={activeSegmentRef} updateHighlight={updateHighlight} parseKeyDown={parseKeyDown} activateOnFocus={activateOnFocus} uid={uid} disabled={disabled} sharedStylingProps={sharedStylingProps} onChange={onChange} setKeyUpdated={setKeyUpdated} {...child.props} /> ); })} <Active data-baseweb="segment-highlight" $length={highlightLayout.length} $distance={highlightLayout.distance} // This avoids the segment sliding in from the side on mount $animate={keyUpdated > 1} aria-hidden="true" role="presentation" {...sharedStylingProps} {...ActiveProps} /> </SegmentList> </Root> ); } function InternalSegment({ childKey, childIndex, activeKey, activeSegmentRef, updateHighlight, parseKeyDown, activateOnFocus, uid, disabled, sharedStylingProps, onChange, setKeyUpdated, ...props }) { const key = childKey || String(childIndex); const isActive = key == activeKey; const { artwork: Artwork, overrides = {}, segmentRef, onClick, label, description, badge, badgeHint, ...restProps } = props; // A way to share our internal activeSegmentRef via the "segmentRef" prop. const ref = React.useRef(); React.useImperativeHandle(segmentRef, () => { return isActive ? activeSegmentRef.current : ref.current; }); // Track segment dimensions in a ref after each render // This is used to compare params when the resize observer fires const segmentLayoutParams = React.useRef({ length: 0, distance: 0 }); React.useEffect(() => { segmentLayoutParams.current = getLayoutParams( isActive ? activeSegmentRef.current : ref.current ); }); // We need to potentially update the active segment highlight when the width or // placement changes for a segment so we listen for resize updates in each segment. React.useEffect(() => { if (window.ResizeObserver) { const observer = new window.ResizeObserver((entries) => { if (entries[0] && entries[0].target) { const segmentLayoutParamsAfterResize = getLayoutParams(entries[0].target); if ( segmentLayoutParamsAfterResize.length !== segmentLayoutParams.current.length || segmentLayoutParamsAfterResize.distance !== segmentLayoutParams.current.distance ) { setKeyUpdated(1); updateHighlight(); } } }); observer.observe(isActive ? activeSegmentRef.current : ref.current); return () => { observer.disconnect(); }; } }, [activeKey]); React.useEffect(updateHighlight, [label]); // Collect overrides const { Segment: SegmentOverrides, ArtworkContainer: ArtworkContainerOverrides, LabelBlock: LabelBlockContainerOverrides, Label: LabelOverrides, Description: DescriptionOverrides, Badge: BadgeOverrides, BadgeHint: BadgeHintOverrides, } = overrides; const [Segment, SegmentProps] = getOverrides(SegmentOverrides, StyledSegment); const [LabelBlockContainer, LabelBlockContainerProps] = getOverrides( LabelBlockContainerOverrides, StyledLabelBlock ); const [ArtworkContainer, ArtworkContainerProps] = getOverrides( ArtworkContainerOverrides, StyledArtworkContainer ); const [LabelContainer, LabelContainerProps] = getOverrides(LabelOverrides, StyledLabel); const [DescriptionContainer, DescriptionContainerProps] = getOverrides( DescriptionOverrides, StyledDescription ); const [BadgeContainer, BadgeContainerProps] = getOverrides(BadgeOverrides, StyledBadge); const [BadgeHintContainer, BadgeHintContainerProps] = getOverrides( BadgeHintOverrides, StyledBadgeHint ); // Keyboard focus styling const [focusVisible, setFocusVisible] = React.useState(false); const handleFocus = React.useCallback((event: SyntheticEvent) => { if (isFocusVisible(event)) { setFocusVisible(true); } }, []); const handleBlur = React.useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars (event: SyntheticEvent) => { if (focusVisible !== false) { setFocusVisible(false); } }, [focusVisible] ); // Keyboard focus management // @ts-expect-error todo(flow->ts): deps are required const handleKeyDown = React.useCallback((event) => { // WAI-ARIA 1.1 // https://www.w3.org/TR/wai-aria-practices-1.1/#segmentpanel // We use directional keys to iterate focus through SegmentedControl. // Find all segments eligible for focus const availableSegmentedControl = [...event.target.parentNode.childNodes].filter( (node) => !node.disabled && node.getAttribute('role') === 'option' ); // Exit early if there are no other segments available if (availableSegmentedControl.length === 1) return; // Find segment to focus, looping to start/end of list if necessary const currentSegmentIndex = availableSegmentedControl.indexOf(event.target); const action = parseKeyDown(event); if (action) { let nextSegment: HTMLButtonElement | undefined | null; if (action === KEYBOARD_ACTION.previous) { if (availableSegmentedControl[currentSegmentIndex - 1]) { nextSegment = availableSegmentedControl[currentSegmentIndex - 1]; } else { nextSegment = availableSegmentedControl[availableSegmentedControl.length - 1]; } } else if (action === KEYBOARD_ACTION.next) { if (availableSegmentedControl[currentSegmentIndex + 1]) { nextSegment = availableSegmentedControl[currentSegmentIndex + 1]; } else { nextSegment = availableSegmentedControl[0]; } } if (nextSegment) { // Focus the segment nextSegment.focus(); // Optionally activate the segment if (activateOnFocus) { nextSegment.click(); } } } }); return ( <Segment data-baseweb="segment" key={key} id={getSegmentId(uid, key)} role="option" onKeyDown={handleKeyDown} aria-selected={isActive} tabIndex={isActive ? '0' : '-1'} ref={isActive ? activeSegmentRef : ref} disabled={!isActive && disabled} type="button" // so it doesn't trigger a submit when used inside forms $focusVisible={focusVisible} $isActive={isActive} $hasArtwork={!!Artwork} $hasLabel={!!label} {...sharedStylingProps} {...restProps} {...SegmentProps} onClick={(event) => { if (typeof onChange === 'function') onChange({ activeKey: key }); if (typeof onClick === 'function') onClick(event); }} onFocus={forkFocus({ ...restProps, ...SegmentProps }, handleFocus)} onBlur={forkBlur({ ...restProps, ...SegmentProps }, handleBlur)} > <LabelBlockContainer {...LabelBlockContainerProps}> {!!Artwork && ( <ArtworkContainer data-baseweb="artwork-container" {...sharedStylingProps} {...ArtworkContainerProps} > <Artwork size={20} color="contentPrimary" /> </ArtworkContainer> )} {!!label && <LabelContainer {...LabelContainerProps}>{label ? label : key}</LabelContainer>} {!!badge && <BadgeContainer {...BadgeContainerProps}>{badge}</BadgeContainer>} {badgeHint && <BadgeHintContainer {...BadgeHintContainerProps} />} </LabelBlockContainer> {description ? ( <DescriptionContainer {...DescriptionOverrides} {...DescriptionContainerProps}> {description} </DescriptionContainer> ) : null} </Segment> ); }