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 16 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

This file was deleted.

86 changes: 75 additions & 11 deletions packages/components/src/__alpha__/SingleSelect/SingleSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,87 @@
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
// TODO: allow user to pass in label
aria-label={'single-select'}
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 @@ -18,10 +18,13 @@ import * as SingleSelectStories from './SingleSelect.stories'

## Overview

{/* @todo: Update summary. */}
Brief summary of the component here.
SingleSelect component that handles selecting items from a dropdown, can be either filterable or not.

<Canvas of={SingleSelectStories.Playground} />
<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
@@ -0,0 +1,100 @@
import React from 'react'
import { type Meta, type StoryObj } from '@storybook/react'
import { expect, screen, userEvent, waitFor } from '@storybook/test'
import { SingleSelect } from '../SingleSelect'
import { singleMockItems } from './mockData'

const meta = {
title: 'Components/SingleSelect/SingleSelect (alpha)',
component: SingleSelect,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof SingleSelect>

export default meta

type Story = StoryObj<typeof meta>

const args = {
items: singleMockItems,
children: (
<SingleSelect.List>
{singleMockItems.map((item) => (
<SingleSelect.ListItem key={item.value} id={item.value}>
{item.label}
</SingleSelect.ListItem>
))}
</SingleSelect.List>
),
}

export const RendersButton: Story = {
args,
play: async () => {
expect(screen.getByRole('button')).toBeInTheDocument()
},
}

export const OpensPopoverOnClick: Story = {
args,
play: async () => {
const trigger = screen.getByRole('button')
await userEvent.click(trigger)
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'true'))
const options = await screen.findAllByRole('option')
expect(options[0]).toBeVisible()
expect(options[0]).toHaveTextContent(singleMockItems[0].label)
},
}

export const ClosesPopoverOnSelect: Story = {
args,
play: async () => {
const trigger = screen.getByRole('button')
await userEvent.click(trigger)
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'true'))
const options = await screen.findAllByRole('option')
await userEvent.click(options[0])
await waitFor(() => expect(screen.queryAllByRole('option')).toHaveLength(0))
},
}

export const KeyboardNavigation: Story = {
args,
play: async () => {
const trigger = screen.getByRole('button')
trigger.focus()
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'true'))
const options = await screen.findAllByRole('option')
await userEvent.keyboard('{ArrowDown}')
expect(options[1]).toHaveAttribute('data-focused', 'true')
await userEvent.keyboard('{ArrowUp}')
expect(options[0]).toHaveAttribute('data-focused', 'true')
},
}

export const KeyboardSelectsItem: Story = {
args,
play: async () => {
const trigger = screen.getByRole('button')
trigger.focus()
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'true'))
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(screen.queryAllByRole('option')).toHaveLength(0))
},
}

export const KeyboardEscapeClosesPopover: Story = {
args,
play: async () => {
const trigger = screen.getByRole('button')
await userEvent.click(trigger)
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'true'))
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false'))
},
}
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,18 +1,45 @@
import React from 'react'
import { type Meta, type StoryObj } from '@storybook/react'
import { SingleSelect } from '../index'
import { singleMockItems } from './mockData'

const meta = {
title: 'Components/SingleSelect/SingleSelect (alpha)',
component: SingleSelect,
args: {},
args: {
items: singleMockItems,
children: (
<SingleSelect.List>
{singleMockItems.map((item) => (
<SingleSelect.ListItem key={item.value} id={item.value}>
{item.label}
</SingleSelect.ListItem>
))}
</SingleSelect.List>
),
},
decorators: [
(Story) => (
<div
style={{
height: '200px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
<Story />
</div>
),
],
} satisfies Meta<typeof SingleSelect>

export default meta

type Story = StoryObj<typeof meta>

export const Playground: Story = {
args: {},
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
Loading
Loading