Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
dcae302
Expose `useMergedRefs` and make ready for React 19
iansan5653 Mar 17, 2026
bb26a62
Add unit tests for `useMergedRefs`
iansan5653 Mar 17, 2026
3d854c9
Update import paths
iansan5653 Mar 17, 2026
8e77831
Deprecate `useProvidedRefOrCreate` and `useRefObjectAsForwardedRef`
iansan5653 Mar 17, 2026
973f184
Add changeset
iansan5653 Mar 17, 2026
63fca28
Deprecate `useProvidedRefOrCreate` and migrate everything except for …
iansan5653 Mar 9, 2026
3b478e3
Migrate `AnchoredOverlay` anchor ref
iansan5653 Mar 9, 2026
04e1ca7
Drop more ts-expect-error!
iansan5653 Mar 9, 2026
ec31af7
Revert behaviors docs
iansan5653 Mar 9, 2026
7bf1ce1
Update packages/react/src/Checkbox/Checkbox.tsx
iansan5653 Mar 10, 2026
006cc27
Update useCombinedRefs to useMergedRefs
iansan5653 Mar 17, 2026
12abb13
Merge branch 'main' of https://github.com/primer/react into replace-u…
iansan5653 Mar 24, 2026
196c083
Fix a few renames
iansan5653 Mar 24, 2026
6e410b6
Somehow I missed some spots???
iansan5653 Mar 24, 2026
a09ae22
anchorref > anchorRef
iansan5653 Mar 24, 2026
90e9941
Revert some files because of a valid use case
iansan5653 Mar 24, 2026
dda4382
Update `useProvidedRefOrCreate` to remove deprecation comment
iansan5653 Mar 24, 2026
f86b058
Don't return a callback ref in React 18-
iansan5653 Mar 24, 2026
c0612a7
Update tests
iansan5653 Mar 24, 2026
072e5b9
Merge branch 'main' into replace-use-provided-ref-or-create
iansan5653 Mar 31, 2026
44e6850
Fix double import from merging
iansan5653 Mar 31, 2026
538f208
Merge branch 'main' of https://github.com/primer/react into replace-u…
iansan5653 Apr 8, 2026
607676a
Merge branch 'main' into replace-use-provided-ref-or-create
llastflowers Jun 26, 2026
bba5847
move merged ref update into useTabList after main refactor
llastflowers Jun 26, 2026
5afda49
fix merge conflict artifact
llastflowers Jun 26, 2026
cd349b0
fix merge conflict artifact
llastflowers Jun 26, 2026
75b228a
fix merge conflict artifact
llastflowers Jun 26, 2026
015347a
fix merge conflict artifact
llastflowers Jun 26, 2026
d30a7f0
fix more merge conflict artifacts
llastflowers Jun 26, 2026
2d40aaa
FilteredActionList updates to match main
llastflowers Jun 26, 2026
2c164e3
Fix AutocompleteInput blur handling with merged refs
llastflowers Jun 26, 2026
e032720
add back accidentally removed lint suppression
llastflowers Jun 26, 2026
fc78eed
ci fix
llastflowers Jun 26, 2026
d18507c
Fix Tabs ref types for useMergedRefs
llastflowers Jun 26, 2026
e88a6e5
Fix Tabs story ref usage with ActionList
llastflowers Jun 26, 2026
a72cd22
Fix Tabs story ActionList ref typing
llastflowers Jun 26, 2026
1c28af9
back last change out and try a different ci fix
llastflowers Jun 26, 2026
67c0f23
test(vrt): update snapshots
llastflowers Jun 26, 2026
e279f5b
test(vrt): update snapshots
llastflowers Jun 29, 2026
39995b7
revert flakey snapshots
llastflowers Jun 29, 2026
607ba77
Merge branch 'main' into replace-use-provided-ref-or-create
llastflowers Jun 29, 2026
fe5fd15
Merge branch 'main' into replace-use-provided-ref-or-create
llastflowers Jul 2, 2026
10882b2
Merge branch 'main' into replace-use-provided-ref-or-create
llastflowers Jul 10, 2026
d8da4e1
Gate forwarded-ref migration behind primer_react_merged_forwarded_ref…
llastflowers Jul 10, 2026
c005c69
Delete .changeset/merged-forwarded-refs-flag.md
llastflowers Jul 10, 2026
25b202a
Update DefaultFeatureFlags.ts
llastflowers Jul 10, 2026
8f82efd
revert to main snapshots
llastflowers Jul 10, 2026
6d18f42
test(vrt): update snapshots
llastflowers Jul 10, 2026
7d5e493
Revert UnderlineNav snapshots to main
llastflowers Jul 10, 2026
86a6558
Update AnchoredOverlay.tsx
llastflowers Jul 14, 2026
38b56cf
Rename combined ref variables to merged for consistency
llastflowers Jul 15, 2026
6d18a85
change naming of FF aliases to clarify names needed after graduation
llastflowers Jul 15, 2026
f268609
one more tiny consistency nit tweak
llastflowers Jul 15, 2026
058a670
Drop redundant assignRef in AnchoredOverlay overlay ref callback when…
llastflowers Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/react/src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {describe, it, expect, vi} from 'vitest'
import {render as HTMLRender} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {ActionList} from '.'
import {createRef} from 'react'
import {ActionListContainerContext} from './ActionListContainerContext'
import {implementsClassName} from '../utils/testing'
import classes from './ActionList.module.css'
Expand Down Expand Up @@ -606,3 +607,33 @@ describe('ActionList with role="tree"', () => {
expect(container.querySelector('[data-component="ActionList"]')).not.toHaveAttribute('data-item-gap')
})
})

