Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove activedescendant focus pattern from SelectPanel #5688

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/yellow-pears-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
Copy link
Member

Choose a reason for hiding this comment

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

Nit: I'm wondering if this should be minor instead of patch. Entirely up to you though! 😁

---

Remove activedescendant focus pattern from SelectPanel in favor of regular focus behavior with a roving tab index.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from 'react'
import type {AriaRole} from '../utils/types'
import type {FocusZoneSettings} from '@primer/behaviors'

type ContextProps = {
container?: string
Expand All @@ -14,6 +15,7 @@ type ContextProps = {
// eslint-disable-next-line @typescript-eslint/ban-types
afterSelect?: Function
enableFocusZone?: boolean
focusZoneFocusOutBehavior?: FocusZoneSettings['focusOutBehavior']
defaultTrailingVisual?: React.ReactElement
}

Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
listLabelledBy,
selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation
enableFocusZone: enableFocusZoneFromContainer,
focusZoneFocusOutBehavior,
} = React.useContext(ActionListContainerContext)

const ariaLabelledBy = slots.heading ? (slots.heading.props.id ?? headingId) : listLabelledBy
Expand All @@ -55,7 +56,7 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
disabled: !enableFocusZone,
containerRef: listRef,
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
focusOutBehavior: listRole === 'menu' ? 'wrap' : undefined,
focusOutBehavior: focusZoneFocusOutBehavior ?? (listRole === 'menu' ? 'wrap' : undefined),
})

const enabled = useFeatureFlag(actionListCssModulesFlag)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {ScrollIntoViewOptions} from '@primer/behaviors'
import {scrollIntoView} from '@primer/behaviors'
import {FocusKeys} from '@primer/behaviors'
import type {KeyboardEventHandler, RefObject} from 'react'
import React, {useCallback, useEffect, useRef, useState} from 'react'
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'
import styled from 'styled-components'
import Box from '../Box'
import type {TextInputProps} from '../TextInput'
Expand All @@ -21,8 +20,7 @@ import {
FilteredActionListBodyLoader,
FilteredActionListLoadingTypes,
} from './FilteredActionListLoaders'

const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'

