Skip to content

Commit 2f54845

Browse files
authored
fix: Don’t focus first 'Item' of 'DropdownMenu' and 'SelectMenu' on open (#1270)
* fix: Don’t focus first 'Item' of 'DropdownMenu' or 'SelectMenu' on open. Instead, invisibly focus container. * fix: Do focus filter in 'SelectPanel' on open. * fix: Update tests to account for not focusing first item. * fix: Since 'getCurrentFocusedIndex' has only one callsite, move the container check into that function to consolidate indexing logic. * fix: Preserve 'container'’s 'tabIndex', when it exists. * fix: Account for the case where 'container' is focusable before a temporary tab index is applied
1 parent 6887707 commit 2f54845

File tree

7 files changed

+42
-22
lines changed

7 files changed

+42
-22
lines changed

src/AnchoredOverlay/AnchoredOverlay.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, {useCallback, useMemo, useRef} from 'react'
22
import Overlay, {OverlayProps} from '../Overlay'
3-
import {useFocusTrap} from '../hooks/useFocusTrap'
3+
import {FocusTrapHookSettings, useFocusTrap} from '../hooks/useFocusTrap'
44
import {FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
55
import {useAnchoredPosition, useRenderForcingRef} from '../hooks'
66
import {uniqueId} from '../utils/uniqueId'
@@ -32,6 +32,11 @@ export interface AnchoredOverlayProps extends Pick<OverlayProps, 'height' | 'wid
3232
*/
3333
overlayProps?: Partial<OverlayProps>
3434

35+
/**
36+
* Settings to apply to the Focus Zone on the internal `Overlay` component.
37+
*/
38+
focusTrapSettings?: Partial<FocusTrapHookSettings>
39+
3540
/**
3641
* Settings to apply to the Focus Zone on the internal `Overlay` component.
3742
*/
@@ -51,6 +56,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
5156
height,
5257
width,
5358
overlayProps,
59+
focusTrapSettings,
5460
focusZoneSettings
5561
}) => {
5662
const anchorRef = useRef<HTMLElement>(null)
@@ -96,7 +102,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
96102
disabled: !open || !position,
97103
...focusZoneSettings
98104
})
99-
useFocusTrap({containerRef: overlayRef, disabled: !open || !position})
105+
useFocusTrap({containerRef: overlayRef, disabled: !open || !position, ...focusTrapSettings})
100106

101107
return (
102108
<>

src/FilteredActionList/FilteredActionList.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {itemActiveDescendantClass} from '../ActionList/Item'
1111
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
1212
import styled from 'styled-components'
1313
import {get} from '../constants'
14+
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
1415
import useScrollFlash from '../hooks/useScrollFlash'
1516

1617
export interface FilteredActionListProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
@@ -19,6 +20,7 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
1920
filterValue?: string
2021
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
2122
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
23+
inputRef?: React.RefObject<HTMLInputElement>
2224
}
2325

