// // Copyright 2023 DXOS.org // import { createContext } from '@radix-ui/react-context'; import { Root as DialogRoot, DialogContent, DialogTitle } from '@radix-ui/react-dialog'; import { Primitive } from '@radix-ui/react-primitive'; import { Slot } from '@radix-ui/react-slot'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import React, { type ComponentPropsWithRef, type Dispatch, forwardRef, type PropsWithChildren, type SetStateAction, useCallback, useEffect, useRef, useState, type KeyboardEvent, type ComponentPropsWithoutRef, } from 'react'; import { log } from '@dxos/log'; import { useMediaQuery, useForwardedRef } from '@dxos/react-hooks'; import { useSwipeToDismiss } from './useSwipeToDismiss'; import { useThemeContext } from '../../hooks'; import { type ThemedClassName } from '../../util'; import { type Label, toLocalizedString, useTranslation } from '../ThemeProvider'; const MAIN_ROOT_NAME = 'MainRoot'; const NAVIGATION_SIDEBAR_NAME = 'NavigationSidebar'; const COMPLEMENTARY_SIDEBAR_NAME = 'ComplementarySidebar'; const MAIN_NAME = 'Main'; const GENERIC_CONSUMER_NAME = 'GenericConsumer'; type SidebarState = 'expanded' | 'collapsed' | 'closed'; type MainContextValue = { resizing: boolean; navigationSidebarState: SidebarState; setNavigationSidebarState: Dispatch>; complementarySidebarState: SidebarState; setComplementarySidebarState: Dispatch>; }; const landmarkAttr = 'data-main-landmark'; /** * Facilitates moving focus between landmarks. * Ref https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/landmark_role */ const useLandmarkMover = (propsOnKeyDown: ComponentPropsWithoutRef<'div'>['onKeyDown'], landmark: string) => { const handleKeyDown = useCallback( (event: KeyboardEvent) => { const target = event.target as HTMLDivElement; if (event.target === event.currentTarget && event.key === 'Tab' && target.hasAttribute(landmarkAttr)) { event.preventDefault(); const landmarks = Array.from(document.querySelectorAll(`[${landmarkAttr}]:not([inert])`)) .map((el) => (el.hasAttribute(landmarkAttr) ? parseInt(el.getAttribute(landmarkAttr)!) : NaN)) .sort(); const l = landmarks.length; const cursor = landmarks.indexOf(parseInt(target.getAttribute(landmarkAttr)!)); const nextLandmark = landmarks[(cursor + l + (event.getModifierState('Shift') ? -1 : 1)) % l]; (document.querySelector(`[${landmarkAttr}="${nextLandmark}"]`) as HTMLDivElement | null)?.focus(); } propsOnKeyDown?.(event); }, [propsOnKeyDown], ); const focusableGroupAttrs = window ? {} : { tabBehavior: 'limited', ignoreDefaultKeydown: { Tab: true } }; return { onKeyDown: handleKeyDown, [landmarkAttr]: landmark, tabIndex: 0, ...focusableGroupAttrs }; }; const [MainProvider, useMainContext] = createContext(MAIN_NAME, { resizing: false, navigationSidebarState: 'closed', setNavigationSidebarState: (nextState) => { // TODO(burdon): Standardize with other context missing errors using raise. log.warn('Attempt to set sidebar state without initializing `MainRoot`'); }, complementarySidebarState: 'closed', setComplementarySidebarState: (nextState) => { // TODO(burdon): Standardize with other context missing errors using raise. log.warn('Attempt to set sidebar state without initializing `MainRoot`'); }, }); const useSidebars = (consumerName = GENERIC_CONSUMER_NAME) => { const { setNavigationSidebarState, navigationSidebarState, setComplementarySidebarState, complementarySidebarState } = useMainContext(consumerName); return { navigationSidebarState, setNavigationSidebarState, toggleNavigationSidebar: useCallback( () => setNavigationSidebarState(navigationSidebarState === 'expanded' ? 'closed' : 'expanded'), [navigationSidebarState, setNavigationSidebarState], ), openNavigationSidebar: useCallback(() => setNavigationSidebarState('expanded'), []), collapseNavigationSidebar: useCallback(() => setNavigationSidebarState('collapsed'), []), closeNavigationSidebar: useCallback(() => setNavigationSidebarState('closed'), []), complementarySidebarState, setComplementarySidebarState, toggleComplementarySidebar: useCallback( () => setComplementarySidebarState(complementarySidebarState === 'expanded' ? 'closed' : 'expanded'), [complementarySidebarState, setComplementarySidebarState], ), openComplementarySidebar: useCallback(() => setComplementarySidebarState('expanded'), []), collapseComplementarySidebar: useCallback(() => setComplementarySidebarState('collapsed'), []), closeComplementarySidebar: useCallback(() => setComplementarySidebarState('closed'), []), }; }; type MainRootProps = PropsWithChildren<{ navigationSidebarState?: SidebarState; defaultNavigationSidebarState?: SidebarState; onNavigationSidebarStateChange?: (nextState: SidebarState) => void; complementarySidebarState?: SidebarState; defaultComplementarySidebarState?: SidebarState; onComplementarySidebarStateChange?: (nextState: SidebarState) => void; }>; const resizeDebounce = 3000; const MainRoot = ({ navigationSidebarState: propsNavigationSidebarState, defaultNavigationSidebarState, onNavigationSidebarStateChange, complementarySidebarState: propsComplementarySidebarState, defaultComplementarySidebarState, onComplementarySidebarStateChange, children, ...props }: MainRootProps) => { const [isLg] = useMediaQuery('lg', { ssr: false }); const [navigationSidebarState = isLg ? 'expanded' : 'collapsed', setNavigationSidebarState] = useControllableState({ prop: propsNavigationSidebarState, defaultProp: defaultNavigationSidebarState, onChange: onNavigationSidebarStateChange, }); const [complementarySidebarState = isLg ? 'expanded' : 'collapsed', setComplementarySidebarState] = useControllableState({ prop: propsComplementarySidebarState, defaultProp: defaultComplementarySidebarState, onChange: onComplementarySidebarStateChange, }); const [resizing, setResizing] = useState(false); const resizeInterval = useRef | null>(null); const handleResize = useCallback(() => { setResizing(true); if (resizeInterval.current) { clearTimeout(resizeInterval.current); } resizeInterval.current = setTimeout(() => { setResizing(false); resizeInterval.current = null; }, resizeDebounce); }, []); useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [handleResize]); return ( {children} ); }; MainRoot.displayName = MAIN_ROOT_NAME; const handleOpenAutoFocus = (event: Event) => { !document.body.hasAttribute('data-is-keyboard') && event.preventDefault(); }; type MainSidebarProps = ThemedClassName> & { swipeToDismiss?: boolean; state?: SidebarState; resizing?: boolean; onStateChange?: (nextState: SidebarState) => void; side: 'inline-start' | 'inline-end'; label: Label; }; const MainSidebar = forwardRef( ( { classNames, children, swipeToDismiss, onOpenAutoFocus, state, resizing, onStateChange, side, label, ...props }, forwardedRef, ) => { const [isLg] = useMediaQuery('lg', { ssr: false }); const { tx } = useThemeContext(); const { t } = useTranslation(); const ref = useForwardedRef(forwardedRef); const noopRef = useRef(null); useSwipeToDismiss(swipeToDismiss ? ref : noopRef, { onDismiss: () => onStateChange?.('closed'), }); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { ((event.target as HTMLDivElement).closest('[data-tabster]') as HTMLDivElement)?.focus(); } props.onKeyDown?.(event); }, [props.onKeyDown], ); const Root = isLg ? Primitive.div : DialogContent; return ( {!isLg && {toLocalizedString(label, t)}} {children} ); }, ); type MainNavigationSidebarProps = Omit; const MainNavigationSidebar = forwardRef((props, forwardedRef) => { const { navigationSidebarState, setNavigationSidebarState, resizing } = useMainContext(NAVIGATION_SIDEBAR_NAME); const mover = useLandmarkMover(props.onKeyDown, '0'); return ( ); }); MainNavigationSidebar.displayName = NAVIGATION_SIDEBAR_NAME; type MainComplementarySidebarProps = Omit; const MainComplementarySidebar = forwardRef((props, forwardedRef) => { const { complementarySidebarState, setComplementarySidebarState, resizing } = useMainContext(COMPLEMENTARY_SIDEBAR_NAME); const mover = useLandmarkMover(props.onKeyDown, '2'); return ( ); }); MainNavigationSidebar.displayName = NAVIGATION_SIDEBAR_NAME; type MainProps = ThemedClassName> & { asChild?: boolean; bounce?: boolean; handlesFocus?: boolean; }; const MainContent = forwardRef( ({ asChild, classNames, bounce, handlesFocus, children, role, ...props }: MainProps, forwardedRef) => { const { navigationSidebarState, complementarySidebarState } = useMainContext(MAIN_NAME); const { tx } = useThemeContext(); const Root = asChild ? Slot : role ? 'div' : 'main'; const mover = useLandmarkMover(props.onKeyDown, '1'); return ( {children} ); }, ); MainContent.displayName = MAIN_NAME; type MainOverlayProps = ThemedClassName, 'children'>>; const MainOverlay = forwardRef(({ classNames, ...props }, forwardedRef) => { const [isLg] = useMediaQuery('lg', { ssr: false }); const { navigationSidebarState, setNavigationSidebarState, complementarySidebarState, setComplementarySidebarState } = useMainContext(MAIN_NAME); const { tx } = useThemeContext(); return (
{ setNavigationSidebarState('collapsed'); setComplementarySidebarState('collapsed'); }} {...props} className={tx( 'main.overlay', 'main__overlay', { isLg, inlineStartSidebarOpen: navigationSidebarState, inlineEndSidebarOpen: complementarySidebarState }, classNames, )} data-state={navigationSidebarState === 'expanded' || complementarySidebarState === 'expanded' ? 'open' : 'closed'} aria-hidden='true' ref={forwardedRef} /> ); }); export const Main = { Root: MainRoot, Content: MainContent, Overlay: MainOverlay, NavigationSidebar: MainNavigationSidebar, ComplementarySidebar: MainComplementarySidebar, }; export { useMainContext, useSidebars, useLandmarkMover }; export type { MainRootProps, MainProps, MainOverlayProps, MainNavigationSidebarProps, SidebarState };