Skip to content

Commit

Permalink
Improve Tabs wrapping around when controlling the component and ove…
Browse files Browse the repository at this point in the history
…rflowing the `selectedIndex` (#2213)

* ensure chaning the `selectedIndex` tabs properly wraps around

We never want to use and index that doesn't map to a proper tab.

This commit also makes the implementation similar for both React and
Vue.

* add tests to prove the underflow and overflow wrapping

* drop updating the index manually

This is already adjusted when tabs change internally. You can still
manually change it of course, but for these tests that doesn't matter
and cause different results.

* update changelog
  • Loading branch information
RobinMalfait authored Jan 26, 2023
1 parent dbcfb23 commit e2294f5
Show file tree
Hide file tree
Showing 6 changed files with 312 additions and 47 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153))
- Fix crash when reading `headlessuiFocusGuard` of `relatedTarget` in the `FocusTrap` component ([#2203](https://github.com/tailwindlabs/headlessui/pull/2203))
- Fix `FocusTrap` in `Dialog` when there is only 1 focusable element ([#2172](https://github.com/tailwindlabs/headlessui/pull/2172))
- Improve `Tabs` wrapping around when controlling the component and overflowing the `selectedIndex` ([#2213](https://github.com/tailwindlabs/headlessui/pull/2213))

## [1.7.7] - 2022-12-16

Expand Down
89 changes: 88 additions & 1 deletion packages/@headlessui-react/src/components/tabs/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ describe('Rendering', () => {
<button
onClick={() => {
setTabs((tabs) => tabs.slice().reverse())
setSelectedIndex((idx) => tabs.length - 1 - idx)
}}
>
reverse
Expand Down Expand Up @@ -1083,6 +1082,94 @@ describe('Rendering', () => {
assertActiveElement(getByText('Tab 1'))
})
)

it(
'should wrap around when overflowing the index when using a controlled component',
suppressConsoleLogs(async () => {
function Example() {
let [selectedIndex, setSelectedIndex] = useState(0)

return (
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
{({ selectedIndex }) => (
<>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
<button onClick={() => setSelectedIndex(selectedIndex + 1)}>Next</button>
</>
)}
</Tab.Group>
)
}
render(<Example />)

assertActiveElement(document.body)

await click(getByText('Next'))
assertTabs({ active: 1 })

await click(getByText('Next'))
assertTabs({ active: 2 })

await click(getByText('Next'))
assertTabs({ active: 0 })

await click(getByText('Next'))
assertTabs({ active: 1 })
})
)

it(
'should wrap around when underflowing the index when using a controlled component',
suppressConsoleLogs(async () => {
function Example() {
let [selectedIndex, setSelectedIndex] = useState(0)

return (
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
{({ selectedIndex }) => (
<>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
<button onClick={() => setSelectedIndex(selectedIndex - 1)}>Previous</button>
</>
)}
</Tab.Group>
)
}
render(<Example />)

assertActiveElement(document.body)

await click(getByText('Previous'))
assertTabs({ active: 2 })

await click(getByText('Previous'))
assertTabs({ active: 1 })

await click(getByText('Previous'))
assertTabs({ active: 0 })

await click(getByText('Previous'))
assertTabs({ active: 2 })
})
)
})

describe(`'Tab'`, () => {
Expand Down
39 changes: 32 additions & 7 deletions packages/@headlessui-react/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ import { microTask } from '../../utils/micro-task'
import { Hidden } from '../../internal/hidden'
import { getOwnerDocument } from '../../utils/owner'

enum Direction {
Forwards,
Backwards,
}

enum Ordering {
Less = -1,
Equal = 0,
Greater = 1,
}

interface StateDefinition {
selectedIndex: number

Expand Down Expand Up @@ -68,16 +79,30 @@ let reducers: {

let nextState = { ...state, tabs, panels }

// Underflow
if (action.index < 0) {
return { ...nextState, selectedIndex: tabs.indexOf(focusableTabs[0]) }
}
if (
// Underflow
action.index < 0 ||
// Overflow
action.index > tabs.length - 1
) {
let direction = match(Math.sign(action.index - state.selectedIndex), {
[Ordering.Less]: () => Direction.Backwards,
[Ordering.Equal]: () => {
return match(Math.sign(action.index), {
[Ordering.Less]: () => Direction.Forwards,
[Ordering.Equal]: () => Direction.Forwards,
[Ordering.Greater]: () => Direction.Backwards,
})
},
[Ordering.Greater]: () => Direction.Forwards,
})

// Overflow
else if (action.index > tabs.length) {
return {
...nextState,
selectedIndex: tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
selectedIndex: match(direction, {
[Direction.Forwards]: () => tabs.indexOf(focusableTabs[0]),
[Direction.Backwards]: () => tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
}),
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153))
- Fix crash when reading `headlessuiFocusGuard` of `relatedTarget` in the `FocusTrap` component ([#2203](https://github.com/tailwindlabs/headlessui/pull/2203))
- Fix `FocusTrap` in `Dialog` when there is only 1 focusable element ([#2172](https://github.com/tailwindlabs/headlessui/pull/2172))
- Improve `Tabs` wrapping around when controlling the component and overflowing the `selectedIndex` ([#2213](https://github.com/tailwindlabs/headlessui/pull/2213))

## [1.7.7] - 2022-12-16

Expand Down
101 changes: 100 additions & 1 deletion packages/@headlessui-vue/src/components/tabs/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,6 @@ describe('Rendering', () => {
selectedIndex,
reverse() {
tabs.value = tabs.value.slice().reverse()
selectedIndex.value = tabs.value.length - 1 - selectedIndex.value
},
handleChange(value: number) {
selectedIndex.value = value
Expand Down Expand Up @@ -999,6 +998,106 @@ describe('`selectedIndex`', () => {
assertTabs({ active: 0 })
assertActiveElement(getByText('Tab 1'))
})

it(
'should wrap around when overflowing the index when using a controlled component',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<TabGroup :selectedIndex="value" @change="set" v-slot="{ selectedIndex }">
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
<button @click="set(selectedIndex + 1)">Next</button>
</TabGroup>
`,
setup() {
let value = ref(0)
return {
value,
set(v: number) {
value.value = v
},
}
},
})

await new Promise<void>(nextTick)

assertActiveElement(document.body)

await click(getByText('Next'))
assertTabs({ active: 1 })

await click(getByText('Next'))
assertTabs({ active: 2 })

await click(getByText('Next'))
assertTabs({ active: 0 })

await click(getByText('Next'))
assertTabs({ active: 1 })
})
)

it(
'should wrap around when underflowing the index when using a controlled component',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<TabGroup :selectedIndex="value" @change="set" v-slot="{ selectedIndex }">
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
<button @click="set(selectedIndex - 1)">Previous</button>
</TabGroup>
`,
setup() {
let value = ref(0)
return {
value,
set(v: number) {
value.value = v
},
}
},
})

await new Promise<void>(nextTick)

assertActiveElement(document.body)

await click(getByText('Previous'))
assertTabs({ active: 2 })

await click(getByText('Previous'))
assertTabs({ active: 1 })

await click(getByText('Previous'))
assertTabs({ active: 0 })

await click(getByText('Previous'))
assertTabs({ active: 2 })
})
)
})

describe('Keyboard interactions', () => {
Expand Down
Loading

0 comments on commit e2294f5

Please sign in to comment.