// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; import type { Transition } from 'framer-motion'; import { motion } from 'framer-motion'; import type { ReactNode } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useId, } from 'react'; import type { Selection } from 'react-aria-components'; import { ListBox, ListBoxItem } from 'react-aria-components'; import { getScrollLeftDistance, getScrollRightDistance, useScrollObserver, } from '../../../hooks/useSizeObserver'; import * as log from '../../../logging/log'; import * as Errors from '../../../types/errors'; import { strictAssert } from '../../../util/assert'; import { FunImage } from './FunImage'; /** * Sub Nav */ export type FunSubNavProps = Readonly<{ children: ReactNode; }>; export function FunSubNav(props: FunSubNavProps): JSX.Element { return
{props.children}
; } /** * Sub Nav Scroller */ export type FunSubNavScrollerProps = Readonly<{ children: ReactNode; }>; export function FunSubNavScroller(props: FunSubNavScrollerProps): JSX.Element { const outerRef = useRef(null); const innerRef = useRef(null); const scrollOpacityThreshold = 32; const [scrollLeftDistance, setScrollLeftDistance] = useState(0); const [scrollRightDistance, setScrollRightDistance] = useState(0); useScrollObserver(outerRef, innerRef, scroll => { setScrollLeftDistance( getScrollLeftDistance(scroll, scrollOpacityThreshold) ); setScrollRightDistance( getScrollRightDistance(scroll, scrollOpacityThreshold) ); }); useEffect(() => { strictAssert(outerRef.current, 'Must have scroller ref'); const scroller = outerRef.current; function onWheel(event: WheelEvent) { event.preventDefault(); scroller.scrollBy({ left: event.deltaX + event.deltaY, behavior: 'instant', }); } scroller.addEventListener('wheel', onWheel, { passive: false }); return () => { scroller.addEventListener('wheel', onWheel, { passive: false }); }; }, []); return (
{props.children}
); } /** * Sub Nav Buttons */ export type FunSubNavButtonsProps = Readonly<{ children: ReactNode; }>; export function FunSubNavButtons(props: FunSubNavButtonsProps): JSX.Element { return
{props.children}
; } /** * Sub Nav Button */ export type FunSubNavButtonProps = Readonly<{ onClick: () => void; children: ReactNode; }>; export function FunSubNavButton(props: FunSubNavButtonProps): JSX.Element { return ( ); } /** * Sub Nav ListBox */ export type FunSubNavListBoxProps = Readonly<{ 'aria-label': string; selected: Key; onSelect: (key: Key) => void; children: ReactNode; }>; type FunSubNavListBoxContextValue = { id: string; selected: string }; const FunSubNavListBoxContext = createContext(null); export function FunSubNavListBox( props: FunSubNavListBoxProps ): JSX.Element { const { onSelect } = props; const id = useId(); const contextValue = useMemo(() => { return { id, selected: props.selected }; }, [id, props.selected]); const handleSelectionChange = useCallback( (keys: Selection) => { try { strictAssert(keys !== 'all', 'Expected single selection'); strictAssert(keys.size === 1, 'Expected single selection'); const [first] = keys.values(); onSelect(first as Key); } catch (error) { // Note: react-aria gets into bad state if you don't catch this error. log.error( 'Failed to handle selection change', Errors.toLogFormat(error) ); } }, [onSelect] ); return ( {props.children} ); } /** * Sub Nav ListBoxItem */ export type FunSubNavListBoxItemProps = Readonly<{ id: string; label: string; children: ReactNode; }>; const FunSubNavListBoxItemTransition: Transition = { type: 'spring', stiffness: 632, damping: 43.8, mass: 1, }; function FunSubNavListBoxItemButton(props: { isSelected: boolean; children: ReactNode; }): JSX.Element { const ref = useRef(null); useEffect(() => { strictAssert(ref.current, 'Expected ref to be defined'); const element = ref.current; let timer: ReturnType; if (props.isSelected) { // Needs setTimeout() for arrow key navigation to work. // Might be something to do with native arrow key scroll handling. timer = setTimeout(() => { element.scrollIntoView({ behavior: 'smooth', inline: 'nearest', }); }, 1); } return () => { clearTimeout(timer); }; }, [props.isSelected]); return (
{props.children}
); } export function FunSubNavListBoxItem( props: FunSubNavListBoxItemProps ): JSX.Element { const context = useContext(FunSubNavListBoxContext); strictAssert(context, 'Must be wrapped with '); return ( {({ isSelected, isFocusVisible }) => { return ( {props.children} {isSelected && ( )} {!isSelected && isFocusVisible && (
)} ); }} ); } /** * Sub Nav Icon */ export type FunSubNavIconProps = Readonly<{ iconClassName: `FunSubNav__Icon--${string}`; }>; export function FunSubNavIcon(props: FunSubNavIconProps): JSX.Element { return
; } /** * Sub Nav Image */ export type FunSubNavImageProps = Readonly<{ src: string; }>; export function FunSubNavImage(props: FunSubNavImageProps): JSX.Element { return ( ); }