Skip to content

Commit

Permalink
WIP - attempt to solve reducing FocusOn wrappers and manually setting…
Browse files Browse the repository at this point in the history
… the select focus
  • Loading branch information
mcwinter07 committed Feb 10, 2025
1 parent 538d1b9 commit e6ed402
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 62 deletions.
18 changes: 18 additions & 0 deletions packages/components/src/__rc__/Select/Select.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,21 @@
.notFullWidth {
width: 180px;
}

.popover {
box-sizing: border-box;
box-shadow: $shadow-large-box-shadow;
border: $border-focus-ring-border-width $border-focus-ring-border-style transparent;
border-radius: $border-solid-border-radius;
background: $color-white;
overflow: auto;
z-index: 100000;

&:focus {
outline: none;
}

&:focus-visible {
border-color: $color-blue-500;
}
}
82 changes: 79 additions & 3 deletions packages/components/src/__rc__/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import React, { useEffect, useId, useState } from 'react'
import { type UseFloatingReturn } from '@floating-ui/react-dom'
import { createPortal } from 'react-dom'

Check failure on line 2 in packages/components/src/__rc__/Select/Select.tsx

View workflow job for this annotation

GitHub Actions / eslint

'createPortal' is defined but never used. Allowed unused vars must match /(^_|^React$)/u
// import {
// autoUpdate,
// flip,
// offset,
// size,
// useFloating,
// type ReferenceType,
// type UseFloatingOptions,
// type UseFloatingReturn,
// } from '@floating-ui/react-dom'
import { useButton } from '@react-aria/button'
import { HiddenSelect, useSelect } from '@react-aria/select'
import { useSelectState, type SelectProps as AriaSelectProps } from '@react-stately/select'
import { type Key } from '@react-types/shared'
import classnames from 'classnames'
import { FocusScope } from 'react-aria'

Check failure on line 18 in packages/components/src/__rc__/Select/Select.tsx

View workflow job for this annotation

GitHub Actions / eslint

'FocusScope' is defined but never used. Allowed unused vars must match /(^_|^React$)/u
import { FocusOn } from 'react-focus-on'

Check failure on line 19 in packages/components/src/__rc__/Select/Select.tsx

View workflow job for this annotation

GitHub Actions / eslint

'FocusOn' is defined but never used. Allowed unused vars must match /(^_|^React$)/u
import { FieldMessage } from '~components/FieldMessage'
import { Popover, useFloating } from '~components/MultiSelect/subcomponents/Popover'
import { type OverrideClassName } from '~components/types/OverrideClassName'
Expand All @@ -27,6 +39,14 @@ import styles from './Select.module.scss'

type OmittedAriaSelectProps = 'children' | 'items'

const focusItem = (items: HTMLElement[], strategy: 'first' | 'last' | null): void => {

Check failure on line 42 in packages/components/src/__rc__/Select/Select.tsx

View workflow job for this annotation

GitHub Actions / eslint

'focusItem' is assigned a value but never used. Allowed unused vars must match /(^_|^React$)/u
const focusableItems = items.filter((item) => !item.hasAttribute('aria-disabled'))
if (focusableItems.length === 0) return
const itemToFocus =
strategy === 'last' ? focusableItems[focusableItems.length - 1] : focusableItems[0]
itemToFocus.focus()
}