describe('ActionList forwarded ref (primer_react_merged_forwarded_refs)', () => {
for (const enabled of [true, false]) {
describe(`with the flag ${enabled ? 'enabled' : 'disabled'}`, () => {
it('forwards a ref object to the element', () => {
const ref = createRef<HTMLUListElement>()
HTMLRender(
<FeatureFlags flags={{primer_react_merged_forwarded_refs: enabled}}>
<ActionList ref={ref}>
<ActionList.Item>Item</ActionList.Item>
</ActionList>
</FeatureFlags>,
)
expect(ref.current).toBeInstanceOf(HTMLElement)
})

it('calls a callback ref with the element', () => {
const refCallback = vi.fn()
HTMLRender(
<FeatureFlags flags={{primer_react_merged_forwarded_refs: enabled}}>
<ActionList ref={refCallback}>
<ActionList.Item>Item</ActionList.Item>
</ActionList>
</FeatureFlags>,
)
expect(refCallback.mock.calls.some(([el]) => el instanceof HTMLElement)).toBe(true)
})
})
}
})
19 changes: 13 additions & 6 deletions packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, {type JSX} from 'react'
import React, {useRef, type JSX} from 'react'
import {fixedForwardRef} from '../utils/modern-polymorphic'
import {ActionListContainerContext} from './ActionListContainerContext'
import {useSlots} from '../hooks/useSlots'
import {Heading} from './Heading'
import {useId} from '../hooks/useId'
import {ListContext, type ActionListProps} from './shared'
import {useProvidedRefOrCreate} from '../hooks'
import {useMergedRefs, useProvidedRefOrCreate} from '../hooks'
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'
import {clsx} from 'clsx'
import classes from './ActionList.module.css'
Expand Down Expand Up @@ -43,7 +43,14 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(

const ariaLabelledBy = slots.heading ? (slots.heading.props.id ?? headingId) : listLabelledBy
const listRole = role || listRoleFromContainer
const listRef = useProvidedRefOrCreate(forwardedRef as React.RefObject<HTMLUListElement>)
const mergedRefEnabled = useFeatureFlag('primer_react_merged_forwarded_refs')
const listRef = useRef<HTMLElement>(null)
const mergedRef = useMergedRefs(listRef, forwardedRef)
// Feature-flag scaffolding for `primer_react_merged_forwarded_refs`.
// At graduation: remove the three declarations below, and replace all instances of `readRef` with `listRef` and `appliedRef` with `mergedRef`.
const providedOrCreatedRef = useProvidedRefOrCreate(forwardedRef as React.RefObject<HTMLElement>)
const readRef = mergedRefEnabled ? listRef : providedOrCreatedRef
const appliedRef = mergedRefEnabled ? mergedRef : providedOrCreatedRef
const itemGapEnabled = useFeatureFlag('primer_react_action_list_item_gap') && container === 'NavList'

let enableFocusZone = false
Expand All @@ -52,7 +59,7 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(

useFocusZone({
disabled: !enableFocusZone,
containerRef: listRef,
containerRef: readRef,
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
focusOutBehavior:
listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined,
Expand Down Expand Up @@ -84,7 +91,7 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
// Two querySelector calls after render is trivially cheap compared to what the browser
// was doing on every DOM mutation with `:has()`.
useIsomorphicLayoutEffect(() => {
const list = listRef.current
const list = readRef.current
if (!list) return
const hasMixed =
list.querySelector('[data-has-description="true"]') !== null &&
Expand All @@ -105,7 +112,7 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
className={clsx(classes.ActionList, className)}
role={listRole}
aria-labelledby={ariaLabelledBy}
ref={listRef}
ref={appliedRef}
data-component="ActionList"
data-dividers={showDividers}
data-variant={variant}
Expand Down
46 changes: 46 additions & 0 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -765,3 +765,49 @@ describe('AnchoredOverlay anchor element replacement', () => {
expect(newAnchor.style.getPropertyValue('anchor-name')).toBe(anchorName)
})
})

describe('AnchoredOverlay overlay forwarded ref (primer_react_merged_forwarded_refs)', () => {
for (const enabled of [true, false]) {
describe(`with the flag ${enabled ? 'enabled' : 'disabled'}`, () => {
it('forwards a ref object to the overlay', () => {
const ref = createRef<HTMLDivElement>()
render(
<FeatureFlags flags={{primer_react_merged_forwarded_refs: enabled}}>
<BaseStyles>
<AnchoredOverlay
open
onClose={() => {}}
onOpen={() => {}}
renderAnchor={props => <Button {...props}>Anchor</Button>}
overlayProps={{ref}}
>
<button type="button">Child</button>
</AnchoredOverlay>
</BaseStyles>
</FeatureFlags>,
)
expect(ref.current).toBeInstanceOf(HTMLDivElement)
})

it('calls a callback ref with the overlay', () => {
const refCallback = vi.fn()
render(
<FeatureFlags flags={{primer_react_merged_forwarded_refs: enabled}}>
<BaseStyles>
<AnchoredOverlay
open
onClose={() => {}}
onOpen={() => {}}
renderAnchor={props => <Button {...props}>Anchor</Button>}
overlayProps={{ref: refCallback}}
>
<button type="button">Child</button>
</AnchoredOverlay>
</BaseStyles>
</FeatureFlags>,
)
expect(refCallback.mock.calls.some(([el]) => el instanceof HTMLDivElement)).toBe(true)
})
})
}
})
22 changes: 14 additions & 8 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {FocusTrapHookSettings} from '../hooks/useFocusTrap'
import {useFocusTrap} from '../hooks/useFocusTrap'
import type {FocusZoneHookSettings} from '../hooks/useFocusZone'
import {useFocusZone} from '../hooks/useFocusZone'
import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
import {useAnchoredPosition, useMergedRefs, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
import {useId} from '../hooks/useId'
import type {AnchorPosition, PositionSettings} from '@primer/behaviors'
import {type ResponsiveValue} from '../hooks/useResponsiveValue'
Expand Down Expand Up @@ -207,7 +207,9 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
setAnchorElement(anchorRef.current)
}
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
const mergedRefEnabled = useFeatureFlag('primer_react_merged_forwarded_refs')
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null)
const mergedOverlayRef = useMergedRefs(updateOverlayRef, useMergedRefs(overlayProps?.ref, setOverlayElement))
const anchorId = useId(externalAnchorId)

const onClickOutside = useCallback(() => onClose?.('click-outside'), [onClose])
Expand Down Expand Up @@ -436,13 +438,17 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
{...(shouldRenderAsPopover ? {popover: 'manual'} : {})}
{...restOverlayProps}
{...(shouldRenderAsPopover ? {id: popoverId} : {})}
ref={node => {
if (overlayProps?.ref) {
assignRef(overlayProps.ref, node)
}
updateOverlayRef(node)
setOverlayElement(node)
}}
ref={
mergedRefEnabled
? mergedOverlayRef
: node => {
setOverlayElement(node)
if (overlayProps?.ref) {
assignRef(overlayProps.ref, node)
}
updateOverlayRef(node)
}
}
data-anchor-position={cssAnchorPositioning}
data-side={cssAnchorPositioning ? side : position?.anchorSide}
>
Expand Down
35 changes: 16 additions & 19 deletions packages/react/src/Autocomplete/AutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,19 @@ const AutocompleteInput = React.forwardRef(

const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(
event => {
onBlur && onBlur(event)
onBlur?.(event)

// HACK: wait a tick and check the focused element before hiding the autocomplete menu
// this prevents the menu from hiding when the user is clicking an option in the Autoselect.Menu,
// but still hides the menu when the user blurs the input by tabbing out or clicking somewhere else on the page
// HACK: wait a tick before hiding the menu so click interactions can complete.
// Use the blur event's relatedTarget to determine whether focus is moving into the
// autocomplete menu; if not, hide the menu when focus leaves the input.
safeSetTimeout(() => {
if (document.activeElement !== inputRef.current) {
const nextFocusedElement = event.relatedTarget as Node | null
const menuElement = document.getElementById(`${id}-listbox`)

if (
!nextFocusedElement ||
(nextFocusedElement !== menuElement && !menuElement?.contains(nextFocusedElement))
) {
Comment on lines +61 to +71

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are these changes? they seem unrelated to the PR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a fix for an issue surfaced by an Autocomplete test that started failing once the mergedRefs pattern was introduced to TextInput in this PR. That change broke Autocomplete's blur logic because it used to depend on how TextInput wires up the ref (inputRef on line 65 in the diff), so I changed the blur logic to not depend on the ref anymore. Now it uses the blur event's relatedTarget to detect when the focus is inside the menu, which works regardless of the refs setup (FF on or off).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does if (document.activeElement !== inputRef.current) not work anymore? does the inputRef work on line 78?
Trying to understand if forwardedRefs are compromised for TextInput in general or not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely agree with @francinelucca here; this seems suspicious. inputRef should be working just as before; if it's not getting bound to the target input then that points at a different problem that's worth digging in to. I would try to find a solution that makes the ref work properly; it could be a problem with how we are binding or creating it. I dug through the code but nothing stood out to me.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does work, and none of the ref stuff is compromised! The sequence of events is kind of confusing, but basically AutocompleteInput and TextInput need to communicate for the blur handler to work properly in the context of the test, and they were at first when both were using the old refs setup.

After this PR was originally opened, #7834 landed on main. It changed two things at once: AutocompleteInput now passes a callback ref, and the test switched from userEvent.tab() to fireEvent.blur() (to address some test flakiness issues; see inline comment in test code snippet below). With these changes, the aforementioned communication is broken, but the test still passes (false positive). The callback ref is one the old TextInput can't apply to focus the input, so the input never actually gets focused. And because fireEvent.blur() fires the handler anyway, the old document.activeElement !== input check sees focus 'off' the input and the test reads it as success. Not because the focus actually moved, but because it was never there in the first place. This is what's on main right now.

On this PR branch, TextInput now focuses the input via its own internal ref, so the mismatch with the AutocompleteInput ref stuff is no longer an issue. But because of the fireEvent.blur() change in the test, it started failing now that the refs are working properly again: userEvent.tab() moves focus off of the active element, but fireEvent.blur() does not. So the input is focused now, but the focus never moves off of the input, so the menu doesn't close, and the test fails.

This exposes imo a legitimate fragility of the original handler: it can't actually tell the difference between the input being focused and then focus moving away vs. the input never being focused at all. The new handler avoids this by checking relatedTarget instead, which is read directly from the blur event. It's based on the actual focus transition instead of an inference based on the DOM state after the interaction. The new handler is also more stable in practice because it doesn't rely on timing (when exactly the document.activeElement changes or ref updates, etc.).

This is the test:

    it('closes the menu when the input is blurred', async () => {
      const {getByLabelText} = render(
        <LabelledAutocomplete menuProps={{items: [], selectedItemIds: [], ['aria-labelledby']: 'autocompleteLabel'}} />,
      )
      const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)

      expect(inputNode.getAttribute('aria-expanded')).not.toBe('true')
      fireEvent.click(inputNode)
      fireEvent.keyDown(inputNode, {key: 'ArrowDown'})

      expect(inputNode.getAttribute('aria-expanded')).toBe('true')

      // `userEvent.tab()` is unreliable in browser-mode Vitest for this case; blur is deterministic.
      // eslint-disable-next-line github/no-blur
      fireEvent.blur(inputNode)

      await waitFor(() => expect(inputNode.getAttribute('aria-expanded')).not.toBe('true'))
    })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thank you for the detailed explanation! Would you be open to pulling this change out into a separate PR? Feels like it might be worth splitting out so it can ship faster / independently of this wider refactoring change.

Otherwise though it sounds good to me 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately if we remove this change from this branch, the test will start failing again since it's related to the TextInput refactoring changes!

setShowMenu(false)

// Reset the input's value to the text the user actually typed rather than leaving the
Expand All @@ -75,11 +81,11 @@ const AutocompleteInput = React.forwardRef(
}
}, 0)
},
[onBlur, setShowMenu, inputRef, safeSetTimeout, autocompleteSuggestion, inputValue],
[onBlur, setShowMenu, inputRef, safeSetTimeout, autocompleteSuggestion, inputValue, id],
)

const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
onChange && onChange(event)
onChange?.(event)
setInputValue(event.currentTarget.value)
if (!showMenu) {
setShowMenu(true)
Expand All @@ -88,7 +94,7 @@ const AutocompleteInput = React.forwardRef(

const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
event => {
onKeyDown && onKeyDown(event)
onKeyDown?.(event)

if (event.key === 'Backspace') {
setHighlightRemainingText(false)
Expand All @@ -107,7 +113,7 @@ const AutocompleteInput = React.forwardRef(

const handleInputKeyUp: KeyboardEventHandler<HTMLInputElement> = useCallback(
event => {
onKeyUp && onKeyUp(event)
onKeyUp?.(event)

if (event.key === 'Backspace') {
setHighlightRemainingText(true)
Expand All @@ -118,12 +124,11 @@ const AutocompleteInput = React.forwardRef(

const onInputKeyPress: KeyboardEventHandler<HTMLInputElement> = useCallback(
event => {
onKeyPress && onKeyPress(event)
onKeyPress?.(event)
if (showMenu && event.key === 'Enter' && activeDescendantRef.current) {
event.preventDefault()
event.nativeEvent.stopImmediatePropagation()

// Forward Enter key press to active descendant so that item gets activated
const activeDescendantEvent = new KeyboardEvent(event.type, event.nativeEvent)
activeDescendantRef.current.dispatchEvent(activeDescendantEvent)
}
Expand All @@ -136,17 +141,10 @@ const AutocompleteInput = React.forwardRef(
return
}

// resets input value to being empty after a selection has been made
if (!autocompleteSuggestion) {
inputRef.current.value = inputValue
}

// TODO: fix bug where this function prevents `onChange` from being triggered if the highlighted item text
// is the same as what I'm typing
// e.g.: typing 'tw' highlights 'two', but when I 'two', the text input change does not get triggered
// Only apply the inline autocomplete suggestion while the input is focused. Without this guard,
// the suggestion can be re-applied to the DOM after the input is blurred, which would restore
// the full suggestion the user was editing away from. See https://github.com/primer/react/issues/4275
const isInputFocused = document.activeElement === inputRef.current

if (
Expand All @@ -163,7 +161,6 @@ const AutocompleteInput = React.forwardRef(
}
}

// calling this useEffect when `highlightRemainingText` changes breaks backspace functionality
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autocompleteSuggestion, inputValue, inputRef, isMenuDirectlyActivated])

Expand Down
34 changes: 33 additions & 1 deletion packages/react/src/ButtonGroup/ButtonGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {render, screen} from '@testing-library/react'
import ButtonGroup from './ButtonGroup'
import {describe, expect, it} from 'vitest'
import {createRef} from 'react'
import {FeatureFlags} from '../FeatureFlags'
import {describe, expect, it, vi} from 'vitest'
import {implementsClassName} from '../utils/testing'
import classes from './ButtonGroup.module.css'

Expand All @@ -22,3 +24,33 @@ describe('ButtonGroup', () => {
expect(screen.getByRole('toolbar')).toBeInTheDocument()
})
})

describe('ButtonGroup forwarded ref (primer_react_merged_forwarded_refs)', () => {
for (const enabled of [true, false]) {
describe(`with the flag ${enabled ? 'enabled' : 'disabled'}`, () => {
it('forwards a ref object to the element', () => {
const ref = createRef<HTMLDivElement>()
render(
<FeatureFlags flags={{primer_react_merged_forwarded_refs: enabled}}>
<ButtonGroup ref={ref}>
<button type="button">A</button>
</ButtonGroup>
</FeatureFlags>,
)
expect(ref.current).toBeInstanceOf(HTMLDivElement)
})

it('calls a callback ref with the element', () => {
const refCallback = vi.fn()
render(
<FeatureFlags flags={{primer_react_merged_forwarded_refs: enabled}}>
<ButtonGroup ref={refCallback}>
<button type="button">A</button>
</ButtonGroup>
</FeatureFlags>,
)
expect(refCallback.mock.calls.some(([el]) => el instanceof HTMLDivElement)).toBe(true)
})
})
}
})
20 changes: 14 additions & 6 deletions packages/react/src/ButtonGroup/ButtonGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, {type PropsWithChildren} from 'react'
import React, {useRef, type PropsWithChildren} from 'react'
import classes from './ButtonGroup.module.css'
import {clsx} from 'clsx'
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'
import {useProvidedRefOrCreate} from '../hooks'
import {useMergedRefs, useProvidedRefOrCreate} from '../hooks'
import {useFeatureFlag} from '../FeatureFlags'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'

export type ButtonGroupProps = PropsWithChildren<{
Expand All @@ -16,24 +17,31 @@ const ButtonGroup = React.forwardRef(function ButtonGroup(
{as: BaseComponent = 'div', children, className, role, ...rest},
forwardRef,
) {
const mergedRefEnabled = useFeatureFlag('primer_react_merged_forwarded_refs')
const buttonRef = useRef<HTMLDivElement>(null)
const mergedRef = useMergedRefs(buttonRef, forwardRef)
// Feature-flag scaffolding for `primer_react_merged_forwarded_refs`.
// At graduation: remove the three declarations below, and replace all instances of `readRef` with `buttonRef` and `appliedRef` with `mergedRef`.
const providedOrCreatedRef = useProvidedRefOrCreate(forwardRef as React.RefObject<HTMLDivElement | null>)
const readRef = mergedRefEnabled ? buttonRef : providedOrCreatedRef
const appliedRef = mergedRefEnabled ? mergedRef : providedOrCreatedRef
const buttons = React.Children.map(children, (child, index) => (
<div key={index} className={classes.Item}>
{child}
</div>
))
const buttonRef = useProvidedRefOrCreate(forwardRef as React.RefObject<HTMLDivElement | null>)

useFocusZone({
containerRef: buttonRef,
containerRef: readRef,
disabled: role !== 'toolbar',
bindKeys: FocusKeys.ArrowHorizontal,
focusOutBehavior: 'wrap',
})

return (
<BaseComponent
//@ts-expect-error it needs a non nullable ref
ref={buttonRef}
// @ts-expect-error it needs a non nullable ref
ref={appliedRef}
className={clsx(className, classes.ButtonGroup)}
role={role}
{...rest}
Expand Down
Loading
Loading