-
Notifications
You must be signed in to change notification settings - Fork 23
[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
base: main
Are you sure you want to change the base?
Changes from 16 commits
a985a19
c862422
7b4beed
566129a
6f62ad1
401c338
5632923
6f84289
6b2cb5b
82f8f8e
2744914
11f606b
c80d7d1
d32fd52
c3c8afb
428052a
40cc648
0808407
738f5f1
7788311
6f973d3
d1b90fb
06dd126
dc10e64
bf6918a
f0ab4e7
239f351
e8a2bc6
a4e61a7
9841ea8
24ac24b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
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} /> | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
) | ||
} | ||
|
||
|
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 |
---|---|---|
@@ -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 |
---|---|---|
|
@@ -2,6 +2,5 @@ | |
.list { | ||
display: flex; | ||
flex-direction: column; | ||
gap: var(--spacing-16); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.