diff --git a/.changeset/fair-bars-smile.md b/.changeset/fair-bars-smile.md new file mode 100644 index 00000000000..f8eba55a7e7 --- /dev/null +++ b/.changeset/fair-bars-smile.md @@ -0,0 +1,5 @@ +--- +'@primer/react': major +--- + +Support nested children in ActionBar. diff --git a/packages/react/src/ActionBar/ActionBar.stories.tsx b/packages/react/src/ActionBar/ActionBar.stories.tsx index 30988947ed4..91c24455431 100644 --- a/packages/react/src/ActionBar/ActionBar.stories.tsx +++ b/packages/react/src/ActionBar/ActionBar.stories.tsx @@ -63,3 +63,33 @@ export const Default = () => ( ) + +const BoldButton = () => + +const FormattingButtons = () => ( + <> + + + + + +) + +const AdvancedFormattingButtons = () => ( + <> + + + + + + + +) + +export const DeepChildTree = () => ( + + + + + +) diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index 50555a2eeab..21139a9cb8e 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -1,9 +1,9 @@ import {describe, expect, it, afterEach, vi} from 'vitest' import {render, screen, act} from '@testing-library/react' import userEvent from '@testing-library/user-event' - import ActionBar from './' -import {BoldIcon} from '@primer/octicons-react' +import {BoldIcon, ItalicIcon, CodeIcon} from '@primer/octicons-react' +import {useState} from 'react' describe('ActionBar', () => { afterEach(() => { @@ -78,3 +78,159 @@ describe('ActionBar', () => { expect(onClick).toHaveBeenCalled() }) }) + +describe('ActionBar Registry System', () => { + it('should preserve order with deep nesting', () => { + render( + +
+ +
+ +
+ +
+
, + ) + + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(3) + expect(buttons[0]).toHaveAccessibleName('First') + expect(buttons[1]).toHaveAccessibleName('Second') + expect(buttons[2]).toHaveAccessibleName('Third') + }) + + it('should handle conditional rendering without breaking order', async () => { + const ConditionalTest = () => { + const [show, setShow] = useState([true, true, true]) + + return ( +
+ + {show[0] && } + {show[1] && } + {show[2] && } + + + +
+ ) + } + + const user = userEvent.setup() + render() + + // Initially should have 3 buttons + expect(screen.getAllByRole('button', {name: /First|Second|Third/})).toHaveLength(3) + + // Hide first button + await user.click(screen.getByText('Hide first')) + + const buttonsAfterHide = screen.getAllByRole('button', {name: /Second|Third/}) + expect(buttonsAfterHide).toHaveLength(2) + expect(buttonsAfterHide[0]).toHaveAccessibleName('Second') + expect(buttonsAfterHide[1]).toHaveAccessibleName('Third') + + // Show first button again + await user.click(screen.getByText('Show all')) + + const buttonsAfterShow = screen.getAllByRole('button', {name: /First|Second|Third/}) + expect(buttonsAfterShow).toHaveLength(3) + expect(buttonsAfterShow[0]).toHaveAccessibleName('First') + expect(buttonsAfterShow[1]).toHaveAccessibleName('Second') + expect(buttonsAfterShow[2]).toHaveAccessibleName('Third') + }) + + it('should handle fragments and array mapping', () => { + render( + + <> + + {[1, 2].map(i => ( + + ))} + + + , + ) + + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(4) + expect(buttons[0]).toHaveAccessibleName('In fragment') + expect(buttons[1]).toHaveAccessibleName('Mapped 1') + expect(buttons[2]).toHaveAccessibleName('Mapped 2') + expect(buttons[3]).toHaveAccessibleName('After fragment') + }) + + it('should handle rapid re-renders without losing registry data', async () => { + const RapidRerenderTest = () => { + const [count, setCount] = useState(0) + + return ( +
+ + + + +
+ ) + } + + const user = userEvent.setup() + render() + + // Rapidly trigger re-renders + for (let i = 0; i < 10; i++) { + await user.click(screen.getByText('Increment')) + } + + expect(screen.getByRole('button', {name: 'Button 10'})).toBeInTheDocument() + }) + + it('should handle zero-width scenarios gracefully', () => { + render( +
+ + + +
, + ) + + // Component should still render even with zero width + expect(screen.getByRole('button', {name: 'Zero width button'})).toBeInTheDocument() + }) + + it('should clean up registry on unmount', async () => { + const UnmountTest = () => { + const [mounted, setMounted] = useState(true) + + return ( +
+ {mounted && ( + + + + )} + +
+ ) + } + + const user = userEvent.setup() + render() + + expect(screen.getByRole('button', {name: 'Will unmount'})).toBeInTheDocument() + + await user.click(screen.getByText('Unmount')) + + expect(screen.queryByRole('button', {name: 'Will unmount'})).not.toBeInTheDocument() + }) +}) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index d4374077cab..f4f718794fa 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -1,5 +1,5 @@ -import type {RefObject, MutableRefObject} from 'react' -import React, {useState, useCallback, useRef, forwardRef} from 'react' +import type {RefObject, MouseEventHandler} from 'react' +import React, {useState, useCallback, useRef, forwardRef, useId} from 'react' import {KebabHorizontalIcon} from '@primer/octicons-react' import {ActionList} from '../ActionList' import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' @@ -14,23 +14,33 @@ import {ActionMenu} from '../ActionMenu' import {useFocusZone, FocusKeys} from '../hooks/useFocusZone' import styles from './ActionBar.module.css' import {clsx} from 'clsx' +import {useRefObjectAsForwardedRef} from '../hooks' const ACTIONBAR_ITEM_GAP = 8 -type ChildSize = { - text: string - width: number -} -type ChildWidthArray = Array -type ResponsiveProps = { - items: Array - menuItems: Array -} +type ChildProps = + | { + type: 'action' + label: string + disabled: boolean + icon: ActionBarIconButtonProps['icon'] + onClick: MouseEventHandler + width: number + } + | {type: 'divider'; width: number} + +/** + * Registry of descendants to render in the list or menu. To preserve insertion order across updates, children are + * set to `null` when unregistered rather than fully dropped from the map. + */ +type ChildRegistry = ReadonlyMap const ActionBarContext = React.createContext<{ size: Size - setChildrenWidth: React.Dispatch<{text: string; width: number}> -}>({size: 'medium', setChildrenWidth: () => null}) + registerChild: (id: string, props: ChildProps) => void + unregisterChild: (id: string) => void + isVisibleChild: (id: string) => boolean +}>({size: 'medium', registerChild: () => {}, unregisterChild: () => {}, isVisibleChild: () => true}) /* small (28px), medium (32px), large (40px) @@ -75,18 +85,12 @@ export type ActionBarIconButtonProps = {disabled?: boolean} & IconButtonProps const MORE_BTN_WIDTH = 32 -const getValidChildren = (children: React.ReactNode) => { - return React.Children.toArray(children).filter(child => { - return React.isValidElement(child) - }) as React.ReactElement[] -} - -const calculatePossibleItems = (childWidthArray: ChildWidthArray, navWidth: number, moreMenuWidth = 0) => { +const calculatePossibleItems = (registryEntries: Array<[string, ChildProps]>, navWidth: number, moreMenuWidth = 0) => { const widthToFit = navWidth - moreMenuWidth - let breakpoint = childWidthArray.length // assume all items will fit + let breakpoint = registryEntries.length // assume all items will fit let sumsOfChildWidth = 0 - for (const [index, childWidth] of childWidthArray.entries()) { - sumsOfChildWidth += index > 0 ? childWidth.width + ACTIONBAR_ITEM_GAP : childWidth.width + for (const [index, [, child]] of registryEntries.entries()) { + sumsOfChildWidth += index > 0 ? child.width + ACTIONBAR_ITEM_GAP : child.width if (sumsOfChildWidth > widthToFit) { breakpoint = index break @@ -97,68 +101,67 @@ const calculatePossibleItems = (childWidthArray: ChildWidthArray, navWidth: numb return breakpoint } -const overflowEffect = ( +const getMenuItems = ( navWidth: number, moreMenuWidth: number, - childArray: Array, - childWidthArray: ChildWidthArray, - updateListAndMenu: (props: ResponsiveProps) => void, + childRegistry: ChildRegistry, hasActiveMenu: boolean, -) => { - if (childWidthArray.length === 0) { - updateListAndMenu({items: childArray, menuItems: []}) - } - const numberOfItemsPossible = calculatePossibleItems(childWidthArray, navWidth) +): Set | void => { + const registryEntries = Array.from(childRegistry).filter((entry): entry is [string, ChildProps] => entry[1] !== null) + + if (registryEntries.length === 0) return new Set() + const numberOfItemsPossible = calculatePossibleItems(registryEntries, navWidth) const numberOfItemsPossibleWithMoreMenu = calculatePossibleItems( - childWidthArray, + registryEntries, navWidth, moreMenuWidth || MORE_BTN_WIDTH, ) - const items: Array = [] - const menuItems: Array = [] + const menuItems = new Set() // First, we check if we can fit all the items with their icons - if (childArray.length >= numberOfItemsPossible) { + if (registryEntries.length >= numberOfItemsPossible) { /* Below is an accessibility requirement. Never show only one item in the overflow menu. * If there is only one item left to display in the overflow menu according to the calculation, * we need to pull another item from the list into the overflow menu. */ - const numberOfItemsInMenu = childArray.length - numberOfItemsPossibleWithMoreMenu + const numberOfItemsInMenu = registryEntries.length - numberOfItemsPossibleWithMoreMenu const numberOfListItems = numberOfItemsInMenu === 1 ? numberOfItemsPossibleWithMoreMenu - 1 : numberOfItemsPossibleWithMoreMenu - for (const [index, child] of childArray.entries()) { + for (const [index, [id, child]] of registryEntries.entries()) { if (index < numberOfListItems) { - items.push(child) + continue //if the last item is a divider - } else if (childWidthArray[index].text === 'divider') { + } else if (child.type === 'divider') { if (index === numberOfListItems - 1 || index === numberOfListItems) { continue } else { - const divider = React.createElement(ActionList.Divider, {key: index}) - menuItems.push(divider) + menuItems.add(id) } } else { - menuItems.push(child) + menuItems.add(id) } } - updateListAndMenu({items, menuItems}) - } else if (numberOfItemsPossible > childArray.length && hasActiveMenu) { + return menuItems + } else if (numberOfItemsPossible > registryEntries.length && hasActiveMenu) { /* If the items fit in the list and there are items in the overflow menu, we need to move them back to the list */ - updateListAndMenu({items: childArray, menuItems: []}) + return new Set() } } export const ActionBar: React.FC> = props => { const {size = 'medium', children, 'aria-label': ariaLabel, flush = false, className} = props - const [childWidthArray, setChildWidthArray] = useState([]) - const setChildrenWidth = useCallback((size: ChildSize) => { - setChildWidthArray(arr => { - const newArr = [...arr, size] - return newArr - }) - }, []) + + const [childRegistry, setChildRegistry] = useState(() => new Map()) + + const registerChild = useCallback( + (id: string, childProps: ChildProps) => setChildRegistry(prev => new Map(prev).set(id, childProps)), + [], + ) + const unregisterChild = useCallback((id: string) => setChildRegistry(prev => new Map(prev).set(id, null)), []) + + const [menuItemIds, setMenuItemIds] = useState>(() => new Set()) const navRef = useRef(null) const listRef = useRef(null) @@ -166,35 +169,24 @@ export const ActionBar: React.FC> = prop const moreMenuBtnRef = useRef(null) const containerRef = React.useRef(null) - const validChildren = getValidChildren(children) - // Responsive props object manages which items are in the list and which items are in the menu. - const [responsiveProps, setResponsiveProps] = useState({ - items: validChildren, - menuItems: [], - }) - - // Make sure to have the fresh props data for list items when children are changed (keeping aria-current up-to-date) - const listItems = responsiveProps.items.map(item => { - return validChildren.find(child => child.key === item.key) ?? item - }) - - // Make sure to have the fresh props data for menu items when children are changed (keeping aria-current up-to-date) - const menuItems = responsiveProps.menuItems.map(menuItem => { - return validChildren.find(child => child.key === menuItem.key) ?? menuItem - }) - - const updateListAndMenu = useCallback((props: ResponsiveProps) => { - setResponsiveProps(props) - }, []) - useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { const navWidth = resizeObserverEntries[0].contentRect.width const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 - const hasActiveMenu = menuItems.length > 0 - navWidth !== 0 && - overflowEffect(navWidth, moreMenuWidth, validChildren, childWidthArray, updateListAndMenu, hasActiveMenu) + const hasActiveMenu = menuItemIds.size > 0 + + if (navWidth > 0) { + const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu) + if (newMenuItemIds) setMenuItemIds(newMenuItemIds) + } }, navRef as RefObject) + const isVisibleChild = useCallback( + (id: string) => { + return !menuItemIds.has(id) + }, + [menuItemIds], + ) + const [isWidgetOpen, setIsWidgetOpen] = useState(false) const closeOverlay = React.useCallback(() => { @@ -225,31 +217,28 @@ export const ActionBar: React.FC> = prop }) return ( - +
- {listItems} - {menuItems.length > 0 && ( + {children} + {menuItemIds.size > 0 && ( - {menuItems.map((menuItem, index) => { - if (menuItem.type === ActionList.Divider) { - return + {Array.from(menuItemIds).map(id => { + const menuItem = childRegistry.get(id) + if (!menuItem) return null + + if (menuItem.type === 'divider') { + return } else { - const { - children: menuItemChildren, - onClick, - icon: Icon, - 'aria-label': ariaLabel, - disabled, - } = menuItem.props + const {onClick, icon: Icon, label, disabled} = menuItem return ( ) => { closeOverlay() @@ -258,12 +247,10 @@ export const ActionBar: React.FC> = prop }} disabled={disabled} > - {Icon ? ( - - - - ) : null} - {ariaLabel} + + + + {label} ) } @@ -280,14 +267,31 @@ export const ActionBar: React.FC> = prop export const ActionBarIconButton = forwardRef( ({disabled, onClick, ...props}: ActionBarIconButtonProps, forwardedRef) => { - const backupRef = useRef(null) - const ref = (forwardedRef ?? backupRef) as RefObject - const {size, setChildrenWidth} = React.useContext(ActionBarContext) + const ref = useRef(null) + useRefObjectAsForwardedRef(forwardedRef, ref) + const id = useId() + + const {size, registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext) + + // Storing the width in a ref ensures we don't forget about it when not visible + const widthRef = useRef() + useIsomorphicLayoutEffect(() => { - const text = props['aria-label'] ? props['aria-label'] : '' - const domRect = (ref as MutableRefObject).current.getBoundingClientRect() - setChildrenWidth({text, width: domRect.width}) - }, [ref, setChildrenWidth]) + const width = ref.current?.getBoundingClientRect().width + if (width) widthRef.current = width + if (!widthRef.current) return + + registerChild(id, { + type: 'action', + label: props['aria-label'] ?? '', + icon: props.icon, + disabled: !!disabled, + onClick: onClick as MouseEventHandler, + width: widthRef.current, + }) + + return () => unregisterChild(id) + }, [registerChild, unregisterChild, props['aria-label'], props.icon, disabled, onClick]) const clickHandler = useCallback( (event: React.MouseEvent) => { @@ -297,6 +301,8 @@ export const ActionBarIconButton = forwardRef( [disabled, onClick], ) + if (!isVisibleChild(id)) return null + return ( { const ref = useRef(null) - const {setChildrenWidth} = React.useContext(ActionBarContext) + const id = useId() + const {registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext) + + // Storing the width in a ref ensures we don't forget about it when not visible + const widthRef = useRef() + useIsomorphicLayoutEffect(() => { - const text = 'divider' - const domRect = (ref as MutableRefObject).current.getBoundingClientRect() - setChildrenWidth({text, width: domRect.width}) - }, [ref, setChildrenWidth]) + const width = ref.current?.getBoundingClientRect().width + if (width) widthRef.current = width + if (!widthRef.current) return + + registerChild(id, {type: 'divider', width: widthRef.current}) + + return () => unregisterChild(id) + }, [registerChild, unregisterChild]) + + if (!isVisibleChild(id)) return null return