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

feat(SelectPanel): active descendant focus #1243

Merged
merged 3 commits into from
May 21, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/odd-melons-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/components": patch
---

Use `active-descendant` to control focus in `SelectPanel`
29 changes: 26 additions & 3 deletions src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import styled from 'styled-components'
import {StyledHeader} from './Header'
import {StyledDivider} from './Divider'
import {useColorSchemeVar, useTheme} from '../ThemeProvider'
import {uniqueId} from '../utils/uniqueId'

/**
* These colors are not yet in our default theme. Need to remove this once they are added.
Expand Down Expand Up @@ -120,6 +121,8 @@ export interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, '
id?: number | string
}

export const itemActiveDescendantClass = `${uniqueId()}active-descendant`

const getItemVariant = (variant = 'default', disabled?: boolean) => {
if (disabled) {
return {
Expand Down Expand Up @@ -191,9 +194,18 @@ const StyledItem = styled.div<
border: 0 solid ${get('colors.selectMenu.borderSecondary')};
border-top-width: ${({showDivider}) => (showDivider ? `1px` : '0')};
}

// Override if current or previous item is active descendant
&.${itemActiveDescendantClass}, .${itemActiveDescendantClass} + & {
${StyledItemContent}::before {
border-color: transparent;
}
}
}

&:focus {
// Focused OR Active Descendant
&:focus,
&.${itemActiveDescendantClass} {
background: ${({focusBackground}) => focusBackground};
outline: none;
}
Expand All @@ -215,7 +227,6 @@ const BaseVisualContainer = styled.div<{variant?: ItemProps['variant']; disabled
flex-shrink: 0;
display: flex;
flex-direction: column;
flex-shrink: 0;
justify-content: center;
margin-right: ${get('space.2')};
`
Expand Down Expand Up @@ -246,6 +257,10 @@ const DescriptionContainer = styled.span<{descriptionVariant: ItemProps['descrip
margin-left: ${({descriptionVariant}) => (descriptionVariant === 'inline' ? get('space.2') : 0)};
`

const MultiSelectInput = styled.input`
pointer-events: none;
`

/**
* An actionable or selectable `Item` with an optional icon and description.
*/
Expand Down Expand Up @@ -329,7 +344,15 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
* readOnly is required because we are doing a one-way bind to `checked`.
* aria-readonly="false" tells screen that they can still interact with the checkbox
*/}
<input type="checkbox" checked={selected} aria-label={text} readOnly aria-readonly="false" />
<MultiSelectInput
disabled={disabled}
tabIndex={-1}
type="checkbox"
checked={selected}
aria-label={text}
readOnly
aria-readonly="false"
/>
</>
) : (
selected && <CheckIcon fill={theme?.colors.text.primary} />
Expand Down
5 changes: 5 additions & 0 deletions src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface ListPropsBase {
*/
role?: AriaRole

/**
* id to attach to the base DOM node of the list
*/
id?: string

/**
* A `List`-level custom `Item` renderer. Every `Item` within this `List`
* without a `Group`-level or `Item`-level custom `Item` renderer will be
Expand Down
5 changes: 2 additions & 3 deletions src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React, {useCallback, useMemo, useRef} from 'react'
import Overlay, {OverlayProps} from '../Overlay'
import {useFocusTrap} from '../hooks/useFocusTrap'
import {useFocusZone} from '../hooks/useFocusZone'
import {FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
import {useAnchoredPosition, useRenderForcingRef} from '../hooks'
import {uniqueId} from '../utils/uniqueId'
import {FocusZoneSettings} from '../behaviors/focusZone'

export interface AnchoredOverlayProps extends Pick<OverlayProps, 'height' | 'width'> {
/**
Expand Down Expand Up @@ -36,7 +35,7 @@ export interface AnchoredOverlayProps extends Pick<OverlayProps, 'height' | 'wid
/**
* Settings to apply to the Focus Zone on the internal `Overlay` component.
*/
focusZoneSettings?: Partial<FocusZoneSettings>
focusZoneSettings?: Partial<FocusZoneHookSettings>
}

/**
Expand Down
56 changes: 52 additions & 4 deletions src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React, {useCallback} from 'react'
import React, {KeyboardEventHandler, useCallback, useMemo, useRef} from 'react'
import {GroupedListProps, ListPropsBase} from '../ActionList/List'
import TextInput, {TextInputProps} from '../TextInput'
import Box from '../Box'
import {ActionList} from '../ActionList'
import Spinner from '../Spinner'
import {useFocusZone} from '../hooks/useFocusZone'
import {uniqueId} from '../utils/uniqueId'
import {itemActiveDescendantClass} from '../ActionList/Item'

export interface FilteredActionListProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
loading?: boolean
Expand All @@ -28,15 +31,60 @@ export function FilteredActionList({
[onFilterChange]
)

const containerRef = useRef<HTMLInputElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const activeDescendantRef = useRef<HTMLElement>()
const listId = useMemo(uniqueId, [])
const onInputKeyPress: KeyboardEventHandler = useCallback(
event => {
if (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)
}
},
[activeDescendantRef]
)

useFocusZone({
containerRef,
focusOutBehavior: 'wrap',
focusableElementFilter: element => {
if (element instanceof HTMLInputElement) {
// No active-descendant focus on checkboxes in list items or filter input
return false
}
return true
},
activeDescendantFocus: inputRef,
onActiveDescendantChanged: (current, previous) => {
activeDescendantRef.current = current

if (previous) {
previous.classList.remove(itemActiveDescendantClass)
}

if (current) {
current.classList.add(itemActiveDescendantClass)
}
}
})

return (
<>
<Box ref={containerRef} flexGrow={1} flexDirection="column">
<TextInput
ref={inputRef}
block
width="auto"
color="text.primary"
onChange={onInputChange}
onKeyPress={onInputKeyPress}
placeholder={placeholderText}
aria-label={placeholderText}
aria-controls={listId}
{...textInputProps}
/>
<Box flexGrow={1} overflow="auto">
Expand All @@ -45,10 +93,10 @@ export function FilteredActionList({
<Spinner />
</Box>
) : (
<ActionList items={items} {...listProps} role="listbox" />
<ActionList items={items} {...listProps} role="listbox" id={listId} />
)}
</Box>
</>
</Box>
)
}

Expand Down
11 changes: 4 additions & 7 deletions src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {useCallback, useMemo} from 'react'
import {FilteredActionList, FilteredActionListProps} from '../FilteredActionList'
import {OverlayProps} from '../Overlay'
import {ItemInput} from '../ActionList/List'
import {FocusZoneSettings} from '../behaviors/focusZone'
import {FocusZoneHookSettings} from '../hooks/useFocusZone'
import {DropdownButton} from '../DropdownMenu'
import {ItemProps} from '../ActionList'
import {AnchoredOverlay, AnchoredOverlayProps} from '../AnchoredOverlay'
Expand Down Expand Up @@ -41,11 +41,9 @@ function isMultiSelectVariant(
return Array.isArray(selected)
}

const focusZoneSettings: Partial<FocusZoneSettings> = {
focusOutBehavior: 'wrap',
focusableElementFilter: element => {
return !(element instanceof HTMLInputElement) || element.type !== 'checkbox'
}
const focusZoneSettings: Partial<FocusZoneHookSettings> = {
// Let FilteredActionList handle focus zone
disabled: true
}

const textInputProps: Partial<TextInputProps> = {
Expand Down Expand Up @@ -104,7 +102,6 @@ export function SelectPanel({
}

if (isMultiSelectVariant(selected)) {
// multi select
const otherSelectedItems = selected.filter(selectedItem => selectedItem !== item)
const newSelectedItems = selected.includes(item) ? otherSelectedItems : [...otherSelectedItems, item]

Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/__snapshots__/ActionMenu.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ exports[`ActionMenu renders consistently 1`] = `
<button
aria-haspopup="listbox"
aria-label="menu"
aria-labelledby="__primer_id_10000"
aria-labelledby="__primer_id_10001"
className="c0"
id="__primer_id_10000"
id="__primer_id_10001"
onClick={[Function]}
onKeyDown={[Function]}
tabIndex={0}
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/__snapshots__/AnchoredOverlay.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ exports[`AnchoredOverlay renders consistently 1`] = `

<button
aria-haspopup="listbox"
aria-labelledby="__primer_id_10000"
aria-labelledby="__primer_id_10001"
className="c0"
id="__primer_id_10000"
id="__primer_id_10001"
onClick={[Function]}
onKeyDown={[Function]}
tabIndex={0}
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/__snapshots__/DropdownMenu.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ exports[`DropdownMenu renders consistently 1`] = `

<button
aria-haspopup="listbox"
aria-labelledby="__primer_id_10000"
aria-labelledby="__primer_id_10001"
className="c0"
id="__primer_id_10000"
id="__primer_id_10001"
onClick={[Function]}
onKeyDown={[Function]}
tabIndex={0}
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/__snapshots__/SelectPanel.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ exports[`SelectPanel renders consistently 1`] = `
>
<button
aria-haspopup="listbox"
aria-labelledby="__primer_id_10000"
aria-labelledby="__primer_id_10001"
className="c1"
id="__primer_id_10000"
id="__primer_id_10001"
onClick={[Function]}
onKeyDown={[Function]}
tabIndex={0}
Expand Down
29 changes: 22 additions & 7 deletions src/behaviors/focusZone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
let activeDescendantSuspended = Boolean(activeDescendantControl)
let currentFocusedElement: HTMLElement | undefined

function getFirstFocusableElement() {
return focusableElements[0] as HTMLElement | undefined
}

function updateFocusedElement(to?: HTMLElement) {
const from = currentFocusedElement
currentFocusedElement = to
Expand All @@ -370,10 +374,10 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
activeDescendantCallback?.(to, from === to ? undefined : from)
}

function suspendActiveDescendant() {
function suspendActiveDescendant(previouslyActiveElement = currentFocusedElement) {
activeDescendantControl?.removeAttribute('aria-activedescendant')
activeDescendantSuspended = true
activeDescendantCallback?.(undefined, currentFocusedElement)
activeDescendantCallback?.(undefined, previouslyActiveElement)
if (focusInStrategy === 'first') {
currentFocusedElement = undefined
}
Expand All @@ -399,7 +403,7 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
}

if (!currentFocusedElement) {
updateFocusedElement(focusableElements[0])
updateFocusedElement(getFirstFocusableElement())
}
}

Expand All @@ -411,7 +415,16 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):

// If removing the last-focused element, set tabindex=0 to the first element in the list.
if (element === currentFocusedElement) {
updateFocusedElement(focusableElements[0])
const nextElementToFocus = getFirstFocusableElement()
updateFocusedElement(nextElementToFocus)

if (activeDescendantControl && !activeDescendantSuspended) {
if (nextElementToFocus) {
setActiveDescendant(element, nextElementToFocus)
} else {
suspendActiveDescendant(element)
}
}
}
}
const savedIndex = savedTabIndex.get(element)
Expand Down Expand Up @@ -443,7 +456,7 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
})

// Open the first tabbable element for tabbing
updateFocusedElement(focusableElements[0])
updateFocusedElement(getFirstFocusableElement())

// If the DOM structure of the container changes, make sure we keep our state up-to-date
// with respect to the focusable elements cache and its order
Expand Down Expand Up @@ -596,8 +609,10 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
let nextElementToFocus: HTMLElement | undefined = undefined

if (activeDescendantSuspended) {
activeDescendantSuspended = false
nextElementToFocus = currentFocusedElement || focusableElements[0]
nextElementToFocus = currentFocusedElement || getFirstFocusableElement()
if (nextElementToFocus) {
activeDescendantSuspended = false
}
} else {
// If there is a custom function that retrieves the next focusable element, try calling that first.
if (settings?.getNextFocusable) {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useFocusTrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react'
import {focusTrap} from '../behaviors/focusTrap'
import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'

interface FocusTrapHookSettings {
export interface FocusTrapHookSettings {
/**
* Ref that will be used for the trapping container. If not provided, one will
* be created by this hook and returned.
Expand Down
2 changes: 2 additions & 0 deletions src/stories/SelectPanel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function MultiSelectStory(): JSX.Element {
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
showItemDividers={true}
/>
</>
)
Expand Down Expand Up @@ -94,6 +95,7 @@ export function SingleSelectStory(): JSX.Element {
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
showItemDividers={true}
/>
</>
)
Expand Down