Skip to content
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

Improve programmatic state management of UnderlinePanels #5527

Merged
merged 20 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4535ea6
add onSelect prop to UnderlinePanels and UnderlinePanels.Tab
ddoyle2017 Jan 10, 2025
9a04962
UnderlinePanels doc updates
ddoyle2017 Jan 10, 2025
eea83ac
unit test for programmatically selecting tab + updates to underline p…
ddoyle2017 Jan 10, 2025
15ae791
rename unit test + code clean-up
ddoyle2017 Jan 10, 2025
3afde78
add test for tab onSelect prop
ddoyle2017 Jan 10, 2025
887353e
comment explaining UnderlinePanels changes
ddoyle2017 Jan 10, 2025
35a98c0
pr feedback
ddoyle2017 Jan 14, 2025
1628288
Merge branch 'main' into ddoyle2017/update-underlinepanels
ddoyle2017 Jan 14, 2025
3d7f6c4
add changeset
ddoyle2017 Jan 14, 2025
61c8f49
storybook updates
ddoyle2017 Jan 14, 2025
868145a
fixed UnderlinePanels.Tab story rendering issues
ddoyle2017 Jan 15, 2025
063a64b
fix playwright vrt regressions
ddoyle2017 Jan 15, 2025
610baba
Merge branch 'main' into ddoyle2017/update-underlinepanels
francinelucca Jan 16, 2025
c729575
Merge branch 'main' into ddoyle2017/update-underlinepanels
ddoyle2017 Jan 17, 2025
f43aca2
Merge branch 'main' into ddoyle2017/update-underlinepanels
ddoyle2017 Jan 17, 2025
92700fc
Merge branch 'main' into ddoyle2017/update-underlinepanels
ddoyle2017 Jan 17, 2025
7961081
Merge branch 'main' into ddoyle2017/update-underlinepanels
ddoyle2017 Jan 21, 2025
5f503fc
added UnderlinePanels.Tab story to .dev
ddoyle2017 Jan 22, 2025
673f26a
remove no tabs selected case from dev story
ddoyle2017 Jan 23, 2025
88d19b6
Merge branch 'main' into ddoyle2017/update-underlinepanels
ddoyle2017 Jan 23, 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
Expand Up @@ -72,7 +72,13 @@
"name": "aria-selected",
"type": "| boolean | 'true' | 'false'",
"defaultValue": "false",
"description": "Whether this is the selected tab. For more information about `aria-current`, see [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-selected)."
"description": "Whether this is the selected tab. For more information about `aria-selected`, see [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-selected)."
},
{
"name": "onSelect",
"type": "(event) => void",
"defaultValue": "",
"description": "The handler that gets called when the tab is selected"
},
{
"name": "counter",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,54 @@ describe('UnderlinePanels', () => {
const tabList = screen.getByRole('tablist')
expect(tabList).toHaveAccessibleName('Select a tab')
})
it('updates the selected tab when aria-selected changes', () => {
const {rerender} = render(
<UnderlinePanels aria-label="Select a tab">
<UnderlinePanels.Tab aria-selected={true}>Tab 1</UnderlinePanels.Tab>
<UnderlinePanels.Tab aria-selected={false}>Tab 2</UnderlinePanels.Tab>
<UnderlinePanels.Panel>Panel 1</UnderlinePanels.Panel>
<UnderlinePanels.Panel>Panel 2</UnderlinePanels.Panel>
</UnderlinePanels>,
)

// Verify that the first tab is selected and second tab is not
let firstTab = screen.getByRole('tab', {name: 'Tab 1'})
let secondTab = screen.getByRole('tab', {name: 'Tab 2'})

expect(firstTab).toHaveAttribute('aria-selected', 'true')
expect(secondTab).toHaveAttribute('aria-selected', 'false')

// Programmatically select the second tab by updating the aria-selected prop
rerender(
<UnderlinePanels aria-label="Select a tab">
<UnderlinePanels.Tab aria-selected={false}>Tab 1</UnderlinePanels.Tab>
<UnderlinePanels.Tab aria-selected={true}>Tab 2</UnderlinePanels.Tab>
<UnderlinePanels.Panel>Panel 1</UnderlinePanels.Panel>
<UnderlinePanels.Panel>Panel 2</UnderlinePanels.Panel>
</UnderlinePanels>,
)

// Verify the updated aria-selected prop changes which tab is selected
firstTab = screen.getByRole('tab', {name: 'Tab 1'})
secondTab = screen.getByRole('tab', {name: 'Tab 2'})

expect(firstTab).toHaveAttribute('aria-selected', 'false')
expect(secondTab).toHaveAttribute('aria-selected', 'true')
})
it('calls onSelect when a tab is clicked', () => {
const onSelect = jest.fn()
render(
<UnderlinePanels aria-label="Select a tab">
<UnderlinePanels.Tab onSelect={onSelect}>Tab 1</UnderlinePanels.Tab>
<UnderlinePanels.Panel>Panel 1</UnderlinePanels.Panel>
</UnderlinePanels>,
)

const tab = screen.getByRole('tab', {name: 'Tab 1'})
tab.click()

expect(onSelect).toHaveBeenCalled()
})
it('throws an error when the neither aria-label nor aria-labelledby are passed', () => {
render(<UnderlinePanelsMockComponent />)
})
Expand Down
122 changes: 78 additions & 44 deletions packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import React, {Children, isValidElement, cloneElement, useState, useRef, type FC, type PropsWithChildren} from 'react'
import React, {
Children,
isValidElement,
cloneElement,
useState,
useRef,
type FC,
type PropsWithChildren,
useEffect,
} from 'react'
import {TabContainerElement} from '@github/tab-container-element'
import type {IconProps} from '@primer/octicons-react'
import {createComponent} from '../../utils/create-component'
Expand Down Expand Up @@ -34,6 +43,10 @@ export type UnderlinePanelsProps = {
* ID of the element containing the name for the tab list
*/
'aria-labelledby'?: React.AriaAttributes['aria-labelledby']
/**
* Callback that will trigger both on click selection and keyboard selection.
*/
onSelect?: (event: React.KeyboardEvent<HTMLButtonElement> | React.MouseEvent<HTMLButtonElement>) => void
/**
* Custom string to use when generating the IDs of tabs and `aria-labelledby` for the panels
*/
Expand All @@ -49,6 +62,10 @@ export type TabProps = PropsWithChildren<{
* Whether this is the selected tab
*/
'aria-selected'?: boolean
/**
* Callback that will trigger both on click selection and keyboard selection.
*/
onSelect?: (event: React.KeyboardEvent<HTMLButtonElement> | React.MouseEvent<HTMLButtonElement>) => void
/**
* Content of CounterLabel rendered after tab text label
*/
Expand Down Expand Up @@ -85,33 +102,40 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
// called in the exact same order in every component render
const parentId = useId(props.id)

// Loop through the chidren, if it's a tab, then add id="{id}-tab-{index}"
// If it's a panel, then add aria-labelledby="{id}-tab-{index}"
let tabIndex = 0
let panelIndex = 0
const [tabs, setTabs] = useState<React.ReactNode[]>([])
const [tabPanels, setTabPanels] = useState<React.ReactNode[]>([])

const childrenWithProps = Children.map(children, child => {
if (isValidElement<UnderlineItemProps>(child) && child.type === Tab) {
return cloneElement(child, {id: `${parentId}-tab-${tabIndex++}`, loadingCounters, iconsVisible})
}
// Make sure we have fresh prop data whenever the tabs or panels are updated (keep aria-selected current)
useEffect(() => {
// Loop through the chidren, if it's a tab, then add id="{id}-tab-{index}"
// If it's a panel, then add aria-labelledby="{id}-tab-{index}"
let tabIndex = 0
let panelIndex = 0

if (isValidElement<PanelProps>(child) && child.type === Panel) {
return cloneElement(child, {'aria-labelledby': `${parentId}-tab-${panelIndex++}`})
}
return child
})
const childrenWithProps = Children.map(children, child => {
if (isValidElement<UnderlineItemProps>(child) && child.type === Tab) {
return cloneElement(child, {id: `${parentId}-tab-${tabIndex++}`, loadingCounters, iconsVisible})
}

// `tabs` and `tabPanels` need to be refs because `child.type === {type}` will become false
// after the elements are cloned by `childrenWithProps` on the first render
const tabs = useRef(
Children.toArray(childrenWithProps).filter(child => {
if (isValidElement<PanelProps>(child) && child.type === Panel) {
return cloneElement(child, {'aria-labelledby': `${parentId}-tab-${panelIndex++}`})
}
return child
})

const newTabs = Children.toArray(childrenWithProps).filter(child => {
return isValidElement(child) && child.type === Tab
}),
)
const tabPanels = useRef(
Children.toArray(childrenWithProps).filter(child => isValidElement(child) && child.type === Panel),
)
const tabsHaveIcons = tabs.current.some(tab => React.isValidElement(tab) && tab.props.icon)
})

const newTabPanels = Children.toArray(childrenWithProps).filter(
child => isValidElement(child) && child.type === Panel,
)

setTabs(newTabs)
setTabPanels(newTabPanels)
}, [children, parentId, loadingCounters, iconsVisible])

const tabsHaveIcons = tabs.some(tab => React.isValidElement(tab) && tab.props.icon)

const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG)

Expand Down Expand Up @@ -142,19 +166,17 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
)

if (__DEV__) {
// only one tab can be selected at a time
const selectedTabs = tabs.current.filter(tab => {
const selectedTabs = tabs.filter(tab => {
const ariaSelected = React.isValidElement(tab) && tab.props['aria-selected']

return ariaSelected === true || ariaSelected === 'true'
})

invariant(selectedTabs.length <= 1, 'Only one tab can be selected at a time.')

// every tab has its panel
invariant(
tabs.current.length === tabPanels.current.length,
`The number of tabs and panels must be equal. Counted ${tabs.current.length} tabs and ${tabPanels.current.length} panels.`,
tabs.length === tabPanels.length,
`The number of tabs and panels must be equal. Counted ${tabs.length} tabs and ${tabPanels.length} panels.`,
)
}

Expand All @@ -170,10 +192,10 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
{...props}
>
<StyledUnderlineItemList ref={listRef} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} role="tablist">
{tabs.current}
{tabs}
</StyledUnderlineItemList>
</StyledUnderlineWrapper>
{tabPanels.current}
{tabPanels}
</StyledTabContainerComponent>
)
}
Expand All @@ -199,25 +221,37 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
{...props}
>
<StyledUnderlineItemList ref={listRef} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} role="tablist">
{tabs.current}
{tabs}
</StyledUnderlineItemList>
</StyledUnderlineWrapper>
{tabPanels.current}
{tabPanels}
</StyledTabContainerComponent>
)
}

const Tab: FC<TabProps> = ({'aria-selected': ariaSelected, sx: sxProp = defaultSxProp, ...props}) => (
<UnderlineItem
as="button"
role="tab"
tabIndex={ariaSelected ? 0 : -1}
aria-selected={ariaSelected}
sx={sxProp}
type="button"
{...props}
/>
)
const Tab: FC<TabProps> = ({'aria-selected': ariaSelected, sx: sxProp = defaultSxProp, onSelect, ...props}) => {
const clickHandler = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement> | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (!event.defaultPrevented && typeof onSelect === 'function') {
onSelect(event)
}
},
[onSelect],
)

return (
<UnderlineItem
as="button"
role="tab"
tabIndex={ariaSelected ? 0 : -1}
aria-selected={ariaSelected}
sx={sxProp}
type="button"
onClick={clickHandler}
{...props}
/>
)
}

Tab.displayName = 'UnderlinePanels.Tab'

Expand Down
Loading