Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/graduate-underline-panels-controlled-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Tabs and UnderlinePanels: Make the controlled selection and manual activation APIs available without a feature flag. Re-selecting the active tab no longer fires the value change callback.
1 change: 0 additions & 1 deletion packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,4 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({
primer_react_action_list_item_gap: false,
primer_react_timeline_list_semantics: false,
primer_react_merged_forwarded_refs: false,
primer_react_underline_panels_controlled: false,
})
2 changes: 0 additions & 2 deletions packages/react/src/experimental/Tabs/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ By default, selection follows focus: navigating with the Arrow, Home, and End ke
</Tabs>
```

Manual activation is behind the `primer_react_underline_panels_controlled` feature flag; with the flag off, `activationMode` is always `'automatic'`.

### Example: `ActionList`

<Canvas of={TabsExamples.WithCustomComponents} />
40 changes: 18 additions & 22 deletions packages/react/src/experimental/Tabs/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,14 @@ describe('Tabs', () => {
const onValueChange = vi.fn()

render(
<FeatureFlags flags={{primer_react_underline_panels_controlled: true}}>
<Tabs defaultValue="a" onValueChange={onValueChange}>
<TabList aria-label="Test tabs">
<Tab value="a">Tab A</Tab>
<Tab value="b">Tab B</Tab>
</TabList>
<TabPanel value="a">Panel A</TabPanel>
<TabPanel value="b">Panel B</TabPanel>
</Tabs>
</FeatureFlags>,
<Tabs defaultValue="a" onValueChange={onValueChange}>
<TabList aria-label="Test tabs">
<Tab value="a">Tab A</Tab>
<Tab value="b">Tab B</Tab>
</TabList>
<TabPanel value="a">Panel A</TabPanel>
<TabPanel value="b">Panel B</TabPanel>
</Tabs>,
)

await user.click(screen.getByRole('tab', {name: 'Tab A'}))
Expand Down Expand Up @@ -609,18 +607,16 @@ describe('Tabs', () => {
describe('manual activation', () => {
const renderManualTabs = (onValueChange?: (args: {value: string}) => void) =>
render(
<FeatureFlags flags={{primer_react_underline_panels_controlled: true}}>
<Tabs defaultValue="a" activationMode="manual" onValueChange={onValueChange}>
<TabList aria-label="Test tabs">
<Tab value="a">Tab A</Tab>
<Tab value="b">Tab B</Tab>
<Tab value="c">Tab C</Tab>
</TabList>
<TabPanel value="a">Panel A</TabPanel>
<TabPanel value="b">Panel B</TabPanel>
<TabPanel value="c">Panel C</TabPanel>
</Tabs>
</FeatureFlags>,
<Tabs defaultValue="a" activationMode="manual" onValueChange={onValueChange}>
<TabList aria-label="Test tabs">
<Tab value="a">Tab A</Tab>
<Tab value="b">Tab B</Tab>
<Tab value="c">Tab C</Tab>
</TabList>
<TabPanel value="a">Panel A</TabPanel>
<TabPanel value="b">Panel B</TabPanel>
<TabPanel value="c">Panel C</TabPanel>
</Tabs>,
)

test('arrow keys move focus without changing selection', async () => {
Expand Down
9 changes: 3 additions & 6 deletions packages/react/src/experimental/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React, {useId, useMemo, type ElementRef} from 'react'
import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect'
import {useControllableState} from '../../hooks/useControllableState'
import {TabsContext} from './TabsContext'
import {useFeatureFlag} from '../../FeatureFlags'
import type {TabListProps, TabPanelProps, TabProps, TabsContextValue, TabsProps} from './types'
import {useTab} from './useTab'
import {useTabList} from './useTabList'
Expand All @@ -19,9 +18,7 @@ function Tabs(props: TabsProps) {
const {children, onValueChange} = props
const generatedId = useId()
const groupId = props.id ?? generatedId
// Feature-flag scaffolding: at graduation, drop this and the guards that read it.
const controlledApiEnabled = useFeatureFlag('primer_react_underline_panels_controlled')
const activationMode = controlledApiEnabled ? (props.activationMode ?? 'automatic') : 'automatic'
const activationMode = props.activationMode ?? 'automatic'

const [selectedValue, setSelectedValue] = useControllableState<string>({
name: 'tab-selection',
Expand All @@ -41,7 +38,7 @@ function Tabs(props: TabsProps) {
focusedValue,
selectTab(value: string) {
setFocusedValue(undefined)
if (controlledApiEnabled && value === selectedValue) {
if (value === selectedValue) {
return
}
setSelectedValue(value)
Expand All @@ -51,7 +48,7 @@ function Tabs(props: TabsProps) {
setFocusedValue(value)
},
}
}, [groupId, selectedValue, activationMode, focusedValue, controlledApiEnabled, setSelectedValue])
}, [groupId, selectedValue, activationMode, focusedValue, setSelectedValue])

useIsomorphicLayoutEffect(() => {
savedOnValueChange.current = onValueChange
Expand Down
11 changes: 4 additions & 7 deletions packages/react/src/experimental/Tabs/useTabList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ export function useTabList<T extends HTMLElement>(props: TabListHookProps<T>): T
const {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation} = props

const mergedRefEnabled = useFeatureFlag('primer_react_merged_forwarded_refs')
const controlledApiEnabled = useFeatureFlag('primer_react_underline_panels_controlled')
const tabListRef = useRef<T>(null)
const mergedRef = useMergedRefs(tabListRef, props.ref)
// Feature-flag scaffolding for `primer_react_merged_forwarded_refs`.
Expand All @@ -26,12 +25,10 @@ export function useTabList<T extends HTMLElement>(props: TabListHookProps<T>): T
const tabs = getFocusableTabs(tablist)

const getCurrentIndex = () => {
if (controlledApiEnabled) {
const activeElement = tablist.ownerDocument.activeElement
const focusedIndex = activeElement instanceof HTMLElement ? tabs.indexOf(activeElement) : -1
if (focusedIndex !== -1) {
return focusedIndex
}
const activeElement = tablist.ownerDocument.activeElement
const focusedIndex = activeElement instanceof HTMLElement ? tabs.indexOf(activeElement) : -1
if (focusedIndex !== -1) {
return focusedIndex
}
return tabs.findIndex(tab => {
return tab.getAttribute('aria-selected') === 'true'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"name": "value",
"type": "string",
"defaultValue": "",
"description": "The value of the selected tab, keyed to each `UnderlinePanels.Tab`/`UnderlinePanels.Panel` `value`. Provide this (with `onChange`) for a controlled component where the selected tab is the single source of truth. Requires the `primer_react_underline_panels_controlled` feature flag."
"description": "The value of the selected tab, keyed to each `UnderlinePanels.Tab`/`UnderlinePanels.Panel` `value`. Provide this (with `onChange`) for a controlled component where the selected tab is the single source of truth."
},
{
"name": "defaultValue",
Expand All @@ -74,7 +74,7 @@
"name": "activationMode",
"type": "'automatic' | 'manual'",
"defaultValue": "'automatic'",
"description": "Controls how tabs are activated with the keyboard. `'automatic'` selects on focus (Arrow/Home/End select immediately); `'manual'` moves focus only and commits selection on Enter, Space, or click. Prefer `'manual'` when displaying a panel is not instant (e.g. it triggers a network request). Requires the `primer_react_underline_panels_controlled` feature flag."
"description": "Controls how tabs are activated with the keyboard. `'automatic'` selects on focus (Arrow/Home/End select immediately); `'manual'` moves focus only and commits selection on Enter, Space, or click. Prefer `'manual'` when displaying a panel is not instant (e.g. it triggers a network request)."
},
{
"name": "children",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {action} from 'storybook/actions'
import {useState} from 'react'
import {INITIAL_VIEWPORTS} from 'storybook/viewport'
import UnderlinePanels from './UnderlinePanels'
import {useFeatureFlag} from '../../FeatureFlags'
import {AnchoredOverlay} from '../../AnchoredOverlay'
import {Button} from '../../Button'
import type {ComponentProps} from '../../utils/types'
Expand Down Expand Up @@ -123,26 +122,11 @@ export const WithCountersInLoadingState = () => {
)
}

// These stories exercise the controlled API, which is gated. Rather than force the flag on (which
// would override the toolbar), surface its state so the toolbar can be used to compare on vs off.
const FlagState = () => {
const enabled = useFeatureFlag('primer_react_underline_panels_controlled')

return enabled ? null : (
<p>
<code>primer_react_underline_panels_controlled</code> is <strong>off</strong>, so <code>value</code>,{' '}
<code>defaultValue</code>, <code>onChange</code>, and <code>activationMode</code> are ignored and tabs fall back
to positional selection. Toggle the flag in the Storybook toolbar to compare.
</p>
)
}

export const Controlled = () => {
const [refType, setRefType] = useState('branch')

return (
<>
<FlagState />
<UnderlinePanels
aria-label="Ref type"
value={refType}
Expand All @@ -169,7 +153,6 @@ export const Controlled = () => {

export const Uncontrolled = () => (
<>
<FlagState />
<UnderlinePanels
aria-label="Ref type"
defaultValue="tag"
Expand All @@ -190,7 +173,6 @@ export const ManualActivation = () => {

return (
<>
<FlagState />
<p>
With <code>activationMode=&quot;manual&quot;</code>, arrow keys only move focus; press Enter or Space (or click)
to commit selection. Prefer this when switching tabs triggers async work like a fetch.
Expand Down Expand Up @@ -222,7 +204,6 @@ export const InOverlay = () => {

return (
<>
<FlagState />
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {CodeIcon, EyeIcon} from '@primer/octicons-react'
import UnderlinePanels from './UnderlinePanels'
import {AnchoredOverlay} from '../../AnchoredOverlay'
import {implementsClassName, withExpectedConsoleError, withExpectedConsoleWarning} from '../../utils/testing'
import {FeatureFlags} from '../../FeatureFlags'
import classes from './UnderlinePanels.module.css'

const UnderlinePanelsMockComponent = (props: {'aria-label'?: string; 'aria-labelledby'?: string; id?: string}) => (
Expand Down Expand Up @@ -201,24 +200,18 @@ describe('UnderlinePanels', () => {
})

describe('controlled value / onChange / activationMode', () => {
const Flagged = ({children}: {children: React.ReactNode}) => (
<FeatureFlags flags={{primer_react_underline_panels_controlled: true}}>{children}</FeatureFlags>
)

const RefTabs = (props: {
value?: string
defaultValue?: string
activationMode?: 'automatic' | 'manual'
onChange?: ({value}: {value: string}) => void
}) => (
<Flagged>
<UnderlinePanels aria-label="Ref type" {...props}>
<UnderlinePanels.Tab value="branch">Branches</UnderlinePanels.Tab>
<UnderlinePanels.Tab value="tag">Tags</UnderlinePanels.Tab>
<UnderlinePanels.Panel value="branch">Branch panel</UnderlinePanels.Panel>
<UnderlinePanels.Panel value="tag">Tag panel</UnderlinePanels.Panel>
</UnderlinePanels>
</Flagged>
<UnderlinePanels aria-label="Ref type" {...props}>
<UnderlinePanels.Tab value="branch">Branches</UnderlinePanels.Tab>
<UnderlinePanels.Tab value="tag">Tags</UnderlinePanels.Tab>
<UnderlinePanels.Panel value="branch">Branch panel</UnderlinePanels.Panel>
<UnderlinePanels.Panel value="tag">Tag panel</UnderlinePanels.Panel>
</UnderlinePanels>
)

it('`value` selects the matching tab and shows its panel', () => {
Expand Down Expand Up @@ -357,14 +350,12 @@ describe('UnderlinePanels', () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<Flagged>
<UnderlinePanels aria-label="Select a tab" onChange={onChange}>
<UnderlinePanels.Tab>Tab 1</UnderlinePanels.Tab>
<UnderlinePanels.Tab>Tab 2</UnderlinePanels.Tab>
<UnderlinePanels.Panel>Panel 1</UnderlinePanels.Panel>
<UnderlinePanels.Panel>Panel 2</UnderlinePanels.Panel>
</UnderlinePanels>
</Flagged>,
<UnderlinePanels aria-label="Select a tab" onChange={onChange}>
<UnderlinePanels.Tab>Tab 1</UnderlinePanels.Tab>
<UnderlinePanels.Tab>Tab 2</UnderlinePanels.Tab>
<UnderlinePanels.Panel>Panel 1</UnderlinePanels.Panel>
<UnderlinePanels.Panel>Panel 2</UnderlinePanels.Panel>
</UnderlinePanels>,
)

await act(async () => {
Expand All @@ -387,20 +378,18 @@ describe('UnderlinePanels', () => {
{tab: 'tag', panel: 'tag'},
]
return (
<Flagged>
<UnderlinePanels aria-label="Ref type" value={props.value} defaultValue={props.defaultValue}>
{pairs.map((p, i) => (
<UnderlinePanels.Tab key={`tab-${i}`} value={p.tab}>
{p.tab}
</UnderlinePanels.Tab>
))}
{pairs.map((p, i) => (
<UnderlinePanels.Panel key={`panel-${i}`} value={p.panel}>
{p.panel} panel
</UnderlinePanels.Panel>
))}
</UnderlinePanels>
</Flagged>
<UnderlinePanels aria-label="Ref type" value={props.value} defaultValue={props.defaultValue}>
{pairs.map((p, i) => (
<UnderlinePanels.Tab key={`tab-${i}`} value={p.tab}>
{p.tab}
</UnderlinePanels.Tab>
))}
{pairs.map((p, i) => (
<UnderlinePanels.Panel key={`panel-${i}`} value={p.panel}>
{p.panel} panel
</UnderlinePanels.Panel>
))}
</UnderlinePanels>
)
}

Expand Down Expand Up @@ -456,52 +445,6 @@ describe('UnderlinePanels', () => {
})
})
})

describe('with the feature flag disabled', () => {
const UnflaggedRefTabs = (props: {
value?: string
activationMode?: 'automatic' | 'manual'
onChange?: ({value}: {value: string}) => void
}) => (
<UnderlinePanels aria-label="Ref type" {...props}>
<UnderlinePanels.Tab value="branch">Branches</UnderlinePanels.Tab>
<UnderlinePanels.Tab value="tag">Tags</UnderlinePanels.Tab>
<UnderlinePanels.Panel value="branch">Branch panel</UnderlinePanels.Panel>
<UnderlinePanels.Panel value="tag">Tag panel</UnderlinePanels.Panel>
</UnderlinePanels>
)

it('ignores `value` and falls back to positional selection', () => {
render(<UnflaggedRefTabs value="tag" />)

expect(screen.getByRole('tab', {name: 'Branches'})).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', {name: 'Tags'})).toHaveAttribute('aria-selected', 'false')
expect(screen.getByText('Branch panel')).toBeVisible()
})

it('does not call onChange', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<UnflaggedRefTabs onChange={onChange} />)

await user.click(screen.getByRole('tab', {name: 'Tags'}))

expect(onChange).not.toHaveBeenCalled()
expect(screen.getByRole('tab', {name: 'Tags'})).toHaveAttribute('aria-selected', 'true')
})

it('ignores `activationMode="manual"` and still selects on arrow keys', async () => {
const user = userEvent.setup()
render(<UnflaggedRefTabs activationMode="manual" />)

await act(async () => {
screen.getByRole('tab', {name: 'Branches'}).focus()
await user.keyboard('{ArrowRight}')
})

expect(screen.getByRole('tab', {name: 'Tags'})).toHaveAttribute('aria-selected', 'true')
})
})
})
})

Expand Down
Loading
Loading