Skip to content

[KZN-3415] uses native popover api in SingleSelect #5885

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

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a985a19
[KZN-3415] uses native popover api in SingleSelect
Kitty-Al Jul 2, 2025
c862422
[KZN-3415] fixes lint, ts, failing tests
Kitty-Al Jul 2, 2025
7b4beed
[KZN-3415] adds styling to see a11y behaviour
Kitty-Al Jul 2, 2025
566129a
[KZN-3415] adds tests for the popover
Kitty-Al Jul 3, 2025
6f62ad1
[KZN-3415] fix lint issues
Kitty-Al Jul 3, 2025
401c338
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 3, 2025
5632923
[KZN-3415] adds a note on popover in the docs
Kitty-Al Jul 3, 2025
6f84289
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 3, 2025
6b2cb5b
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 4, 2025
82f8f8e
[KZN-3415] removes classNameOverride
Kitty-Al Jul 4, 2025
2744914
[KZN-3415] change trigger to type
Kitty-Al Jul 8, 2025
11f606b
[KZN-3415] updates positioning logic
Kitty-Al Jul 9, 2025
c80d7d1
[KZN-3415] moves interaction tests to storybook
Kitty-Al Jul 9, 2025
d32fd52
[KZN-3415] guards against SSR
Kitty-Al Jul 9, 2025
c3c8afb
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 9, 2025
428052a
[KZN-3415] improves positioning function performance and handles pare…
Kitty-Al Jul 10, 2025
40cc648
[KZN-3415] removes popover toggle
Kitty-Al Jul 15, 2025
0808407
[KZN-3415] updates popover to handle flicker
Kitty-Al Jul 15, 2025
738f5f1
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 15, 2025
7788311
[KZN-3415] WIP: using css anchor position
Kitty-Al Jul 16, 2025
6f973d3
[KZN-3415] gives the popover a unique id
Kitty-Al Jul 17, 2025
d1b90fb
[KZN-3415] refactor the popover positioning
Kitty-Al Jul 17, 2025
06dd126
[KZN-3415] css anchoring position
Kitty-Al Jul 17, 2025
dc10e64
[KZN-3415] fixes lint issues
Kitty-Al Jul 18, 2025
bf6918a
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 18, 2025
f0ab4e7
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 18, 2025
239f351
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 21, 2025
e8a2bc6
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 22, 2025
a4e61a7
[KZN-3415] refactoring
Kitty-Al Jul 22, 2025
9841ea8
[KZN-3415] refactors moving out positioning styles
Kitty-Al Jul 22, 2025
24ac24b
[KZN-3415] fixes lint error
Kitty-Al Jul 22, 2025
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
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react'
import { render } from '@testing-library/react'
import { cleanup, render, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SingleSelect } from './SingleSelect'
import { singleMockItems } from './_docs/mockData'

const SingleSelectWrapper = (): JSX.Element => (
<SingleSelect>
<SingleSelect items={singleMockItems}>
<SingleSelect.List>
{singleMockItems.map((item) => (
<SingleSelect.ListItem key={item.value} value={{ value: item.value }}>
<SingleSelect.ListItem key={item.value} id={item.value}>
{item.label}
</SingleSelect.ListItem>
))}
Expand All @@ -16,11 +17,90 @@ const SingleSelectWrapper = (): JSX.Element => (
)

describe('<SingleSelect />', () => {
afterEach(cleanup)
describe('renders', () => {
it('a basic select component', () => {
const { getByRole } = render(<SingleSelectWrapper />)
const select = getByRole('button')
expect(select).toBeInTheDocument()
})
})

describe('popover interactions', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I keen to get thoughts about potentially moving the interaction tests over to Storybook's play tests 🤔

I don't think we have a hard and fast rule about when to use one over the other so might also be worth discussing as a team when we're all back :)

it('opens the popover when the trigger is clicked', async () => {
const user = userEvent.setup()
const { getByRole, findAllByRole } = render(<SingleSelectWrapper />)
const trigger = getByRole('button')
await user.click(trigger)

await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
const options = await findAllByRole('option')
expect(options[0]).toBeVisible()
expect(options[0]).toHaveTextContent(singleMockItems[0].label)
})

it('closes the popover when an item is selected', async () => {
const user = userEvent.setup()
const { getByRole, findAllByRole } = render(<SingleSelectWrapper />)
const trigger = getByRole('button')
await user.click(trigger)
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
const options = await findAllByRole('option')
const item = options[0]
await user.click(item)
expect(options[0]).not.toBeVisible()
})

describe('via keyboard', () => {
it('opens the popover with keyboard (Enter) and navigates items with ArrowDown/ArrowUp', async () => {
const user = userEvent.setup()
const { getByRole, findAllByRole } = render(<SingleSelectWrapper />)
const trigger = getByRole('button')
trigger.focus()
await user.keyboard('{Enter}')
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
const options = await findAllByRole('option')
expect(options[0]).toHaveTextContent(singleMockItems[0].label)
await user.keyboard('{ArrowDown}')
const secondItem = options[1]
expect(secondItem).toHaveAttribute('data-focused', 'true')
await user.keyboard('{ArrowUp}')
expect(options[0]).toHaveAttribute('data-focused', 'true')
})

it('selects an item with keyboard (Enter)', async () => {
const user = userEvent.setup()
const { getByRole, queryAllByRole } = render(<SingleSelectWrapper />)
const trigger = getByRole('button')
trigger.focus()
await user.keyboard('{Enter}')
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
await user.keyboard('{ArrowDown}')
await user.keyboard('{Enter}')
const options = await queryAllByRole('option')
expect(options.length).toBe(0)
})
it('closes the popover when Escape key is pressed', async () => {
const user = userEvent.setup()
const { getByRole } = render(<SingleSelectWrapper />)
const trigger = getByRole('button')
await user.click(trigger)
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
await user.keyboard('{Escape}')
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
})
})
})
})
84 changes: 73 additions & 11 deletions packages/components/src/__alpha__/SingleSelect/SingleSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,85 @@
import React, { type HTMLAttributes, type PropsWithChildren } from 'react'
import { Popover as RACPopover, Select as RACSelect } from 'react-aria-components'
import { type OverrideClassName } from '~components/types/OverrideClassName'
import { List, ListItem, ListSection, Trigger } from './subcomponents'
import styles from './SingleSelect.module.css'
import React, { isValidElement, type PropsWithChildren } from 'react'
import { useSelectState } from '@react-stately/select'
import { type Key, type Selection } from '@react-types/shared'
import { Select as RACSelect, type ListBoxProps } from 'react-aria-components'
import { SingleSelectContext } from './context'
import { List, ListItem, ListSection, Popover, Trigger } from './subcomponents'
import { type SelectItem, type SelectSection } from './types'

