Skip to content

Commit fce18ee

Browse files
authored
Merge branch 'main' into dep-bump
2 parents 75d2d72 + 1148a71 commit fce18ee

File tree

9 files changed

+147
-28
lines changed

9 files changed

+147
-28
lines changed

.changeset/good-grapes-collect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/components": patch
3+
---
4+
5+
Restore "fix: Don’t focus first 'Item' of 'DropdownMenu' and 'SelectMenu' on open" by deferring the removal of a temporary `tabIndex` until focus moves within the container.

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} 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, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
66
import {uniqueId} from '../utils/uniqueId'
@@ -53,6 +53,11 @@ interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'
5353
*/
5454
overlayProps?: Partial<OverlayProps>
5555

56+
/**
57+
* Settings to apply to the Focus Zone on the internal `Overlay` component.
58+
*/
59+
focusTrapSettings?: Partial<FocusTrapHookSettings>
60+
5661
/**
5762
* Settings to apply to the Focus Zone on the internal `Overlay` component.
5863
*/
@@ -76,6 +81,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
7681
height,
7782
width,
7883
overlayProps,
84+
focusTrapSettings,
7985
focusZoneSettings
8086
}) => {
8187
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
@@ -121,7 +127,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
121127
disabled: !open || !position,
122128
...focusZoneSettings
123129
})
124-
useFocusTrap({containerRef: overlayRef, disabled: !open || !position})
130+
useFocusTrap({containerRef: overlayRef, disabled: !open || !position, ...focusTrapSettings})
125131

