Skip to content

Commit

Permalink
Merge 1628288 into 98b5456
Browse files Browse the repository at this point in the history
  • Loading branch information
ddoyle2017 authored Jan 14, 2025
2 parents 98b5456 + 1628288 commit 16179c6
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 45 deletions.
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
127 changes: 83 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 @@ -49,6 +58,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 +98,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 +162,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 +188,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 +217,46 @@ 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.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (!event.defaultPrevented && typeof onSelect === 'function') {
onSelect(event)
}
},
[onSelect],
)
const keyDownHandler = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === ' ' || event.key === 'Enter') && !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}
onKeyDown={keyDownHandler}
{...props}
/>
)
}

Tab.displayName = 'UnderlinePanels.Tab'

Expand Down

0 comments on commit 16179c6

Please sign in to comment.