Skip to content

Commit

Permalink
update util name and add ability to properly focus in section groups
Browse files Browse the repository at this point in the history
  • Loading branch information
mcwinter07 committed Jan 22, 2025
1 parent 928e975 commit 99348c4
Show file tree
Hide file tree
Showing 3 changed files with 48 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { type Meta, type StoryObj } from '@storybook/react'
import { fn } from '@storybook/test'
import { renderTriggerControls } from '~components/Filter/_docs/controls/renderTriggerControls'
import { Well } from '~components/Well'
import { FilterButton } from '../../FilterButton'
import { FilterSelect } from '../FilterSelect'
import { type SelectOption } from '../types'
Expand Down Expand Up @@ -104,44 +105,40 @@ export const AdditionalProperties: Story = {
/**
* Extend the option type to have additional properties to use for rendering.
*/
export const TestPageWithFilterSelect: Story = {
render: (args) => {
export const FilterSelectBelowPageContent: Story = {
render: () => {
const [isOpen, setIsOpen] = useState<boolean>(false)

return (
<div>
<div style={{ color: 'coral', display: 'block', height: '1500px' }}>Content</div>
<FilterSelect<SelectOption & { isFruit: boolean }>
{...args}
label="Custom"
<Well color="gray" style={{ height: '1500px' }}>
Page content above the FilterSelect
</Well>
<FilterSelect
label="Label"
isOpen={isOpen}
setIsOpen={setIsOpen}
items={[
{ label: 'Bubblegum', value: 'bubblegum', isFruit: false },
{ label: 'Strawberry', value: 'strawberry', isFruit: true },
{ label: 'Chocolate', value: 'chocolate', isFruit: false },
{ label: 'Apple', value: 'apple', isFruit: true },
{ label: 'Lemon', value: 'lemon', isFruit: true },
]}
renderTrigger={(triggerProps) => <FilterButton {...triggerProps} />}
items={groupedMockItems}
>
{({ items }): JSX.Element[] =>
items.map((item) =>
item.type === 'item' ? (
<FilterSelect.Option
key={item.key}
item={{
...item,
rendered: item.value?.isFruit ? `${item.rendered} (Fruit)` : item.rendered,
}}
/>
) : (
<FilterSelect.ItemDefaultRender key={item.key} item={item} />
),
)
items.map((item) => {
if (item.type === 'item') {
return (
<FilterSelect.Option
key={item.key}
item={{
...item,
}}
/>
)
}
return <FilterSelect.ItemDefaultRender key={item.key} item={item} />
})
}
</FilterSelect>
</div>
)
},
name: 'Additional option properties',
name: 'FilterSelect below page content',
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
* Due to the floating element's position starting as a negative value on render and then jumping to the correct position, this caused the focus to jump to the top of the page.
* This now polls to check if the element's position is stable by comparing the first and last position.
*/
export const useHasStableYPosition = (ref: React.RefObject<HTMLElement>): boolean => {
export const useHasCalculatedListboxPosition = (ref: React.RefObject<HTMLElement>): boolean => {
const [isStable, setIsStable] = useState(false)
const [lastYPosition, setLastYPosition] = useState<number | null>(null)

Expand All @@ -14,7 +14,7 @@ export const useHasStableYPosition = (ref: React.RefObject<HTMLElement>): boolea
const { y } = ref.current.getBoundingClientRect()
if (lastYPosition === null) {
setLastYPosition(y)
} else if (y === lastYPosition) {
} else if (y === lastYPosition && y >= 0) {
setIsStable(true)
} else {
setLastYPosition(y)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import React, { useEffect, useRef, type HTMLAttributes, type Key, type ReactNode
import { useListBox, type AriaListBoxOptions } from '@react-aria/listbox'
import { type SelectState } from '@react-stately/select'
import classnames from 'classnames'
import { useIsClientReady } from '~components/__utilities__/useIsClientReady'
import { type OverrideClassName } from '~components/types/OverrideClassName'
import { useSelectContext } from '../../context'
import { useHasStableYPosition } from '../../hooks/useHasStableYPosition'
import { type SelectItem, type SelectOption } from '../../types'
import { useHasCalculatedListboxPosition } from '../../hooks/useHasCalculatedListboxPosition'
import { type SelectItem, type SelectItemNode, type SelectOption } from '../../types'
import styles from './ListBox.module.scss'

export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<
Expand All @@ -17,42 +16,38 @@ export type SingleListBoxProps<Option extends SelectOption> = OverrideClassName<
menuProps: AriaListBoxOptions<SelectItem<Option>>
}

// TODO: This accounts for the grouping of elements in a listbox - there's still a cleaner way to do this I'm sure but this is something that can be finessed
const getOptionKey = (
/** determines is the first or last key passed in is a section. If not it will return the key, otherwise will return the first option key of that section */
const getOptionOrSectionKey = (
optionKey: SelectOption['value'] | null,
state: SelectState<SelectItem<any>>,
): Key | null => {
if (!optionKey) {
return null
}
const option = state.collection.getItem(optionKey)
const optionType = option?.type
// const optionChildren = option
if (!optionKey) return null

console.log('optionType', option)
console.log('optionType', optionType)
const option = state.collection.getItem(optionKey) as SelectItemNode | null
const optionType = option?.type

if (optionType === 'section') {
const firstChildOption = option?.value.options[0]
console.log(firstChildOption)
// return the first key of the section options
return firstChildOption.value
const sectionOptions = option?.value?.options

return sectionOptions ? Array.from(sectionOptions)[0]?.value : null
}
return optionKey
}

/** A util to retrieve the key of the correct focusable items based of the focus strategy
/** A util to retrieve the key of the correct focusable option 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 getOptionKey(state.collection.getLastKey(), state)
}
return getOptionKey(state.collection.getFirstKey(), state)

if (state.focusStrategy === 'last') {
return getOptionOrSectionKey(state.collection.getLastKey(), state)
}

return getOptionOrSectionKey(state.collection.getFirstKey(), state)
}

/** This makes the use of query selector less brittle in instances where a failed selector is passed in
Expand All @@ -73,10 +68,9 @@ export const ListBox = <Option extends SelectOption>({
classNameOverride,
...restProps
}: SingleListBoxProps<Option>): JSX.Element => {
const isClientReady = useIsClientReady()
const { state, section } = useSelectContext<Option>()
const { state } = useSelectContext<Option>()
const ref = useRef<HTMLUListElement>(null)
const hasStableYPosition = useHasStableYPosition(ref)
const hasCalculatedListboxPosition = useHasCalculatedListboxPosition(ref)
const { listBoxProps } = useListBox(
{
...menuProps,
Expand All @@ -89,29 +83,22 @@ export const ListBox = <Option extends SelectOption>({
)

/**
* This uses the hasStableYPosition to determine if the position is stable within the window
* When the Listbox is opened the initial position starts above the window, which can cause the out of the box behaviour in react-aria's listbox to jump a user to the top of the page.
*/
useEffect(() => {
if (isClientReady && hasStableYPosition) {
if (hasCalculatedListboxPosition) {
const optionKey = getOptionKeyFromCollection(state)
const focusToElement = safeQuerySelector(`[data-key='${optionKey}']`)
// console.log(
// 'state.collection',
// state.collection.getItem(state.collection.getFirstKey())?.type === 'section',
// )
// console.log(optionKey)
// console.log(focusToElement)

if (focusToElement) {
console.log('focusToElement', focusToElement)
focusToElement.focus()
} else {
ref.current?.focus()
}
}
// Only run this effect for checking the first successful render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isClientReady, hasStableYPosition])
}, [hasCalculatedListboxPosition])

return (
<ul
Expand Down

0 comments on commit 99348c4

Please sign in to comment.