Ensure Tab order stays consistent, and the currently active Tab stays active (#1837)

* ensure tabs order stays consistent

This ensures that whenever you insert or delete tabs before the current
tab, that the current tab stays active with the proper panel.

To do this we had to start rendering the non-visible panels as well, but
we used the `Hidden` component already which is position fixed and
completely hidden so this should not break layouts where using flexbox
or grid.

* update changelog

* fix TypeScript issue
This commit is contained in:
Robin Malfait
2022-09-08 19:14:42 +02:00
committed by GitHub
parent 10f932afef
commit b296b73783
9 changed files with 134 additions and 13 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve iOS scroll locking ([#1830](https://github.com/tailwindlabs/headlessui/pull/1830))
- Add `<fieldset disabled>` check to radio group options in React ([#1835](https://github.com/tailwindlabs/headlessui/pull/1835))
- Ensure `Tab` order stays consistent, and the currently active `Tab` stays active ([#1837](https://github.com/tailwindlabs/headlessui/pull/1837))
## [1.7.0] - 2022-09-06
@@ -1046,7 +1046,7 @@ describe('Rendering', () => {
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
}}
>
<Combobox name="assignee">
<Combobox<string> name="assignee">
{({ value }) => (
<>
<div data-testid="value">{value}</div>
@@ -123,6 +123,50 @@ describe('Rendering', () => {
})
)
it(
'should guarantee the order when injecting new tabs dynamically',
suppressConsoleLogs(async () => {
function Example() {
let [tabs, setTabs] = useState<string[]>([])
return (
<Tab.Group>
<Tab.List>
{tabs.map((t, i) => (
<Tab key={t}>Tab {i + 1}</Tab>
))}
<Tab>Insert new</Tab>
</Tab.List>
<Tab.Panels>
{tabs.map((t) => (
<Tab.Panel key={t}>{t}</Tab.Panel>
))}
<Tab.Panel>
<button
onClick={() => {
setTabs((old) => [...old, `Panel ${old.length + 1}`])
}}
>
Insert
</button>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
)
}
render(<Example />)
assertTabs({ active: 0, tabContents: 'Insert new', panelContents: 'Insert' })
// Add some new tabs
await click(getByText('Insert'))
// We should still be on the tab we were on
assertTabs({ active: 1, tabContents: 'Insert new', panelContents: 'Insert' })
})
)
describe('`renderProps`', () => {
it(
'should expose the `selectedIndex` on the `Tab.Group` component',
@@ -27,6 +27,7 @@ import { useLatestValue } from '../../hooks/use-latest-value'
import { FocusSentinel } from '../../internal/focus-sentinel'
import { useEvent } from '../../hooks/use-event'
import { microTask } from '../../utils/micro-task'
import { Hidden } from '../../internal/hidden'
interface StateDefinition {
selectedIndex: number
@@ -88,20 +89,23 @@ let reducers: {
},
[ActionTypes.RegisterTab](state, action) {
if (state.tabs.includes(action.tab)) return state
return { ...state, tabs: sortByDomNode([...state.tabs, action.tab], (tab) => tab.current) }
let activeTab = state.tabs[state.selectedIndex]
let adjustedTabs = sortByDomNode([...state.tabs, action.tab], (tab) => tab.current)
let selectedIndex = adjustedTabs.indexOf(activeTab) ?? state.selectedIndex
if (selectedIndex === -1) selectedIndex = state.selectedIndex
return { ...state, tabs: adjustedTabs, selectedIndex }
},
[ActionTypes.UnregisterTab](state, action) {
return {
...state,
tabs: sortByDomNode(
state.tabs.filter((tab) => tab !== action.tab),
(tab) => tab.current
),
}
return { ...state, tabs: state.tabs.filter((tab) => tab !== action.tab) }
},
[ActionTypes.RegisterPanel](state, action) {
if (state.panels.includes(action.panel)) return state
return { ...state, panels: [...state.panels, action.panel] }
return {
...state,
panels: sortByDomNode([...state.panels, action.panel], (panel) => panel.current),
}
},
[ActionTypes.UnregisterPanel](state, action) {
return { ...state, panels: state.panels.filter((panel) => panel !== action.panel) }
@@ -487,7 +491,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
let SSRContext = useSSRTabsCounter('Tab.Panel')
let id = `headlessui-tabs-panel-${useId()}`
let internalPanelRef = useRef<HTMLElement>(null)
let internalPanelRef = useRef<HTMLElement | null>(null)
let panelRef = useSyncRefs(internalPanelRef, ref, (element) => {
if (!element) return
requestAnimationFrame(() => actions.forceRerender())
@@ -514,6 +518,10 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
tabIndex: selected ? 0 : -1,
}
if (!selected && (props.unmount ?? true)) {
return <Hidden as="span" {...ourProps} />
}
return render({
ourProps,
theirProps,
@@ -1664,9 +1664,13 @@ export function assertTabs(
{
active,
orientation = 'horizontal',
tabContents = null,
panelContents = null,
}: {
active: number
orientation?: 'vertical' | 'horizontal'
tabContents?: string | null
panelContents?: string | null
},
list = getTabList(),
tabs = getTabs(),
@@ -1689,6 +1693,9 @@ export function assertTabs(
if (tab === activeTab) {
expect(tab).toHaveAttribute('aria-selected', 'true')
expect(tab).toHaveAttribute('tabindex', '0')
if (tabContents !== null) {
expect(tab.textContent).toBe(tabContents)
}
} else {
expect(tab).toHaveAttribute('aria-selected', 'false')
expect(tab).toHaveAttribute('tabindex', '-1')
@@ -1716,6 +1723,9 @@ export function assertTabs(
if (panel === activePanel) {
expect(panel).toHaveAttribute('tabindex', '0')
if (tabContents !== null) {
expect(panel.textContent).toBe(panelContents)
}
} else {
expect(panel).toHaveAttribute('tabindex', '-1')
}
+1
View File
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Improve iOS scroll locking ([#1830](https://github.com/tailwindlabs/headlessui/pull/1830))
- Ensure `Tab` order stays consistent, and the currently active `Tab` stays active ([#1837](https://github.com/tailwindlabs/headlessui/pull/1837))
## [1.7.0] - 2022-09-06
@@ -132,6 +132,48 @@ describe('Rendering', () => {
assertTabs({ active: 2 })
})
it(
'should guarantee the order when injecting new tabs dynamically',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<TabGroup>
<TabList>
<Tab v-for="(t, i) in tabs" :key="t">Tab {{ i + 1 }}</Tab>
<Tab>Insert new</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="t in tabs" :key="t">{{ t }}</TabPanel>
<TabPanel>
<button @click="add">Insert</button>
</TabPanel>
</TabPanels>
</TabGroup>
`,
setup() {
let tabs = ref<string[]>([])
return {
tabs,
add() {
tabs.value.push(`Panel ${tabs.value.length + 1}`)
},
}
},
})
await new Promise<void>(nextTick)
assertTabs({ active: 0, tabContents: 'Insert new', panelContents: 'Insert' })
// Add some new tabs
await click(getByText('Insert'))
// We should still be on the tab we were on
assertTabs({ active: 1, tabContents: 'Insert new', panelContents: 'Insert' })
})
)
describe('`renderProps`', () => {
it('should expose the `selectedIndex` on the `Tabs` component', async () => {
renderTemplate(
@@ -24,6 +24,7 @@ import { focusIn, Focus } from '../../utils/focus-management'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { FocusSentinel } from '../../internal/focus-sentinel'
import { microTask } from '../../utils/micro-task'
import { Hidden } from '../../internal/hidden'
type StateDefinition = {
// State
@@ -320,7 +321,7 @@ export let Tab = defineComponent({
id,
role: 'tab',
type: type.value,
'aria-controls': api.panels.value[myIndex.value]?.value?.id,
'aria-controls': dom(api.panels.value[myIndex.value])?.id,
'aria-selected': selected.value,
tabIndex: selected.value ? 0 : -1,
disabled: props.disabled ? true : undefined,
@@ -390,10 +391,14 @@ export let TabPanel = defineComponent({
ref: internalPanelRef,
id,
role: 'tabpanel',
'aria-labelledby': api.tabs.value[myIndex.value]?.value?.id,
'aria-labelledby': dom(api.tabs.value[myIndex.value])?.id,
tabIndex: selected.value ? 0 : -1,
}
if (!selected.value && props.unmount) {
return h(Hidden, { as: 'span', ...ourProps })
}
return render({
ourProps,
theirProps: props,
@@ -1664,9 +1664,13 @@ export function assertTabs(
{
active,
orientation = 'horizontal',
tabContents = null,
panelContents = null,
}: {
active: number
orientation?: 'vertical' | 'horizontal'
tabContents?: string | null
panelContents?: string | null
},
list = getTabList(),
tabs = getTabs(),
@@ -1689,6 +1693,9 @@ export function assertTabs(
if (tab === activeTab) {
expect(tab).toHaveAttribute('aria-selected', 'true')
expect(tab).toHaveAttribute('tabindex', '0')
if (tabContents !== null) {
expect(tab.textContent).toBe(tabContents)
}
} else {
expect(tab).toHaveAttribute('aria-selected', 'false')
expect(tab).toHaveAttribute('tabindex', '-1')
@@ -1716,6 +1723,9 @@ export function assertTabs(
if (panel === activePanel) {
expect(panel).toHaveAttribute('tabindex', '0')
if (tabContents !== null) {
expect(panel.textContent).toBe(panelContents)
}
} else {
expect(panel).toHaveAttribute('tabindex', '-1')
}