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:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user