export type SingleSelectProps = {
children?: React.ReactNode
} & OverrideClassName<HTMLAttributes<Element>>
items: (SelectItem | SelectSection)[]
onSelectionChange?: (key: Key | null) => void
}

export const SingleSelect = ({
classNameOverride,
items,
onSelectionChange,
children,
...restProps
}: PropsWithChildren<SingleSelectProps>): JSX.Element => {
const buttonRef = React.useRef<HTMLButtonElement>(null)
const popoverRef = React.useRef<HTMLDivElement>(null)
const racPopoverRef = React.useRef<HTMLElement>(null)

// Select state without children render prop to keep things flexible
// and allow for custom list rendering
const state = useSelectState({
items,
})

const handleOnSelectionChange = (keys: Selection): void => {
let key: Key | null = null

if (keys instanceof Set && keys.size > 0) {
key = Array.from(keys)[0]
}

state.setSelectedKey(key)
if (onSelectionChange) {
onSelectionChange(key)
}
}

const selectedKeys: Iterable<Key> = state.selectedKey
? new Set<Key>([state.selectedKey])
: new Set()

// Clone user children injection selection props
const injectedChildren = isValidElement(children)
? React.cloneElement(children as React.ReactElement<ListBoxProps<SelectItem | SelectSection>>, {
selectionMode: 'single',
selectedKeys,
onSelectionChange: handleOnSelectionChange,
autoFocus: 'first',
})
: null

return (
<RACSelect className={classNameOverride} placeholder="" {...restProps}>
<Trigger />
<RACPopover className={styles.popover}>{children}</RACPopover>
</RACSelect>
<SingleSelectContext.Provider
value={{
isOpen: state.isOpen,
setOpen: state.setOpen,
selectedKey: state.selectedKey,
items: items,
}}
>
<RACSelect
onSelectionChange={(key) =>
handleOnSelectionChange(key != null ? new Set([key]) : new Set())
}
placeholder=""
{...restProps}
>
<Trigger buttonRef={buttonRef} />

Copy link
Contributor

Choose a reason for hiding this comment

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

The trigger at the moment is controlled entirely by our implementation with only the buttonRef exposed. Have we considered any potential needs for our consumers to either have a custom trigger or to be able to pass in data attributes that they may used in testing or for programmatically shifting focus?

The main thing that gets me thinking about that is this recent support question

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the callout. I totally appreciate that our users might need more flexibility here. I've written a ticket so that we capture the work on exposing the trigger & popover, trying to give our users as much flexibility as possible https://cultureamp.atlassian.net/browse/KZN-3462

{state.isOpen && (
<Popover buttonRef={buttonRef} popoverRef={popoverRef} racPopoverRef={racPopoverRef}>
{injectedChildren}
</Popover>
)}
</RACSelect>
</SingleSelectContext.Provider>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ Brief summary of the component here.
<Controls of={SingleSelectStories.Playground} />

## API

## Positioning and z-index Management

The SingleSelect component leverages the native Popover API to manage its dropdown functionality. By using popover instead of custom portal logic, the component takes full advantage of CSS layers, ensuring dropdowns appear above other content without manual z-index management.
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,22 @@ const StickerSheetTemplate: StickerSheetStory = {
return (
<StickerSheet isReversed={isReversed} title="SingleSelect" headers={['Items', 'Grouped']}>
<StickerSheet.Row>
<SingleSelect>
<SingleSelect items={singleMockItems}>
<SingleSelect.List>
{singleMockItems.map((item) => (
<SingleSelect.ListItem key={item.value} value={{ value: item.value }}>
<SingleSelect.ListItem key={item.value} id={item.value}>
{item.label}
</SingleSelect.ListItem>
))}
</SingleSelect.List>
</SingleSelect>

<SingleSelect>
<SingleSelect items={groupedMockItems}>
<SingleSelect.List>
{groupedMockItems.map((section) => (
<SingleSelect.ListSection name={section.label} key={section.label}>
{section.options.map((item) => (
<SingleSelect.ListItem key={item.value} value={{ value: item.value }}>
<SingleSelect.ListItem key={item.value} id={item.value}>
{item.label}
</SingleSelect.ListItem>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Meta, type StoryObj } from '@storybook/react'
import { SingleSelect } from '../index'
import { singleMockItems } from './mockData'

const meta = {
title: 'Components/SingleSelect/SingleSelect (alpha)',
Expand All @@ -12,7 +13,9 @@ export default meta
type Story = StoryObj<typeof meta>

export const Playground: Story = {
args: {},
args: {
items: singleMockItems,
Copy link
Contributor

Choose a reason for hiding this comment

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

I noticed in the playground that when there is no children there appears to be a bullet rendered.
Screenshot 2025-07-08 at 11 10 09 am

I'm assume we'll tackle what null or an empty children prop looks like later but just wanted to flag it in this story :)

},
parameters: {
docs: {
canvas: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react'
import { type Key } from '@react-types/shared'
import { type SelectItem, type SelectSection } from '../types'

type SingleSelectContextType = {
isOpen: boolean
setOpen: (open: boolean) => void
selectedKey: Key | null
items: (SelectItem | SelectSection)[]
}

export const SingleSelectContext = createContext<SingleSelectContextType | undefined>(undefined)

export const useSingleSelectContext = (): SingleSelectContextType => {
const context = useContext(SingleSelectContext)
if (!context) {
throw new Error('useSingleSelectContext must be used within a SingleSelectContext.Provider')
}
return context
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SingleSelectContext'
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
.list {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { type PropsWithChildren } from 'react'
import classNames from 'classnames'
import { ListBox as RACListBox, type ListBoxProps } from 'react-aria-components'
import { type SelectItem, type SelectSection } from '../../types'
import styles from './List.module.css'

export const List = ({
children,
className,
...props
}: ListBoxProps<object> & PropsWithChildren): React.ReactElement => {
}: ListBoxProps<SelectItem | SelectSection> & PropsWithChildren): React.ReactElement => {
return (
<RACListBox className={classNames(styles.list, className)} {...props}>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,12 @@
font-size: var(--typography-paragraph-body-font-size);
line-height: var(--typography-paragraph-body-line-height);
letter-spacing: var(--typography-paragraph-body-letter-spacing);
padding: var(--spacing-8) var(--spacing-16);
}

.listItem:focus-visible {
background-color: var(--color-blue-200);
outline: none;
border-color: white;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { type PropsWithChildren } from 'react'
import classNames from 'classnames'
import { ListBoxItem as RACListBoxItem, type ListBoxItemProps } from 'react-aria-components'
import { type SelectItem } from '../../types'
import styles from './ListItem.module.css'

export const ListItem = ({
children,
className,
...props
}: ListBoxItemProps<object> & PropsWithChildren): React.ReactElement => {
}: ListBoxItemProps<SelectItem> & PropsWithChildren): React.ReactElement => {
return (
<RACListBoxItem className={classNames(styles.listItem, className)} {...props}>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import {
ListBoxSection as RACListBoxSection,
type ListBoxSectionProps,
} from 'react-aria-components'
import { type SelectSection } from '../../types'
import styles from './ListSection.module.css'

export const ListSection = ({
name,
className,
children,
...props
}: ListBoxSectionProps<object> & PropsWithChildren & { name: string }): React.ReactElement => {
}: ListBoxSectionProps<SelectSection> &
PropsWithChildren & { name: string }): React.ReactElement => {
return (
<RACListBoxSection {...props}>
<RACHeader className={classNames(styles.listSectionHeader, className)}>{name}</RACHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
.popover {
background-color: var(--color-white);
border-radius: var(--spacing-8);
padding: var(--spacing-8);
width: 200px;
padding: 0;
box-shadow: var(--shadow-small-box-shadow);
}
}
Loading
Loading