2426
function scrollIntoViewingArea(
@@ -56,6 +58,7 @@ export function FilteredActionList({
5658
onFilterChange,
5759
items,
5860
textInputProps,
61+
inputRef: providedInputRef,
5962
...listProps
6063
}: FilteredActionListProps): JSX.Element {
6164
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
@@ -70,7 +73,7 @@ export function FilteredActionList({
7073

7174
const containerRef = useRef<HTMLInputElement>(null)
7275
const scrollContainerRef = useRef<HTMLDivElement>(null)
73-
const inputRef = useRef<HTMLInputElement>(null)
76+
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
7477
const activeDescendantRef = useRef<HTMLElement>()
7578
const listId = useMemo(uniqueId, [])
7679
const onInputKeyPress: KeyboardEventHandler = useCallback(

src/Overlay.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ const StyledOverlay = styled.div<StyledOverlayProps & SystemCommonProps & System
5454
}
5555
}
5656
visibility: ${props => props.visibility || 'visible'};
57+
:focus {
58+
outline: none;
59+
}
5760
${COMMON};
5861
${POSITION};
5962
${sx};

src/SelectPanel/SelectPanel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,19 @@ export function SelectPanel({
127127
})
128128
}, [onClose, onSelectedChange, items, selected])
129129

130+
const inputRef = React.useRef<HTMLInputElement>(null)
131+
const focusTrapSettings = {
132+
initialFocusRef: inputRef
133+
}
134+
130135
return (
131136
<AnchoredOverlay
132137
renderAnchor={renderMenuAnchor}
133138
open={open}
134139
onOpen={onOpen}
135140
onClose={onClose}
136141
overlayProps={overlayProps}
142+
focusTrapSettings={focusTrapSettings}
137143
focusZoneSettings={focusZoneSettings}
138144
>
139145
<Flex flexDirection="column" width="100%" height="100%">
@@ -145,6 +151,7 @@ export function SelectPanel({
145151
items={itemsToRender}
146152
selectionVariant={isMultiSelectVariant(selected) ? 'multiple' : 'single'}
147153
textInputProps={textInputProps}
154+
inputRef={inputRef}
148155
/>
149156
</Flex>
150157
</AnchoredOverlay>

src/__tests__/behaviors/focusTrap.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ beforeAll(() => {
2222
}
2323
})
2424

25-
it('Should initially focus the first focusable element when activated', () => {
25+
it('Should initially focus the container when activated', () => {
2626
const {container} = render(
2727
<div>
2828
<button tabIndex={0}>Bad Apple</button>
@@ -35,9 +35,8 @@ it('Should initially focus the first focusable element when activated', () => {
3535
)
3636

3737
const trapContainer = container.querySelector<HTMLElement>('#trapContainer')!
38-
const firstButton = trapContainer.querySelector('button')!
3938
const controller = focusTrap(trapContainer)
40-
expect(document.activeElement).toEqual(firstButton)
39+
expect(document.activeElement).toEqual(trapContainer)
4140

4241
controller.abort()
4342
})
@@ -74,13 +73,12 @@ it('Should prevent focus from exiting the trap, returns focus to previously-focu
7473
)
7574

7675
const trapContainer = container.querySelector<HTMLElement>('#trapContainer')!
77-
const firstButton = trapContainer.querySelector('button')!
7876
const secondButton = trapContainer.querySelectorAll('button')[1]!
7977
const durianButton = container.querySelector<HTMLElement>('#durian')!
8078
const controller = focusTrap(trapContainer)
8179

8280
focus(durianButton)
83-
expect(document.activeElement).toEqual(firstButton)
81+
expect(document.activeElement).toEqual(trapContainer)
8482

8583
focus(secondButton)
8684
expect(document.activeElement).toEqual(secondButton)
@@ -157,12 +155,11 @@ it('Should should release the trap when the signal is aborted', async () => {
157155

158156
const trapContainer = container.querySelector<HTMLElement>('#trapContainer')!
159157
const durianButton = container.querySelector<HTMLElement>('#durian')!
160-
const firstButton = trapContainer.querySelector('button')!
161158

162159
const controller = focusTrap(trapContainer)
163160

164161
focus(durianButton)
165-
expect(document.activeElement).toEqual(firstButton)
162+
expect(document.activeElement).toEqual(trapContainer)
166163

167164
controller.abort()
168165

@@ -189,7 +186,7 @@ it('Should should release the trap when the container is removed from the DOM',
189186
focusTrap(trapContainer)
190187

191188
focus(durianButton)
192-
expect(document.activeElement).toEqual(firstButton)
189+
expect(document.activeElement).toEqual(trapContainer)
193190

194191
// empty trap and remove it from the DOM
195192
trapContainer.removeChild(firstButton)

src/behaviors/focusTrap.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ export function focusTrap(
6868
// Ensure focus remains in the trap zone by checking that a given recently-focused
6969
// element is inside the trap zone. If it isn't, redirect focus to a suitable
7070
// element within the trap zone. If need to redirect focus and a suitable element
71-
// is not found, blur the recently-focused element so that focus doesn't leave the
72-
// trap zone.
71+
// is not found, focus the container.
7372
function ensureTrapZoneHasFocus(focusedElement: EventTarget | null) {
7473
if (focusedElement instanceof HTMLElement && document.contains(container)) {
7574
if (container.contains(focusedElement)) {
@@ -80,16 +79,19 @@ export function focusTrap(
8079
if (lastFocusedChild && isTabbable(lastFocusedChild) && container.contains(lastFocusedChild)) {
8180
lastFocusedChild.focus()
8281
return
82+
} else if (initialFocus && container.contains(initialFocus)) {
83+
initialFocus.focus()
84+
return
8385
} else {
84-
const toFocus = initialFocus && container.contains(initialFocus) ? initialFocus : getFocusableChild(container)
85-
if (toFocus) {
86-
toFocus.focus()
87-
return
88-
} else {
89-
// no element focusable within trap, blur the external element instead
90-
// eslint-disable-next-line github/no-blur
91-
focusedElement.blur()
86+
const containerNeedsTemporaryTabIndex = container.getAttribute('tabindex') === null
87+
if (containerNeedsTemporaryTabIndex) {
88+
container.setAttribute('tabindex', '-1')
89+
}
90+
container.focus()
91+
if (containerNeedsTemporaryTabIndex) {
92+
container.removeAttribute('tabindex')
9293
}
94+
return
9395
}
9496
}
9597
}

src/behaviors/focusZone.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,9 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
588588
}
589589

590590
const focusedIndex = focusableElements.indexOf(currentFocusedElement)
591-
return focusedIndex === -1 ? 0 : focusedIndex
591+
const fallbackIndex = currentFocusedElement === container ? -1 : 0
592+
593+
return focusedIndex !== -1 ? focusedIndex : fallbackIndex
592594
}
593595

594596
// "keydown" is the event that triggers DOM focus change, so that is what we use here

0 commit comments

Comments
 (0)