Fix arrow key handling in Tab (after DOM order changes) (#2145)

* detect change in `Tab` order

This will guarantee that when you are using your arrow keys that the
previous / next values are the correct ones instead of the "old" values
before the order change happened.

Fixes: #2131

* update changelog
This commit is contained in:
Robin Malfait
2023-01-04 14:59:30 +01:00
committed by GitHub
parent d874e561a1
commit e8b7b7fe60
6 changed files with 396 additions and 11 deletions
+1
View File
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fix SSR tab rendering on React 17 ([#2102](https://github.com/tailwindlabs/headlessui/pull/2102))
- Fix arrow key handling in `Tab` (after DOM order changes) ([#2145](https://github.com/tailwindlabs/headlessui/pull/2145))
## [1.7.7] - 2022-12-16
@@ -167,6 +167,177 @@ describe('Rendering', () => {
})
)
it(
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (controlled component)',
suppressConsoleLogs(async () => {
function Example() {
let [selectedIndex, setSelectedIndex] = useState(1)
let [tabs, setTabs] = useState([0, 1, 2])
return (
<>
<button
onClick={() => {
setTabs((tabs) => tabs.slice().reverse())
setSelectedIndex((idx) => tabs.length - 1 - idx)
}}
>
reverse
</button>
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
<Tab.List>
{tabs.map((tab) => (
<Tab key={tab}>Tab {tab}</Tab>
))}
</Tab.List>
<Tab.Panels>
{tabs.map((tab) => (
<Tab.Panel key={tab}>Content {tab}</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
<p id="selectedIndex">{selectedIndex}</p>
</>
)
}
render(<Example />)
let selectedIndexElement = document.getElementById('selectedIndex')
assertTabs({ active: 1 })
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('reverse'))
// Note: the indices are reversed now
await click(getByText('Tab 0'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('reverse'))
// Note: the indices are reversed again now (back to normal)
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
})
)
it(
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (uncontrolled component)',
suppressConsoleLogs(async () => {
function Example() {
let [tabs, setTabs] = useState([0, 1, 2])
return (
<>
<button
onClick={() => {
setTabs((tabs) => tabs.slice().reverse())
}}
>
reverse
</button>
<Tab.Group>
{({ selectedIndex }) => (
<>
<Tab.List>
{tabs.map((tab) => (
<Tab key={tab}>Tab {tab}</Tab>
))}
</Tab.List>
<Tab.Panels>
{tabs.map((tab) => (
<Tab.Panel key={tab}>Content {tab}</Tab.Panel>
))}
</Tab.Panels>
<p id="selectedIndex">{selectedIndex}</p>
</>
)}
</Tab.Group>
</>
)
}
render(<Example />)
let selectedIndexElement = document.getElementById('selectedIndex')
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('reverse'))
// Note: the indices are reversed now
await click(getByText('Tab 0'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('reverse'))
// Note: the indices are reversed again now (back to normal)
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
})
)
describe('`renderProps`', () => {
it(
'should be possible to render using as={Fragment}',
@@ -61,29 +61,37 @@ let reducers: {
) => StateDefinition
} = {
[ActionTypes.SetSelectedIndex](state, action) {
let focusableTabs = state.tabs.filter((tab) => !tab.current?.hasAttribute('disabled'))
let tabs = sortByDomNode(state.tabs, (tab) => tab.current)
let panels = sortByDomNode(state.panels, (panel) => panel.current)
let focusableTabs = tabs.filter((tab) => !tab.current?.hasAttribute('disabled'))
let nextState = { ...state, tabs, panels }
// Underflow
if (action.index < 0) {
return { ...state, selectedIndex: state.tabs.indexOf(focusableTabs[0]) }
return { ...nextState, selectedIndex: tabs.indexOf(focusableTabs[0]) }
}
// Overflow
else if (action.index > state.tabs.length) {
else if (action.index > tabs.length) {
return {
...state,
selectedIndex: state.tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
...nextState,
selectedIndex: tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
}
}
// Middle
let before = state.tabs.slice(0, action.index)
let after = state.tabs.slice(action.index)
let before = tabs.slice(0, action.index)
let after = tabs.slice(action.index)
let next = [...after, ...before].find((tab) => focusableTabs.includes(tab))
if (!next) return state
if (!next) return nextState
return { ...state, selectedIndex: state.tabs.indexOf(next) }
let selectedIndex = tabs.indexOf(next) ?? state.selectedIndex
if (selectedIndex === -1) selectedIndex = state.selectedIndex
return { ...nextState, selectedIndex }
},
[ActionTypes.RegisterTab](state, action) {
if (state.tabs.includes(action.tab)) return state
@@ -237,6 +245,20 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
dispatch({ type: ActionTypes.SetSelectedIndex, index: selectedIndex ?? defaultIndex })
}, [selectedIndex /* Deliberately skipping defaultIndex */])
useIsoMorphicEffect(() => {
if (realSelectedIndex.current === undefined) return
if (state.tabs.length <= 0) return
// TODO: Figure out a way to detect this without the slow sort on every render. Might be fine
// unless you have a lot of tabs.
let sorted = sortByDomNode(state.tabs, (tab) => tab.current)
let didOrderChange = sorted.some((tab, i) => state.tabs[i] !== tab)
if (didOrderChange) {
change(sorted.indexOf(state.tabs[realSelectedIndex.current]))
}
})
let SSRCounter = useRef({ tabs: 0, panels: 0 })
let ourProps = { ref: tabsRef }
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Ensure `disabled="false"` is not incorrectly passed to the underlying DOM Node ([#2138](https://github.com/tailwindlabs/headlessui/pull/2138))
- Fix arrow key handling in `Tab` (after DOM order changes) ([#2145](https://github.com/tailwindlabs/headlessui/pull/2145))
## [1.7.7] - 2022-12-16
@@ -174,6 +174,175 @@ describe('Rendering', () => {
})
)
it(
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (controlled component)',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<button @click="reverse">reverse</button>
<TabGroup :selectedIndex="selectedIndex" @change="handleChange">
<TabList>
<Tab v-for="tab in tabs" :key="tab">Tab {{ tab }}</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="tab in tabs" :key="tab">Content {{ tab }}</TabPanel>
</TabPanels>
</TabGroup>
<p id="selectedIndex">{{ selectedIndex }}</p>
`,
setup() {
let selectedIndex = ref(1)
let tabs = ref([0, 1, 2])
return {
tabs,
selectedIndex,
reverse() {
tabs.value = tabs.value.slice().reverse()
selectedIndex.value = tabs.value.length - 1 - selectedIndex.value
},
handleChange(value: number) {
selectedIndex.value = value
},
}
},
})
await new Promise<void>(nextTick)
let selectedIndexElement = document.getElementById('selectedIndex')
assertTabs({ active: 1 })
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('reverse'))
// Note: the indices are reversed now
await click(getByText('Tab 0'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('reverse'))
// Note: the indices are reversed again now (back to normal)
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
})
)
it(
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (uncontrolled component)',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<button @click="reverse">reverse</button>
<TabGroup
:selectedIndex="selectedIndex"
@change="handleChange"
v-slot="{ selectedIndex }"
>
<TabList>
<Tab v-for="tab in tabs" :key="tab">Tab {{ tab }}</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="tab in tabs" :key="tab">Content {{ tab }}</TabPanel>
</TabPanels>
<p id="selectedIndex">{{ selectedIndex }}</p>
</TabGroup>
`,
setup() {
let selectedIndex = ref(1)
let tabs = ref([0, 1, 2])
return {
tabs,
selectedIndex,
reverse() {
tabs.value = tabs.value.slice().reverse()
},
handleChange(value: number) {
selectedIndex.value = value
},
}
},
})
await new Promise<void>(nextTick)
let selectedIndexElement = document.getElementById('selectedIndex')
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('reverse'))
// Note: the indices are reversed now
await click(getByText('Tab 0'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('reverse'))
// Note: the indices are reversed again now (back to normal)
await click(getByText('Tab 0'))
assertTabs({ active: 0 })
expect(selectedIndexElement).toHaveTextContent('0')
await click(getByText('Tab 1'))
assertTabs({ active: 1 })
expect(selectedIndexElement).toHaveTextContent('1')
await click(getByText('Tab 2'))
assertTabs({ active: 2 })
expect(selectedIndexElement).toHaveTextContent('2')
})
)
describe('`renderProps`', () => {
it('should expose the `selectedIndex` on the `Tabs` component', async () => {
renderTemplate(
@@ -20,7 +20,7 @@ import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { dom } from '../../utils/dom'
import { match } from '../../utils/match'
import { focusIn, Focus, FocusResult } from '../../utils/focus-management'
import { focusIn, Focus, FocusResult, sortByDomNode } from '../../utils/focus-management'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { FocusSentinel } from '../../internal/focus-sentinel'
import { microTask } from '../../utils/micro-task'
@@ -134,6 +134,9 @@ export let TabGroup = defineComponent({
if (api.tabs.value.length <= 0) return
if (props.selectedIndex === null && selectedIndex.value !== null) return
api.tabs.value = sortByDomNode(api.tabs.value, dom)
api.panels.value = sortByDomNode(api.panels.value, dom)
let tabs = api.tabs.value.map((tab) => dom(tab)).filter(Boolean) as HTMLElement[]
let focusableTabs = tabs.filter((tab) => !tab.hasAttribute('disabled'))
@@ -157,7 +160,25 @@ export let TabGroup = defineComponent({
let next = [...after, ...before].find((tab) => focusableTabs.includes(tab))
if (!next) return
selectedIndex.value = tabs.indexOf(next)
let localSelectedIndex = tabs.indexOf(next) ?? api.selectedIndex.value
if (localSelectedIndex === -1) localSelectedIndex = api.selectedIndex.value
selectedIndex.value = localSelectedIndex
}
})
watchEffect(() => {
if (!isControlled.value) return
if (realSelectedIndex.value == null) return
if (api.tabs.value.length <= 0) return
let sorted = sortByDomNode(api.tabs.value, dom)
let didOrderChange = sorted.some((tab, i) => dom(api.tabs.value[i]) !== dom(tab))
if (didOrderChange) {
api.setSelectedIndex(
sorted.findIndex((x) => dom(x) === dom(api.tabs.value[realSelectedIndex.value!]))
)
}
})