From d7112747cee1fab22dfb0030bd455a971dcb3dfd Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 18 Aug 2026 03:46:57 +0000 Subject: [PATCH 1/4] Add active anchors to ActionBar actions --- .changeset/actionbar-active-anchor.md | 5 + packages/react/script/react-compiler.mjs | 1 - .../react/src/ActionBar/ActionBar.docs.json | 12 + .../react/src/ActionBar/ActionBar.test.tsx | 268 +++++++++++++++++- packages/react/src/ActionBar/ActionBar.tsx | 149 ++++++---- .../AnchoredOverlay/AnchoredOverlay.docs.json | 4 +- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 34 +++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 58 ++-- .../AnchoredOverlay.types.test.tsx | 5 +- .../react/src/SelectPanel/SelectPanel.tsx | 23 +- packages/react/src/deprecated/ActionMenu.tsx | 15 +- .../__tests__/useOpenAndCloseFocus.test.tsx | 40 +++ .../react/src/hooks/useOpenAndCloseFocus.ts | 9 +- 13 files changed, 528 insertions(+), 95 deletions(-) create mode 100644 .changeset/actionbar-active-anchor.md diff --git a/.changeset/actionbar-active-anchor.md b/.changeset/actionbar-active-anchor.md new file mode 100644 index 00000000000..f687d55ea9e --- /dev/null +++ b/.changeset/actionbar-active-anchor.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +ActionBar: Add `activeAnchorRef` to action buttons for overlays that need the action's currently visible trigger. Anchored overlay render functions now receive their reactive anchor ref as a second argument. \ No newline at end of file diff --git a/packages/react/script/react-compiler.mjs b/packages/react/script/react-compiler.mjs index 74ee5a4e7e0..5d6d6a01512 100644 --- a/packages/react/script/react-compiler.mjs +++ b/packages/react/script/react-compiler.mjs @@ -12,7 +12,6 @@ const files = glob return path.join(PACKAGE_DIR, match) }) const unsupportedPatterns = [ - 'src/ActionBar/ActionBar.tsx', 'src/ActionList/ActionList.examples.stories.tsx', 'src/ActionMenu/ActionMenu.test.tsx', 'src/ActionMenu/ActionMenu.tsx', diff --git a/packages/react/src/ActionBar/ActionBar.docs.json b/packages/react/src/ActionBar/ActionBar.docs.json index b1e1bc89bd0..4f494a19dd3 100644 --- a/packages/react/src/ActionBar/ActionBar.docs.json +++ b/packages/react/src/ActionBar/ActionBar.docs.json @@ -80,6 +80,12 @@ "type": "boolean", "defaultValue": "", "description": "Provides a disabled state for the button. The button will remain focusable, and have `aria-disabled` applied." + }, + { + "name": "activeAnchorRef", + "type": "React.Ref", + "required": false, + "description": "Receives the visible trigger representing this action. Points to this button while inline and the ActionBar overflow button while this action is overflowing." } ], "passthrough": { @@ -108,6 +114,12 @@ "type": "boolean", "defaultValue": "", "description": "Provides a disabled state for the button. The button will remain focusable, and have `aria-disabled` applied." + }, + { + "name": "activeAnchorRef", + "type": "React.Ref", + "required": false, + "description": "Receives the visible trigger representing this action. Points to this button while inline and the ActionBar overflow button while this action is overflowing." } ], "passthrough": { diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index f5537efde2e..937c7808d63 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -1,11 +1,95 @@ import {describe, expect, it, afterEach, vi} from 'vitest' import {render, screen, act} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import React, {createRef, useState} from 'react' +import type React from 'react' +import {createRef, useState} from 'react' import ActionBar from './' import {BoldIcon, ItalicIcon, CodeIcon} from '@primer/octicons-react' import {implementsClassName} from '../utils/testing' import classes from './ActionBar.module.css' +import {SelectPanel, type SelectPanelProps} from '../SelectPanel' + +type IntersectionEntry = Pick + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = [] + readonly observed = new Set() + callback: (entries: IntersectionEntry[]) => void + + constructor(callback: (entries: IntersectionEntry[]) => void) { + this.callback = callback + MockIntersectionObserver.instances.push(this) + } + + observe(element: Element) { + this.observed.add(element) + } + + unobserve(element: Element) { + this.observed.delete(element) + } + + disconnect() { + this.observed.clear() + } + + trigger(entries: IntersectionEntry[]) { + act(() => this.callback(entries)) + } +} + +const selectPanelItems: SelectPanelProps['items'] = [{text: 'Alpha'}, {text: 'Beta'}] + +function ActionBarSelectPanel({ + disabled = false, + grouped = false, + onOpenChange = () => {}, +}: { + disabled?: boolean + grouped?: boolean + onOpenChange?: (open: boolean) => void +}) { + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState([]) + const [filterValue, setFilterValue] = useState('') + + const panel = ( + { + onOpenChange(nextOpen) + setOpen(nextOpen) + }} + items={selectPanelItems} + selected={selected} + onSelectedChange={setSelected} + filterValue={filterValue} + onFilterChange={setFilterValue} + renderAnchor={(anchorProps, activeAnchorRef) => ( + + Projects + + )} + /> + ) + + return {grouped ? {panel} : panel} +} + +function overflowElement(element: Element, overflowing: boolean) { + const observer = MockIntersectionObserver.instances.find(instance => instance.observed.has(element)) + if (!observer) throw new Error('Expected element to be observed for overflow') + + observer.trigger([ + { + target: element, + isIntersecting: !overflowing, + intersectionRatio: overflowing ? 0 : 1, + }, + ]) +} describe('ActionBar', () => { implementsClassName(ActionBar, classes.Nav) @@ -80,11 +164,30 @@ describe('ActionBar', () => { expect(onClick).toHaveBeenCalled() }) + + it('updates IconButton activeAnchorRef when the action overflows', () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + const activeAnchorRef = createRef() + + render( + + + , + ) + + const inlineButton = screen.getByRole('button', {name: 'Bold'}) + expect(activeAnchorRef.current).toBe(inlineButton) + + overflowElement(inlineButton, true) + expect(activeAnchorRef.current).toBe(screen.getByRole('button', {name: 'More items'})) + }) }) describe('ActionBar.Button', () => { afterEach(() => { vi.clearAllMocks() + vi.unstubAllGlobals() + MockIntersectionObserver.instances = [] }) it('renders a text button with its children as the accessible name', () => { @@ -146,6 +249,169 @@ describe('ActionBar.Button', () => { expect(onClick).not.toHaveBeenCalled() }) + + it('updates activeAnchorRef when the button moves into and out of overflow', () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + + const activeAnchorRef = createRef() + const forwardedRef = createRef() + render( + + + Save + + , + ) + + const inlineButton = screen.getByRole('button', {name: 'Save'}) + expect(forwardedRef.current).toBe(inlineButton) + expect(activeAnchorRef.current).toBe(inlineButton) + + const observer = MockIntersectionObserver.instances.at(-1)! + observer.trigger([{target: inlineButton, isIntersecting: false, intersectionRatio: 0}]) + + expect(activeAnchorRef.current).toBe(screen.getByRole('button', {name: 'More items'})) + expect(forwardedRef.current).toBe(inlineButton) + + observer.trigger([{target: inlineButton, isIntersecting: true, intersectionRatio: 1}]) + expect(activeAnchorRef.current).toBe(inlineButton) + }) + + it('clears callback activeAnchorRef when the button unmounts', async () => { + const user = userEvent.setup() + const activeAnchors: Array = [] + const activeAnchorRef = (anchor: HTMLButtonElement | null) => { + activeAnchors.push(anchor) + } + + const Test = () => { + const [visible, setVisible] = useState(true) + return ( + <> + + {visible ? Save : null} + + + + ) + } + + render() + await user.click(screen.getByRole('button', {name: 'Unmount action'})) + + expect(activeAnchors.at(-1)).toBeNull() + }) + + it.each([ + ['pointer', async (user: ReturnType, item: HTMLElement) => user.click(item)], + [ + 'Enter', + async (user: ReturnType, item: HTMLElement) => { + item.focus() + await user.keyboard('{Enter}') + }, + ], + [ + 'Space', + async (user: ReturnType, item: HTMLElement) => { + item.focus() + await user.keyboard(' ') + }, + ], + ])('opens a SelectPanel once from overflow with %s activation', async (_name, activate) => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + const user = userEvent.setup() + const onOpenChange = vi.fn() + render() + + const inlineButton = screen.getByRole('button', {name: 'Projects'}) + overflowElement(inlineButton, true) + await user.click(screen.getByRole('button', {name: 'More items'})) + await activate(user, screen.getByRole('menuitem', {name: 'Projects'})) + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(screen.getByRole('dialog', {name: 'Projects'})).toBeInTheDocument() + expect(onOpenChange).toHaveBeenCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith(true) + }) + + it('returns focus to the overflow button when the SelectPanel closes', async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + const user = userEvent.setup() + render() + + const inlineButton = screen.getByRole('button', {name: 'Projects'}) + overflowElement(inlineButton, true) + const overflowButton = screen.getByRole('button', {name: 'More items'}) + await user.click(overflowButton) + await user.click(screen.getByRole('menuitem', {name: 'Projects'})) + await user.keyboard('{Escape}') + + expect(overflowButton).toHaveFocus() + }) + + it('returns focus to the inline button when it becomes visible while the SelectPanel is open', async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + const user = userEvent.setup() + render() + + const inlineButton = screen.getByRole('button', {name: 'Projects'}) + overflowElement(inlineButton, true) + await user.click(screen.getByRole('button', {name: 'More items'})) + await user.click(screen.getByRole('menuitem', {name: 'Projects'})) + overflowElement(inlineButton, false) + await user.keyboard('{Escape}') + + expect(inlineButton).toHaveFocus() + }) + + it('returns focus to the overflow button when the action overflows while the SelectPanel is open', async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + const user = userEvent.setup() + render() + + const inlineButton = screen.getByRole('button', {name: 'Projects'}) + await user.click(inlineButton) + overflowElement(inlineButton, true) + const overflowButton = screen.getByRole('button', {name: 'More items'}) + await user.keyboard('{Escape}') + + expect(overflowButton).toHaveFocus() + }) + + it('uses the overflow button as the active anchor when a group overflows', async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + const user = userEvent.setup() + const {container} = render() + + const group = container.querySelector('[data-component="ActionBar.Group"]') + if (!group) throw new Error('Expected ActionBar group') + overflowElement(group, true) + + const overflowButton = screen.getByRole('button', {name: 'More items'}) + await user.click(overflowButton) + await user.click(screen.getByRole('menuitem', {name: 'Projects'})) + await user.keyboard('{Escape}') + + expect(overflowButton).toHaveFocus() + }) + + it('does not activate a disabled action from overflow', async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + const user = userEvent.setup() + const onOpenChange = vi.fn() + render() + + const inlineButton = screen.getByRole('button', {name: 'Projects'}) + overflowElement(inlineButton, true) + await user.click(screen.getByRole('button', {name: 'More items'})) + await user.click(screen.getByRole('menuitem', {name: 'Projects'})) + + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog', {name: 'Projects'})).not.toBeInTheDocument() + }) }) describe('ActionBar Registry System', () => { diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 4e5324b65fd..60c1ab82730 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -1,4 +1,4 @@ -import {type RefObject, type MouseEventHandler, useContext} from 'react' +import {type RefObject, useContext} from 'react' import React, {useState, useCallback, useRef, forwardRef, useMemo} from 'react' import {KebabHorizontalIcon} from '@primer/octicons-react' import {ActionList, type ActionListItemProps} from '../ActionList' @@ -20,7 +20,7 @@ type ChildProps = label: React.ReactNode disabled: boolean icon?: ActionBarIconButtonProps['icon'] - onClick: MouseEventHandler + onActivate: () => void } | {type: 'divider' | 'group'} | { @@ -33,8 +33,10 @@ type ChildProps = const ActionBarContext = React.createContext<{ size: Size + overflowButtonRef: React.RefObject }>({ size: 'medium', + overflowButtonRef: {current: null}, }) /* @@ -84,9 +86,18 @@ export type ActionBarProps = { gap?: GapScale } & A11yProps -export type ActionBarIconButtonProps = {disabled?: boolean} & IconButtonProps +type ActiveAnchorProps = { + disabled?: boolean + /** + * Receives the visible trigger representing this action. Points to this button while inline and the ActionBar + * overflow button while this action is overflowing. + */ + activeAnchorRef?: React.Ref +} -export type ActionBarButtonProps = {disabled?: boolean} & ButtonProps +export type ActionBarIconButtonProps = ActiveAnchorProps & IconButtonProps + +export type ActionBarButtonProps = ActiveAnchorProps & ButtonProps export type ActionBarMenuItemProps = | ({ @@ -207,6 +218,8 @@ export const ActionBar: React.FC> = ({ gap = 'condensed', }) => { const [childRegistry, setChildRegistry] = ActionBarItemsRegistry.useRegistryState() + const [overflowMenuOpen, setOverflowMenuOpen] = useState(false) + const overflowButtonRef = useRef(null) const overflowItems = useMemo( () => @@ -225,7 +238,7 @@ export const ActionBar: React.FC> = ({ ) return ( - +
} @@ -246,7 +259,7 @@ export const ActionBar: React.FC> = ({
- + > = ({ } if (menuItem.type === 'action') { - const {onClick, icon: Icon, label, disabled} = menuItem + const {onActivate, icon: Icon, label, disabled} = menuItem return ( { - typeof onClick === 'function' && onClick(event as React.MouseEvent) + event.preventDefault() + setOverflowMenuOpen(false) + onActivate() }} disabled={disabled} > @@ -316,8 +331,13 @@ export const ActionBar: React.FC> = ({ ) } -function useActionBarItem(ref: React.RefObject, registryProps: ChildProps) { +function useActionBarItem( + ref: React.RefObject, + registryProps: ChildProps, + activeAnchorRef?: React.Ref, +) { const isGroupOverflowing = useContext(ActionBarGroupContext)?.isOverflowing + const {overflowButtonRef} = useContext(ActionBarContext) const isInGroup = isGroupOverflowing !== undefined // There's no need to observe items inside of a group since the entire group overflows at once, so `disabled` skips @@ -325,22 +345,33 @@ function useActionBarItem(ref: React.RefObject, registryProp const isItemOverflowing = useIsClipped(ref, {disabled: isInGroup}) const isOverflowing = isGroupOverflowing || isItemOverflowing + const setActiveAnchorRef = useMergedRefs(activeAnchorRef, undefined) + const publishActiveAnchor = useCallback( + (button: HTMLButtonElement | null) => { + if (!activeAnchorRef || button === null) return setActiveAnchorRef(null) + return setActiveAnchorRef(isOverflowing ? overflowButtonRef.current : button) + }, + [activeAnchorRef, isOverflowing, overflowButtonRef, setActiveAnchorRef], + ) ActionBarItemsRegistry.useRegisterDescendant(isOverflowing ? registryProps : null) - return {isOverflowing, dataOverflowingAttr: isOverflowing ? '' : undefined} + return {isOverflowing, dataOverflowingAttr: isOverflowing ? '' : undefined, publishActiveAnchor} } export const ActionBarIconButton = forwardRef( - ({disabled, onClick, ...props}: ActionBarIconButtonProps, forwardedRef) => { + ({disabled, onClick, activeAnchorRef, ...props}: ActionBarIconButtonProps, forwardedRef) => { const ref = useRef(null) - const mergedRef = useMergedRefs(forwardedRef, ref) + const forwardedAndInternalRef = useMergedRefs(forwardedRef, ref) const {size} = React.useContext(ActionBarContext) - const {['aria-label']: ariaLabel, icon} = props + const ariaLabel = props['aria-label'] + const {icon} = props - const {dataOverflowingAttr} = useActionBarItem( + const activate = useCallback(() => ref.current?.click(), []) + + const {dataOverflowingAttr, publishActiveAnchor} = useActionBarItem( ref, useMemo( (): ChildProps => ({ @@ -348,11 +379,13 @@ export const ActionBarIconButton = forwardRef( label: ariaLabel ?? '', icon, disabled: !!disabled, - onClick: onClick as MouseEventHandler, + onActivate: activate, }), - [ariaLabel, icon, disabled, onClick], + [ariaLabel, icon, disabled, activate], ), + activeAnchorRef, ) + const mergedRef = useMergedRefs(forwardedAndInternalRef, publishActiveAnchor) const clickHandler = useCallback( (event: React.MouseEvent) => { @@ -376,50 +409,56 @@ export const ActionBarIconButton = forwardRef( }, ) -export const ActionBarButton = forwardRef(({disabled, onClick, ...props}: ActionBarButtonProps, forwardedRef) => { - const ref = useRef(null) - const mergedRef = useMergedRefs(forwardedRef, ref) +export const ActionBarButton = forwardRef( + ({disabled, onClick, activeAnchorRef, ...props}: ActionBarButtonProps, forwardedRef) => { + const ref = useRef(null) + const forwardedAndInternalRef = useMergedRefs(forwardedRef, ref) - const {size} = React.useContext(ActionBarContext) + const {size} = React.useContext(ActionBarContext) - const {children, leadingVisual} = props + const {children, leadingVisual} = props - const {dataOverflowingAttr} = useActionBarItem( - ref, - useMemo( - (): ChildProps => ({ - type: 'action', - label: children, - // Only forward the leading visual to the overflow menu when it is a component - // that can be rendered as an icon (e.g. an octicon), matching ActionBar.IconButton. - icon: typeof leadingVisual === 'function' ? (leadingVisual as ActionBarIconButtonProps['icon']) : undefined, - disabled: !!disabled, - onClick: onClick as MouseEventHandler, - }), - [children, leadingVisual, disabled, onClick], - ), - ) + const activate = useCallback(() => ref.current?.click(), []) - const clickHandler = useCallback( - (event: React.MouseEvent) => { - if (disabled) return - onClick?.(event) - }, - [disabled, onClick], - ) + const {dataOverflowingAttr, publishActiveAnchor} = useActionBarItem( + ref, + useMemo( + (): ChildProps => ({ + type: 'action', + label: children, + // Only forward the leading visual to the overflow menu when it is a component + // that can be rendered as an icon (e.g. an octicon), matching ActionBar.IconButton. + icon: typeof leadingVisual === 'function' ? (leadingVisual as ActionBarIconButtonProps['icon']) : undefined, + disabled: !!disabled, + onActivate: activate, + }), + [children, leadingVisual, disabled, activate], + ), + activeAnchorRef, + ) + const mergedRef = useMergedRefs(forwardedAndInternalRef, publishActiveAnchor) - return ( - + {}} + onClose={() => {}} + onPositionChange={onPositionChange} + renderAnchor={(props, ref) => ( + + )} + > + + + + ) + } + + const {getByRole} = render() + const initialCalls = onPositionChange.mock.calls.length + act(() => getByRole('button', {name: 'Switch'}).click()) + + expect(onPositionChange.mock.calls.length).toBeGreaterThan(initialCalls) + }) + it('should re-apply anchor-name to a new anchor DOM element when the overlay reopens', () => { function TestComponent() { const anchorRef = useRef(null) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index bc373025e56..70116b660c8 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -17,6 +17,7 @@ import {clsx} from 'clsx' import {useFeatureFlag} from '../FeatureFlags' import {widthMap} from '../Overlay/constants' import {reactMajorVersion} from '../utils/environment' +import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' interface AnchoredOverlayPropsWithAnchor { /** @@ -25,6 +26,7 @@ interface AnchoredOverlayPropsWithAnchor { */ renderAnchor: , 'aria-label' | 'aria-labelledby'>>( props: T, + anchorRef: React.RefCallback, ) => JSX.Element /** @@ -150,6 +152,24 @@ const defaultVariant = { const defaultCloseButtonProps: Partial = {} +function DetachedAnchorObserver({ + anchorRef, + anchorElement, + onAnchorElementChange, +}: { + anchorRef: React.RefObject + anchorElement: HTMLElement | null + onAnchorElementChange: React.Dispatch> +}) { + // Detached object refs do not notify when their element changes, so check after each parent-driven render. + useLayoutEffect(() => { + if (anchorRef.current !== anchorElement) { + onAnchorElementChange(anchorRef.current) + } + }) + return null +} + /** * An `AnchoredOverlay` provides an anchor that will open a floating overlay positioned relative to the anchor. * The overlay can be opened and navigated using keyboard or mouse. @@ -202,10 +222,7 @@ export const AnchoredOverlay: React.FC(null) - // eslint-disable-next-line react-hooks/refs - if (anchorRef.current !== anchorElement) { - setAnchorElement(anchorRef.current) - } + const mergedAnchorRef = useMergedRefs(anchorRef, setAnchorElement) const [overlayRef, updateOverlayRef] = useRenderForcingRef() const mergedRefEnabled = useFeatureFlag('primer_react_merged_forwarded_refs') const [overlayElement, setOverlayElement] = useState(null) @@ -271,7 +288,7 @@ export const AnchoredOverlay: React.FC { @@ -405,18 +422,27 @@ export const AnchoredOverlay: React.FC + {renderAnchor === null ? ( + + ) : null} {renderAnchor && - // eslint-disable-next-line react-hooks/refs - renderAnchor({ - ref: anchorRef, - id: anchorId, - 'aria-haspopup': 'true', - 'aria-expanded': open, - tabIndex: 0, - onClick: onAnchorClick, - onKeyDown: onAnchorKeyDown, - ...popoverTargetProps, - })} + renderAnchor( + { + ref: mergedAnchorRef, + id: anchorId, + 'aria-haspopup': 'true', + 'aria-expanded': open, + tabIndex: 0, + onClick: onAnchorClick, + onKeyDown: onAnchorKeyDown, + ...popoverTargetProps, + }, + mergedAnchorRef, + )} {open ? ( }> + } + >

GitHub

) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index d818724c193..c7d6be3cb75 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -575,16 +575,19 @@ function Panel({ const selectedValueText = selectedItems.length ? selectedItems.map(item => item.text).join(', ') : placeholder const shouldAutoWireLabel = isReferenced === false && Boolean(labelId) && Boolean(selectedValueId) - return >(props: T) => { - return renderAnchor({ - ...props, - ...(shouldAutoWireLabel - ? { - 'aria-labelledby': [labelId, selectedValueId].filter(Boolean).join(' '), - } - : {}), - children: shouldAutoWireLabel ? {selectedValueText} : selectedValueText, - }) + return >(props: T, anchorRef: React.RefCallback) => { + return renderAnchor( + { + ...props, + ...(shouldAutoWireLabel + ? { + 'aria-labelledby': [labelId, selectedValueId].filter(Boolean).join(' '), + } + : {}), + children: shouldAutoWireLabel ? {selectedValueText} : selectedValueText, + }, + anchorRef, + ) } }, [placeholder, renderAnchor, selected, isReferenced, labelId, selectedValueId]) diff --git a/packages/react/src/deprecated/ActionMenu.tsx b/packages/react/src/deprecated/ActionMenu.tsx index fa0cf1de949..684c5f190ff 100644 --- a/packages/react/src/deprecated/ActionMenu.tsx +++ b/packages/react/src/deprecated/ActionMenu.tsx @@ -68,12 +68,15 @@ const ActionMenuBase = ({ if (renderAnchor === null) { return null } - return >(props: T) => { - return renderAnchor({ - 'aria-label': 'menu', - children: anchorContent, - ...props, - }) + return >(props: T, anchorRef: React.RefCallback) => { + return renderAnchor( + { + 'aria-label': 'menu', + children: anchorContent, + ...props, + }, + anchorRef, + ) } }, [anchorContent, renderAnchor]) diff --git a/packages/react/src/hooks/__tests__/useOpenAndCloseFocus.test.tsx b/packages/react/src/hooks/__tests__/useOpenAndCloseFocus.test.tsx index 0f908423619..06331014538 100644 --- a/packages/react/src/hooks/__tests__/useOpenAndCloseFocus.test.tsx +++ b/packages/react/src/hooks/__tests__/useOpenAndCloseFocus.test.tsx @@ -97,3 +97,43 @@ it('should focus returnFocusRef element when rendered', async () => { expect(document.activeElement).toEqual(toggleButton) }) + +it('focuses the current returnFocusRef element when the target changes before cleanup', async () => { + const user = userEvent.setup() + + const FocusManager = ({returnFocusRef}: {returnFocusRef: React.RefObject}) => { + const containerRef = useRef(null) + useOpenAndCloseFocus({containerRef, returnFocusRef}) + return
+ } + + const Component = () => { + const [open, setOpen] = useState(true) + const [useSecondTarget, setUseSecondTarget] = useState(false) + const returnFocusRef = useRef(null) + + return ( + <> + + + + + {open ? : null} + + ) + } + + const {getByText} = render() + await user.click(getByText('switch target')) + await user.click(getByText('close')) + + expect(getByText('second target')).toHaveFocus() +}) diff --git a/packages/react/src/hooks/useOpenAndCloseFocus.ts b/packages/react/src/hooks/useOpenAndCloseFocus.ts index fd804b30b7b..ff0299b3e8a 100644 --- a/packages/react/src/hooks/useOpenAndCloseFocus.ts +++ b/packages/react/src/hooks/useOpenAndCloseFocus.ts @@ -9,6 +9,11 @@ export type UseOpenAndCloseFocusSettings = { preventFocusOnOpen?: boolean } +function focusCurrentTarget(ref: React.RefObject) { + // The return target may change while an overlay is open, such as when an ActionBar action moves into overflow. + ref.current?.focus() +} + export function useOpenAndCloseFocus({ initialFocusRef, returnFocusRef, @@ -27,10 +32,8 @@ export function useOpenAndCloseFocus({ } } - // If returnFocusRef element is rendered, apply focus - const returnFocusRefCurrent = returnFocusRef.current return function () { - returnFocusRefCurrent?.focus() + focusCurrentTarget(returnFocusRef) } }, [initialFocusRef, returnFocusRef, containerRef, preventFocusOnOpen]) } From 25c322b9b2f445f87d8bf5fa34000fe9fb4b1a2e Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 18 Aug 2026 12:53:41 +0000 Subject: [PATCH 2/4] Restore ActionBar test globals --- packages/react/src/ActionBar/ActionBar.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index 937c7808d63..6506b2fd0c0 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -95,6 +95,8 @@ describe('ActionBar', () => { implementsClassName(ActionBar, classes.Nav) afterEach(() => { vi.clearAllMocks() + vi.unstubAllGlobals() + MockIntersectionObserver.instances = [] }) it('should not trigger disabled button', () => { From 997b0b6cb051512766ac44469e336d36cc425571 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 18 Aug 2026 13:40:13 +0000 Subject: [PATCH 3/4] Preserve rendered anchor ref compatibility --- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 25 +++++++++++++++++++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 16 ++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 7e24180f363..49e4b17231a 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -80,6 +80,31 @@ const AnchoredOverlayTestComponent = ({ return content } +it('preserves the object ref in renderAnchor props while providing a reactive callback ref', () => { + let propsRef: unknown + let reactiveAnchorRef: React.RefCallback | undefined + + render( + { + propsRef = (props as React.RefAttributes).ref + reactiveAnchorRef = anchorRef + return ( + + ) + }} + > + Overlay + , + ) + + expect(propsRef).toEqual({current: expect.any(HTMLButtonElement)}) + expect(reactiveAnchorRef).toEqual(expect.any(Function)) +}) + describe.each([true, false])( 'AnchoredOverlay (primer_react_css_anchor_positioning=%s)', (withCSSAnchorPositioningFeatureFlag: boolean) => { diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 70116b660c8..ee7a8a5e3a3 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import {useCallback, useEffect, useState, type JSX} from 'react' +import {useCallback, useEffect, useMemo, useState, type JSX} from 'react' import type {OverlayProps} from '../Overlay' import Overlay from '../Overlay' import type {FocusTrapHookSettings} from '../hooks/useFocusTrap' @@ -223,6 +223,18 @@ export const AnchoredOverlay: React.FC(null) const mergedAnchorRef = useMergedRefs(anchorRef, setAnchorElement) + // Keep the legacy object-ref shape while making assignments reactive for positioning. + const renderAnchorRef = useMemo( + () => ({ + get current() { + return anchorRef.current + }, + set current(value: HTMLElement | null) { + mergedAnchorRef(value) + }, + }), + [anchorRef, mergedAnchorRef], + ) const [overlayRef, updateOverlayRef] = useRenderForcingRef() const mergedRefEnabled = useFeatureFlag('primer_react_merged_forwarded_refs') const [overlayElement, setOverlayElement] = useState(null) @@ -432,7 +444,7 @@ export const AnchoredOverlay: React.FC Date: Wed, 19 Aug 2026 12:29:59 +0000 Subject: [PATCH 4/4] Preserve renderAnchor callback compatibility --- .changeset/actionbar-active-anchor.md | 2 +- .../AnchoredOverlay/AnchoredOverlay.docs.json | 4 ++-- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 3 ++- .../AnchoredOverlay.types.test.tsx | 16 +++++++++++++++- packages/react/src/SelectPanel/SelectPanel.tsx | 2 +- packages/react/src/deprecated/ActionMenu.tsx | 2 +- 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.changeset/actionbar-active-anchor.md b/.changeset/actionbar-active-anchor.md index f687d55ea9e..2e3e99a4465 100644 --- a/.changeset/actionbar-active-anchor.md +++ b/.changeset/actionbar-active-anchor.md @@ -2,4 +2,4 @@ '@primer/react': minor --- -ActionBar: Add `activeAnchorRef` to action buttons for overlays that need the action's currently visible trigger. Anchored overlay render functions now receive their reactive anchor ref as a second argument. \ No newline at end of file +ActionBar: Add `activeAnchorRef` to action buttons for overlays that need the action's currently visible trigger. Anchored overlay render functions now receive their reactive anchor ref as an optional second argument. \ No newline at end of file diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json b/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json index cfdbd43f17f..85979b5c726 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json @@ -64,9 +64,9 @@ }, { "name": "renderAnchor", - "type": ">(props: T, anchorRef: React.RefCallback) => JSX.Element", + "type": ">(props: T, anchorRef?: React.RefCallback) => JSX.Element", "defaultValue": "", - "description": "A custom function component used to render the anchor element. The second argument is a reactive ref for the current anchor element. When renderAnchor is null, an anchorRef is required." + "description": "A custom function component used to render the anchor element. The optional second argument is a reactive ref for the current anchor element. When renderAnchor is null, an anchorRef is required." }, { "name": "anchorRef", diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index ee7a8a5e3a3..a704ac1320f 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -22,11 +22,12 @@ import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' interface AnchoredOverlayPropsWithAnchor { /** * A custom function component used to render the anchor element. + * The optional second argument is a reactive ref for the current anchor element. * Will receive the selected text as `children` prop when an item is activated. */ renderAnchor: , 'aria-label' | 'aria-labelledby'>>( props: T, - anchorRef: React.RefCallback, + anchorRef?: React.RefCallback, ) => JSX.Element /** diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.types.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.types.test.tsx index 762039c6655..70da25a1df0 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.types.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.types.test.tsx @@ -1,7 +1,21 @@ import {InfoIcon} from '@primer/octicons-react' -import {AnchoredOverlay} from '.' +import {AnchoredOverlay, type AnchoredOverlayProps} from '.' import {IconButton} from '../Button' +type RenderAnchor = NonNullable + +export function invokeRenderAnchorWithLegacyArguments(renderAnchor: RenderAnchor) { + return renderAnchor({id: 'info'}) +} + +export function renderAnchorWithLegacyCallback() { + return ( + }> +

GitHub

+
+ ) +} + export function itemWithIconElements() { return ( item.text).join(', ') : placeholder const shouldAutoWireLabel = isReferenced === false && Boolean(labelId) && Boolean(selectedValueId) - return >(props: T, anchorRef: React.RefCallback) => { + return >(props: T, anchorRef?: React.RefCallback) => { return renderAnchor( { ...props, diff --git a/packages/react/src/deprecated/ActionMenu.tsx b/packages/react/src/deprecated/ActionMenu.tsx index 684c5f190ff..df5bc489129 100644 --- a/packages/react/src/deprecated/ActionMenu.tsx +++ b/packages/react/src/deprecated/ActionMenu.tsx @@ -68,7 +68,7 @@ const ActionMenuBase = ({ if (renderAnchor === null) { return null } - return >(props: T, anchorRef: React.RefCallback) => { + return >(props: T, anchorRef?: React.RefCallback) => { return renderAnchor( { 'aria-label': 'menu',