export type SelectProps<Option extends SelectOption = SelectOption> = {
/**
* Item objects in the collection.
Expand Down Expand Up @@ -153,6 +173,29 @@ export const Select = <Option extends SelectOption = SelectOption>({
ref: refs.setReference,
}

// const { floatingStyles } = useFloating({
// elements: {
// reference: refs.reference.current,
// floating: refs.floating.current,
// },
// placement: 'bottom-start',
// middleware: [
// offset(6),
// flip(),
// size({
// apply({ availableWidth, availableHeight, elements }) {
// Object.assign(elements.floating.style, {
// maxWidth: `${Math.min(availableWidth, 400)}px`,
// minWidth: `${Math.min(availableWidth, 196)}px`,
// maxHeight: `${Math.min(availableHeight, 352)}px`,
// })
// },
// }),
// ],
// whileElementsMounted: autoUpdate,
// // ...floatingOptions,
// })

const [portalContainer, setPortalContainer] = useState<HTMLElement>()

useEffect(() => {
Expand All @@ -161,6 +204,18 @@ export const Select = <Option extends SelectOption = SelectOption>({
if (portalElement) setPortalContainer(portalElement)
}
}, [portalContainerId])
// console.log('ksdnfksjdnksdjncfv')

const handleActivation = (container: HTMLElement): void => {
const focusableElements = container.querySelectorAll<HTMLElement>(
'[role="option"]:not([aria-disabled="true"])',
)

if (focusableElements.length > 0) {
const focusToIndex = state.focusStrategy === 'last' ? focusableElements.length - 1 : 0
focusableElements[focusToIndex].focus()
}
}

return (
<div className={classnames(!isFullWidth && styles.notFullWidth, classNameOverride)}>
Expand All @@ -172,10 +227,31 @@ export const Select = <Option extends SelectOption = SelectOption>({
) : (
trigger(selectToggleProps, selectToggleProps.ref)
)}

{state.isOpen && (
<Popover id={popoverId} portalContainer={portalContainer} refs={refs}>
<Popover
id={popoverId}
portalContainer={portalContainer}
refs={refs}
focusOnProps={{
onClickOutside: state.close,
autoFocus: false,
enabled: true,
onDeactivation: () => {
triggerRef.current?.focus()
state.close()
},
onEscapeKey: () => {
triggerRef.current?.focus()
state.close()
},
onActivation: handleActivation,
}}
>
<SelectProvider<Option> state={state}>
<SelectPopoverContents menuProps={menuProps}>{children}</SelectPopoverContents>
<SelectPopoverContents menuProps={menuProps} onActivation={handleActivation}>
{children}
</SelectPopoverContents>
</SelectProvider>
</Popover>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { useEffect, useRef, type HTMLAttributes, type Key, type ReactNode } from 'react'

Check failure on line 1 in packages/components/src/__rc__/Select/subcomponents/ListBox/ListBox.tsx

View workflow job for this annotation

GitHub Actions / eslint

'useEffect' is defined but never used. Allowed unused vars must match /(^_|^React$)/u

Check failure on line 1 in packages/components/src/__rc__/Select/subcomponents/ListBox/ListBox.tsx

View workflow job for this annotation

GitHub Actions / eslint

'Key' is defined but never used. Allowed unused vars must match /(^_|^React$)/u
import { useListBox, type AriaListBoxOptions } from '@react-aria/listbox'
import { type SelectState } from '@react-stately/select'
// import { type SelectState } from '@react-stately/select'
import classnames from 'classnames'
import { useIsClientReady } from '~components/__utilities__/useIsClientReady'

Check failure on line 5 in packages/components/src/__rc__/Select/subcomponents/ListBox/ListBox.tsx

View workflow job for this annotation

GitHub Actions / eslint

'useIsClientReady' is defined but never used. Allowed unused vars must match /(^_|^React$)/u
import { type OverrideClassName } from '~components/types/OverrideClassName'
// import { type OverrideClassName } from '~components/types/OverrideClassName'
import { useSelectContext } from '../../context'
import { type SelectItem, type SelectOption } from '../../types'
import styles from './ListBox.module.scss'
Expand All @@ -16,29 +16,12 @@ export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<
menuProps: AriaListBoxOptions<SelectItem<Option>>
}

/** A util to retrieve the key of the correct focusable items based of the focus strategy
* This is used to determine which element from the collection to focus to on open base on the keyboard event
* ie: UpArrow will set the focusStrategy to "last"
*/
const getOptionKeyFromCollection = (state: SelectState<SelectItem<any>>): Key | null => {
if (state.selectedItem) {
return state.selectedItem.key
} else if (state.focusStrategy === 'last') {
return state.collection.getLastKey()
}
return state.collection.getFirstKey()
}

/** This makes the use of query selector less brittle in instances where a failed selector is passed in
*/
const safeQuerySelector = (selector: string): HTMLElement | null => {
try {
return document.querySelector(selector)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Kaizen querySelector failed:', error)
return null
}
const focusItem = (items: HTMLElement[], strategy: 'first' | 'last' | null): void => {

Check failure on line 19 in packages/components/src/__rc__/Select/subcomponents/ListBox/ListBox.tsx

View workflow job for this annotation

GitHub Actions / eslint

'focusItem' is assigned a value but never used. Allowed unused vars must match /(^_|^React$)/u
const focusableItems = items.filter((item) => !item.hasAttribute('aria-disabled'))
if (focusableItems.length === 0) return
const itemToFocus =
strategy === 'last' ? focusableItems[focusableItems.length - 1] : focusableItems[0]
itemToFocus.focus()
}

export const ListBox = <Option extends SelectOption>({
Expand All @@ -47,7 +30,7 @@ export const ListBox = <Option extends SelectOption>({
classNameOverride,
...restProps
}: SingleListBoxProps<Option>): JSX.Element => {
const isClientReady = useIsClientReady()
// const isClientReady = useIsClientReady()
const { state } = useSelectContext<Option>()
const ref = useRef<HTMLUListElement>(null)
const { listBoxProps } = useListBox(
Expand All @@ -61,25 +44,6 @@ export const ListBox = <Option extends SelectOption>({
ref,
)

/**
* This uses the new useIsClientReady to ensure document exists before trying to querySelector and give the time to focus to the correct element
*/
useEffect(() => {
if (isClientReady) {
const optionKey = getOptionKeyFromCollection(state)
const focusToElement = safeQuerySelector(`[data-key='${optionKey}']`)

if (focusToElement) {
focusToElement.focus()
} else {
// If an element is not found, focus on the listbox. This ensures the list can still be navigated to via keyboard if the keys do not align to the data attributes of the list items.
ref.current?.focus()
}
}
// Only run this effect for checking the first successful render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isClientReady])

return (
<ul
ref={ref}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,11 @@ export const Overlay = <Option extends SelectOption>({
// to allow screen reader users to dismiss the popup easily.
return (
<div ref={overlayRef} className={classNameOverride} {...overlayProps} {...restProps}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus={false} restoreFocus>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</FocusScope>
{/* <FocusScope autoFocus={true} restoreFocus> */}
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
{/* </FocusScope> */}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import React from 'react'
import React, { useEffect, useRef } from 'react'
import { type AriaListBoxOptions } from '@react-aria/listbox'
import { useSelectContext } from '../../context'
import { type SelectItem, type SelectItemNode, type SelectOption } from '../../types'
import { ListBox } from '../ListBox'
import { ListItems } from '../ListItems'
import { Overlay } from '../Overlay'
import styles from './SelectPopoverContents.module.scss'

export type SelectPopoverContentsProps<Option extends SelectOption> = {
children?: (args: { items: SelectItemNode<Option>[] }) => React.ReactNode
menuProps: AriaListBoxOptions<SelectItem<Option>>
/* A callback that is triggered when opened. Used to handle focus management of popover contents */
onActivation?: (container: HTMLDivElement) => void
}

export const SelectPopoverContents = <Option extends SelectOption>({
children,
menuProps,
onActivation,
}: SelectPopoverContentsProps<Option>): JSX.Element => {
const { state } = useSelectContext<Option>()
const containerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (onActivation && containerRef.current) {
onActivation(containerRef.current)
}
}, [onActivation])

// The collection structure is set by useSelectState's `children`
// which we have used a util to ensure the following structure
Expand All @@ -25,12 +34,10 @@ export const SelectPopoverContents = <Option extends SelectOption>({
const itemNodes = Array.from(state.collection) as SelectItemNode<Option>[]

return (
<div className={styles.selectPopoverContents}>
<Overlay<Option>>
<ListBox<Option> menuProps={menuProps}>
{children ? children({ items: itemNodes }) : <ListItems<Option> items={itemNodes} />}
</ListBox>
</Overlay>
<div ref={containerRef} className={styles.selectPopoverContents}>
<ListBox<Option> menuProps={menuProps}>
{children ? children({ items: itemNodes }) : <ListItems<Option> items={itemNodes} />}
</ListBox>
</div>
)
}
Expand Down

0 comments on commit e6ed402

Please sign in to comment.