126132
return (
127133
<>

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: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import styled from 'styled-components'
2-
import React, {ReactElement, useRef} from 'react'
2+
import React, {ReactElement, useEffect, useRef} from 'react'
33
import {get, COMMON, POSITION, SystemPositionProps, SystemCommonProps} from './constants'
44
import {ComponentProps} from './utils/types'
55
import {useOverlay, TouchOrMouseEvent} from './hooks'
@@ -10,6 +10,7 @@ import {useCombinedRefs} from './hooks/useCombinedRefs'
1010
type StyledOverlayProps = {
1111
width?: keyof typeof widthMap
1212
height?: keyof typeof heightMap
13+
maxHeight?: keyof Omit<typeof heightMap, 'auto' | 'initial'>
1314
visibility?: 'visible' | 'hidden'
1415
}
1516

@@ -19,7 +20,8 @@ const heightMap = {
1920
medium: '320px',
2021
large: '432px',
2122
xlarge: '600px',
22-
auto: 'auto'
23+
auto: 'auto',
24+
initial: 'auto' // Passing 'initial' initially applies 'auto'
2325
}
2426

2527
const widthMap = {
@@ -39,6 +41,7 @@ const StyledOverlay = styled.div<StyledOverlayProps & SystemCommonProps & System
3941
min-width: 192px;
4042
max-width: 640px;
4143
height: ${props => heightMap[props.height || 'auto']};
44+
max-height: ${props => props.maxHeight && heightMap[props.maxHeight]};
4245
width: ${props => widthMap[props.width || 'auto']};
4346
border-radius: 12px;
4447
overflow: hidden;
@@ -54,6 +57,9 @@ const StyledOverlay = styled.div<StyledOverlayProps & SystemCommonProps & System
5457
}
5558
}
5659
visibility: ${props => props.visibility || 'visible'};
60+
:focus {
61+
outline: none;
62+
}
5763
${COMMON};
5864
${POSITION};
5965
${sx};
@@ -78,31 +84,49 @@ export type OverlayProps = {
7884
* @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function sets the `Overlay` visibility state to `false`.
7985
* @param onEscape Required. Function to call when user presses `Escape`. Typically this function sets the `Overlay` visibility state to `false`.
8086
* @param width Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `480px`, `xlarge` corresponds to `640px`, `xxlarge` corresponds to `960px`.
81-
* @param height Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`. `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`.
87+
* @param height Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`, or pass `initial` to set the height based on the initial content of the `Overlay` (i.e. ignoring content changes). `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`.
88+
* @param maxHeight Sets the maximum height of the `Overlay`, pick from our set list of heights. `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`.
8289
* @param visibility Sets the visibility of the `Overlay`
8390
*/
8491
const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
8592
(
86-
{onClickOutside, role = 'dialog', initialFocusRef, returnFocusRef, ignoreClickRefs, onEscape, visibility, ...rest},
93+
{
94+
onClickOutside,
95+
role = 'dialog',
96+
initialFocusRef,
97+
returnFocusRef,
98+
ignoreClickRefs,
99+
onEscape,
100+
visibility,
101+
height,
102+
...rest
103+
},
87104
forwardedRef
88105
): ReactElement => {
89106
const overlayRef = useRef<HTMLDivElement>(null)
90107
const combinedRef = useCombinedRefs(overlayRef, forwardedRef)
91108

92-
const overlayProps = useOverlay({
109+
useOverlay({
93110
overlayRef,
94111
returnFocusRef,
95112
onEscape,
96113
ignoreClickRefs,
97114
onClickOutside,
98115
initialFocusRef
99116
})
117+
118+
useEffect(() => {
119+
if (height === 'initial' && combinedRef.current?.clientHeight) {
120+
combinedRef.current.style.height = `${combinedRef.current.clientHeight}px`
121+
}
122+
}, [height, combinedRef])
123+
100124
return (
101125
<Portal>
102126
<StyledOverlay
103-
{...overlayProps}
104127
aria-modal="true"
105128
role={role}
129+
height={height}
106130
{...rest}
107131
ref={combinedRef}
108132
visibility={visibility}

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: 22 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,29 @@ 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+
// Ensure the container is focusable:
87+
// - Either the container already has a `tabIndex`
88+
// - Or provide a temporary `tabIndex`
89+
const containerNeedsTemporaryTabIndex = container.getAttribute('tabindex') === null
90+
if (containerNeedsTemporaryTabIndex) {
91+
container.setAttribute('tabindex', '-1')
92+
}
93+
// Focus the container.
94+
container.focus()
95+
// If a temporary `tabIndex` was provided, remove it.
96+
if (containerNeedsTemporaryTabIndex) {
97+
// Once focus has moved from the container to a child within the FocusTrap,
98+
// the container can be made un-refocusable by removing `tabIndex`.
99+
container.addEventListener('blur', () => container.removeAttribute('tabindex'), {once: true})
100+
// NB: If `tabIndex` was removed *before* `blur`, then certain browsers (e.g. Chrome)
101+
// would consider `body` the `activeElement`, and as a result, keyboard navigation
102+
// between children would break, since `body` is outside the `FocusTrap`.
92103
}
104+
return
93105
}
94106
}
95107
}

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

src/stories/SelectPanel.stories.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,66 @@ export function SingleSelectStory(): JSX.Element {
106106
)
107107
}
108108
SingleSelectStory.storyName = 'Single Select'
109+
110+
export function SelectPanelHeightInitialWithOverflowingItemsStory(): JSX.Element {
111+
const [selected, setSelected] = React.useState<ItemInput | undefined>(items[0])
112+
const [filter, setFilter] = React.useState('')
113+
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
114+
const [open, setOpen] = useState(false)
115+
116+
return (
117+
<>
118+
<h1>Single Select Panel</h1>
119+
<div>Please select a label that describe your issue:</div>
120+
<SelectPanel
121+
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => (
122+
<DropdownButton aria-labelledby={` ${ariaLabelledBy}`} {...anchorProps}>
123+
{children ?? 'Select Labels'}
124+
</DropdownButton>
125+
)}
126+
placeholderText="Filter Labels"
127+
open={open}
128+
onOpenChange={setOpen}
129+
items={filteredItems}
130+
selected={selected}
131+
onSelectedChange={setSelected}
132+
onFilterChange={setFilter}
133+
showItemDividers={true}
134+
overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}}
135+
/>
136+
</>
137+
)
138+
}
139+
SelectPanelHeightInitialWithOverflowingItemsStory.storyName = 'SelectPanel, Height: Initial, Overflowing Items'
140+
141+
export function SelectPanelHeightInitialWithUnderflowingItemsStory(): JSX.Element {
142+
const underflowingItems = [items[0], items[1]]
143+
const [selected, setSelected] = React.useState<ItemInput | undefined>(underflowingItems[0])
144+
const [filter, setFilter] = React.useState('')
145+
const filteredItems = underflowingItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
146+
const [open, setOpen] = useState(false)
147+
148+
return (
149+
<>
150+
<h1>Single Select Panel</h1>
151+
<div>Please select a label that describe your issue:</div>
152+
<SelectPanel
153+
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => (
154+
<DropdownButton aria-labelledby={` ${ariaLabelledBy}`} {...anchorProps}>
155+
{children ?? 'Select Labels'}
156+
</DropdownButton>
157+
)}
158+
placeholderText="Filter Labels"
159+
open={open}
160+
onOpenChange={setOpen}
161+
items={filteredItems}
162+
selected={selected}
163+
onSelectedChange={setSelected}
164+
onFilterChange={setFilter}
165+
showItemDividers={true}
166+
overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}}
167+
/>
168+
</>
169+
)
170+
}
171+
SelectPanelHeightInitialWithUnderflowingItemsStory.storyName = 'SelectPanel, Height: Initial, Underflowing Items'

0 commit comments

Comments
 (0)