export interface FilteredActionListProps
extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>,
Expand Down Expand Up @@ -62,6 +60,7 @@ export function FilteredActionList({
announcementsEnabled: _announcementsEnabled = true,
...listProps
}: FilteredActionListProps): JSX.Element {
const {container} = useContext(ActionListContainerContext)
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -75,21 +74,20 @@ export function FilteredActionList({
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [listContainerElement, setListContainerElement] = useState<HTMLDivElement | null>(null)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
const activeDescendantRef = useRef<HTMLElement>()
const listId = useId()
const inputDescriptionTextId = useId()
const onInputKeyPress: KeyboardEventHandler = useCallback(
event => {
if (event.key === 'Enter' && activeDescendantRef.current) {
event.preventDefault()
event.nativeEvent.stopImmediatePropagation()
if (event.key === 'Enter' && listContainerElement) {
const firstItem = listContainerElement.querySelector('[data-select-panel-item=true]')

// Forward Enter key press to active descendant so that item gets activated
const activeDescendantEvent = new KeyboardEvent(event.type, event.nativeEvent)
activeDescendantRef.current.dispatchEvent(activeDescendantEvent)
if (firstItem) {
const firstItemEvent = new KeyboardEvent(event.type, event.nativeEvent)
firstItem.dispatchEvent(firstItemEvent)
}
}
},
[activeDescendantRef],
[listContainerElement],
)

const listContainerRefCallback = useCallback(
Expand All @@ -107,35 +105,12 @@ export function FilteredActionList({
useFocusZone(
{
containerRef: {current: listContainerElement},
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
focusOutBehavior: 'wrap',
focusableElementFilter: element => {
return !(element instanceof HTMLInputElement)
},
activeDescendantFocus: inputRef,
onActiveDescendantChanged: (current, previous, directlyActivated) => {
activeDescendantRef.current = current

if (current && scrollContainerRef.current && directlyActivated) {
scrollIntoView(current, scrollContainerRef.current, menuScrollMargins)
}
},
},
[
// List container isn't in the DOM while loading. Need to re-bind focus zone when it changes.
listContainerElement,
],
[listContainerElement],
)

useEffect(() => {
// if items changed, we want to instantly move active descendant into view
if (activeDescendantRef.current && scrollContainerRef.current) {
scrollIntoView(activeDescendantRef.current, scrollContainerRef.current, {
...menuScrollMargins,
behavior: 'auto',
})
}
}, [items])

useScrollFlash(scrollContainerRef)

return (
Expand Down Expand Up @@ -171,7 +146,19 @@ export function FilteredActionList({
{loading && scrollContainerRef.current && loadingType.appearsInBody ? (
<FilteredActionListBodyLoader loadingType={loadingType} height={scrollContainerRef.current.clientHeight} />
) : (
<ActionList ref={listContainerRefCallback} items={items} {...listProps} role="listbox" id={listId} />
<ActionList
ref={listContainerRefCallback}
items={items.map(item => {
return {
...item,
'data-select-panel-item': container === 'SelectPanel',
role: 'option',
}
})}
{...listProps}
Comment on lines +151 to +157
Copy link
Member

Choose a reason for hiding this comment

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

Should we utilize useMemo here? I noticed that this was being called a few times on the initial render. I think if we utilize useMemo we could reduce it to one since we'll memorize the array.

const filteredItems: ItemInput[] = React.useMemo(() => {
  return items.map(item => {
    return {
      ...item,
      'data-select-panel-item': container === 'SelectPanel',
      role: 'option',
    }
  })
}, [items, container])

Not a big concern though, so up to you! I believe this will still be called upon filtering existing options, so this only helps us when it comes to the initial render.

role="listbox"
id={listId}
/>
)}
</Box>
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import type {ScrollIntoViewOptions} from '@primer/behaviors'
import {scrollIntoView, FocusKeys} from '@primer/behaviors'
import type {KeyboardEventHandler} from 'react'
import React, {useCallback, useEffect, useRef, useState} from 'react'
import React, {useCallback, useContext, useEffect, useRef} from 'react'
import styled from 'styled-components'
import Box from '../Box'
import type {TextInputProps} from '../TextInput'
import TextInput from '../TextInput'
import {get} from '../constants'
import {ActionList} from '../ActionList'
import type {GroupedListProps, ListPropsBase, ItemInput} from '../SelectPanel/types'
import {useFocusZone} from '../hooks/useFocusZone'
import {useId} from '../hooks/useId'
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
Expand All @@ -21,9 +18,7 @@ import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './Fi

import {isValidElementType} from 'react-is'
import type {RenderItemFn} from '../deprecated/ActionList/List'
import {useAnnouncements} from './useAnnouncements'

const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'

export interface FilteredActionListProps
extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>,
Expand All @@ -39,7 +34,6 @@ export interface FilteredActionListProps
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
inputRef?: React.RefObject<HTMLInputElement>
className?: string
announcementsEnabled?: boolean
}

const StyledHeader = styled.div`
Expand All @@ -53,7 +47,6 @@ export function FilteredActionList({
filterValue: externalFilterValue,
loadingType = FilteredActionListLoadingTypes.bodySpinner,
onFilterChange,
onListContainerRefChanged,
onInputRefChanged,
items,
textInputProps,
Expand All @@ -62,7 +55,7 @@ export function FilteredActionList({
groupMetadata,
showItemDividers,
className,
announcementsEnabled = true,
selectionVariant,
...listProps
}: FilteredActionListProps): JSX.Element {
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
Expand All @@ -77,70 +70,26 @@ export function FilteredActionList({

const scrollContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
const [listContainerElement, setListContainerElement] = useState<HTMLUListElement | null>(null)
const activeDescendantRef = useRef<HTMLElement>()
const listId = useId()
const inputDescriptionTextId = useId()
const onInputKeyPress: KeyboardEventHandler = useCallback(
event => {
if (event.key === 'Enter' && activeDescendantRef.current) {
event.preventDefault()
event.nativeEvent.stopImmediatePropagation()
if (event.key === 'Enter' && scrollContainerRef.current) {
const firstItem = scrollContainerRef.current.querySelector('[data-select-panel-item=true]')

// Forward Enter key press to active descendant so that item gets activated
const activeDescendantEvent = new KeyboardEvent(event.type, event.nativeEvent)
activeDescendantRef.current.dispatchEvent(activeDescendantEvent)
if (firstItem) {
const firstItemEvent = new KeyboardEvent(event.type, event.nativeEvent)
firstItem.dispatchEvent(firstItemEvent)
}
}
},
[activeDescendantRef],
)

const listContainerRefCallback = useCallback(
(node: HTMLUListElement | null) => {
setListContainerElement(node)
onListContainerRefChanged?.(node)
},
[onListContainerRefChanged],
[scrollContainerRef],
)

useEffect(() => {
onInputRefChanged?.(inputRef)
}, [inputRef, onInputRefChanged])

useFocusZone(
{
containerRef: {current: listContainerElement},
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
focusOutBehavior: 'wrap',
focusableElementFilter: element => {
return !(element instanceof HTMLInputElement)
},
activeDescendantFocus: inputRef,
onActiveDescendantChanged: (current, previous, directlyActivated) => {
activeDescendantRef.current = current

if (current && scrollContainerRef.current && directlyActivated) {
scrollIntoView(current, scrollContainerRef.current, menuScrollMargins)
}
},
},
[
// List container isn't in the DOM while loading. Need to re-bind focus zone when it changes.
listContainerElement,
],
)

useEffect(() => {
// if items changed, we want to instantly move active descendant into view
if (activeDescendantRef.current && scrollContainerRef.current) {
scrollIntoView(activeDescendantRef.current, scrollContainerRef.current, {
...menuScrollMargins,
behavior: 'auto',
})
}
}, [items])

useAnnouncements(items, {current: listContainerElement}, inputRef, announcementsEnabled)
useScrollFlash(scrollContainerRef)

function getItemListForEachGroup(groupId: string) {
Expand Down Expand Up @@ -190,12 +139,12 @@ export function FilteredActionList({
<FilteredActionListBodyLoader loadingType={loadingType} height={scrollContainerRef.current.clientHeight} />
) : (
<ActionList
ref={listContainerRefCallback}
showDividers={showItemDividers}
{...listProps}
role="listbox"
id={listId}
sx={{flexGrow: 1}}
selectionVariant={selectionVariant}
>
{groupMetadata?.length
? groupMetadata.map((group, index) => {
Expand Down Expand Up @@ -223,9 +172,19 @@ export function FilteredActionList({
}

function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) {
const {container} = useContext(ActionListContainerContext)

// keep backward compatibility for renderItem
// escape hatch for custom Item rendering
if (typeof item.renderItem === 'function') return item.renderItem(item)
if (typeof item.renderItem === 'function') {
if (container === 'SelectPanel') {
return React.cloneElement(item.renderItem(item), {
'data-select-panel-item': true,
})
}

return item.renderItem(item)
}

Comment on lines 174 to +187
Copy link
Member

Choose a reason for hiding this comment

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

Do we have an example that utilizes this? Not a blocker, but just curious 🤔

const {
id,
Expand All @@ -243,13 +202,13 @@ function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) {

return (
<ActionList.Item
role="option"
// @ts-ignore - for now
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The role is automatically inferred by ActionList.

onSelect={(e: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => {
if (typeof onAction === 'function')
onAction(item, e as React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>)
}}
data-id={id}
data-select-panel-item={container === 'SelectPanel'}
{...rest}
>
{LeadingVisual ? (
Expand Down
Loading
Loading