Skip to content

Commit fca88fc

Browse files
committed
fix(CFocusTrap): add support for element.ref and element.props.ref to support both React 18 and 19
1 parent fa15500 commit fca88fc

File tree

2 files changed

+58
-9
lines changed

2 files changed

+58
-9
lines changed

packages/coreui-react/src/components/focus-trap/CFocusTrap.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React, { FC, ReactElement, cloneElement, useEffect, useRef } from 'react'
2-
import { mergeRefs, focusableChildren } from './utils'
1+
import React, { FC, ReactElement, cloneElement, use, useEffect, useRef } from 'react'
2+
import { mergeRefs, focusableChildren, getChildRef } from './utils'
33

44
export interface CFocusTrapProps {
55
/**
@@ -146,7 +146,10 @@ export const CFocusTrap: FC<CFocusTrapProps> = ({
146146

147147
if (elements.length === 0) {
148148
container.focus({ preventScroll: true })
149-
} else if (lastTabNavDirectionRef.current === 'backward') {
149+
return
150+
}
151+
152+
if (lastTabNavDirectionRef.current === 'backward') {
150153
elements.at(-1)?.focus({ preventScroll: true })
151154
} else {
152155
elements[0].focus({ preventScroll: true })
@@ -161,20 +164,37 @@ export const CFocusTrap: FC<CFocusTrapProps> = ({
161164
tabEventSourceRef.current = container
162165
lastTabNavDirectionRef.current = event.shiftKey ? 'backward' : 'forward'
163166

164-
if (!_additionalContainer) {
165-
return
166-
}
167-
168167
const containerElements = focusableChildren(container)
169-
const additionalElements = focusableChildren(_additionalContainer)
168+
const additionalElements = _additionalContainer ? focusableChildren(_additionalContainer) : []
170169

171170
if (containerElements.length === 0 && additionalElements.length === 0) {
172171
// No focusable elements, prevent tab
173172
event.preventDefault()
174173
return
175174
}
176175

176+
const focusableElements = [...containerElements, ...additionalElements]
177+
178+
const firstFocusableElement = focusableElements[0] as HTMLElement
179+
const lastFocusableElement = focusableElements.at(-1) as HTMLElement
177180
const activeElement = document.activeElement as HTMLElement
181+
182+
if (event.shiftKey && activeElement === firstFocusableElement) {
183+
event.preventDefault()
184+
lastFocusableElement.focus()
185+
return
186+
}
187+
188+
if (!event.shiftKey && activeElement === lastFocusableElement) {
189+
event.preventDefault()
190+
firstFocusableElement.focus()
191+
return
192+
}
193+
194+
if (!_additionalContainer) {
195+
return
196+
}
197+
178198
const isInContainer = containerElements.includes(activeElement)
179199
const isInAdditional = additionalElements.includes(activeElement)
180200

@@ -245,7 +265,12 @@ export const CFocusTrap: FC<CFocusTrapProps> = ({
245265

246266
// Attach our ref to the ONLY child — no extra wrappers
247267
const onlyChild = React.Children.only(children)
248-
const childRef = (onlyChild as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref
268+
269+
// Handle different ref access patterns between React versions
270+
// React 19+: ref is accessed via element.props.ref
271+
// React 18 and earlier: ref is accessed via element.ref
272+
const childRef: React.Ref<HTMLElement> | undefined = getChildRef(onlyChild)
273+
249274
const mergedRef = mergeRefs(childRef, (node: HTMLElement | null) => {
250275
containerRef.current = node
251276
})

packages/coreui-react/src/components/focus-trap/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,30 @@ export const focusableChildren = (element: HTMLElement): HTMLElement[] => {
2323
return elements.filter((el) => !isDisabled(el) && isVisible(el))
2424
}
2525

26+
/**
27+
* Extracts the ref from a React element, handling version differences between React 18 and 19+.
28+
*
29+
* In React 18 and earlier, refs are stored directly on the element object.
30+
* In React 19+, refs are stored in the element's props object due to changes in React's internals.
31+
* This function automatically detects the React version and uses the appropriate access pattern.
32+
* @param child - The React element to extract the ref from
33+
* @returns The ref attached to the element, or undefined if no ref is present
34+
*/
35+
export const getChildRef = (child: React.ReactElement): React.Ref<HTMLElement> | undefined => {
36+
const major = Number(React.version?.split?.('.')[0] ?? 18)
37+
// React 18 stores ref directly on the element
38+
if (major <= 18 && 'ref' in child && child.ref !== undefined) {
39+
return (child as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref
40+
}
41+
42+
// React 19 stores ref in props
43+
if (child.props && typeof child.props === 'object' && 'ref' in child.props) {
44+
return (child.props as { ref?: React.Ref<HTMLElement> }).ref
45+
}
46+
47+
return undefined
48+
}
49+
2650
/**
2751
* Checks if an element is disabled.
2852
* Considers various ways an element can be disabled including CSS classes and attributes.

0 commit comments

Comments
 (0)