diff --git a/CHANGELOG.md b/CHANGELOG.md
index 566cb5c70b..7a62564a61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,9 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure portal root exists in the DOM ([#950](https://github.com/tailwindlabs/headlessui/pull/950))
+### Added
+
+- Allow for `Tab.Group` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970))
+
## [Unreleased - Vue]
-- Nothing yet!
+### Added
+
+- Allow for `TabGroup` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970))
## [@headlessui/react@v1.4.2] - 2021-11-08
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
index 2125583c00..4f233892d0 100644
--- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
+++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
@@ -1,4 +1,4 @@
-import React, { createElement } from 'react'
+import React, { createElement, useState } from 'react'
import { render } from '@testing-library/react'
import { Tab } from './tabs'
@@ -415,6 +415,249 @@ describe('Rendering', () => {
assertTabs({ active: 0 })
assertActiveElement(getByText('Tab 1'))
})
+
+ it('should not change the Tab if the defaultIndex changes', async () => {
+ function Example() {
+ let [defaultIndex, setDefaultIndex] = useState(1)
+
+ return (
+ <>
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+
+ >
+ )
+ }
+
+ render()
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 1 })
+ assertActiveElement(getByText('Tab 2'))
+
+ await click(getByText('Tab 3'))
+
+ assertTabs({ active: 2 })
+ assertActiveElement(getByText('Tab 3'))
+
+ // Change default index
+ await click(getByText('change'))
+
+ // Nothing should change...
+ assertTabs({ active: 2 })
+ })
+ })
+
+ describe('`selectedIndex`', () => {
+ it('should be possible to change active tab controlled and uncontrolled', async () => {
+ let handleChange = jest.fn()
+
+ function ControlledTabs() {
+ let [selectedIndex, setSelectedIndex] = useState(0)
+
+ return (
+ <>
+ {
+ setSelectedIndex(value)
+ handleChange(value)
+ }}
+ >
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+
+ >
+ )
+ }
+
+ render()
+
+ assertActiveElement(document.body)
+
+ // test uncontrolled behaviour
+ await click(getByText('Tab 2'))
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenNthCalledWith(1, 1)
+ assertTabs({ active: 1 })
+
+ // test controlled behaviour
+ await click(getByText('setSelectedIndex'))
+ assertTabs({ active: 2 })
+ })
+
+ it('should jump to the nearest tab when the selectedIndex is out of bounds (-2)', async () => {
+ render(
+ <>
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ >
+ )
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 0 })
+ assertActiveElement(getByText('Tab 1'))
+ })
+
+ it('should jump to the nearest tab when the selectedIndex is out of bounds (+5)', async () => {
+ render(
+ <>
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ >
+ )
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 2 })
+ assertActiveElement(getByText('Tab 3'))
+ })
+
+ it('should jump to the next available tab when the selectedIndex is a disabled tab', async () => {
+ render(
+ <>
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ >
+ )
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 1 })
+ assertActiveElement(getByText('Tab 2'))
+ })
+
+ it('should jump to the next available tab when the selectedIndex is a disabled tab and wrap around', async () => {
+ render(
+ <>
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ >
+ )
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 0 })
+ assertActiveElement(getByText('Tab 1'))
+ })
+
+ it('should prefer selectedIndex over defaultIndex', async () => {
+ render(
+ <>
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ >
+ )
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 0 })
+ assertActiveElement(getByText('Tab 1'))
+ })
})
describe(`'Tab'`, () => {
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx
index d90cfe385a..7020f0e442 100644
--- a/packages/@headlessui-react/src/components/tabs/tabs.tsx
+++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx
@@ -127,11 +127,19 @@ function Tabs(
props: Props & {
defaultIndex?: number
onChange?: (index: number) => void
+ selectedIndex?: number
vertical?: boolean
manual?: boolean
}
) {
- let { defaultIndex = 0, vertical = false, manual = false, onChange, ...passThroughProps } = props
+ let {
+ defaultIndex = 0,
+ vertical = false,
+ manual = false,
+ onChange,
+ selectedIndex = null,
+ ...passThroughProps
+ } = props
const orientation = vertical ? 'vertical' : 'horizontal'
const activation = manual ? 'manual' : 'auto'
@@ -161,18 +169,20 @@ function Tabs(
useEffect(() => {
if (state.tabs.length <= 0) return
- if (state.selectedIndex !== null) return
+ if (selectedIndex === null && state.selectedIndex !== null) return
let tabs = state.tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[]
let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled'))
+ let indexToSet = selectedIndex ?? defaultIndex
+
// Underflow
- if (defaultIndex < 0) {
+ if (indexToSet < 0) {
dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(focusableTabs[0]) })
}
// Overflow
- else if (defaultIndex > state.tabs.length) {
+ else if (indexToSet > state.tabs.length) {
dispatch({
type: ActionTypes.SetSelectedIndex,
index: tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
@@ -181,15 +191,15 @@ function Tabs(
// Middle
else {
- let before = tabs.slice(0, defaultIndex)
- let after = tabs.slice(defaultIndex)
+ let before = tabs.slice(0, indexToSet)
+ let after = tabs.slice(indexToSet)
let next = [...after, ...before].find(tab => focusableTabs.includes(tab))
if (!next) return
dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(next) })
}
- }, [defaultIndex, state.tabs, state.selectedIndex])
+ }, [defaultIndex, selectedIndex, state.tabs, state.selectedIndex])
let lastChangedIndex = useRef(state.selectedIndex)
let providerBag = useMemo>(
@@ -349,7 +359,7 @@ export function Tab(
let passThroughProps = props
if (process.env.NODE_ENV === 'test') {
- Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex })
+ Object.assign(propsWeControl, { 'data-headlessui-index': myIndex })
}
return render({
@@ -424,7 +434,7 @@ function Panel(
}
if (process.env.NODE_ENV === 'test') {
- Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex })
+ Object.assign(propsWeControl, { 'data-headlessui-index': myIndex })
}
let passThroughProps = props
diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts
index 74b12f42f8..4acc8794dd 100644
--- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts
+++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts
@@ -1,4 +1,4 @@
-import { defineComponent, nextTick } from 'vue'
+import { defineComponent, nextTick, ref } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
@@ -433,6 +433,262 @@ describe('Rendering', () => {
assertTabs({ active: 0 })
assertActiveElement(getByText('Tab 1'))
})
+
+ it('should not change the Tab if the defaultIndex changes', async () => {
+ renderTemplate({
+ template: html`
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+
+ `,
+ setup() {
+ let defaultIndex = ref(1)
+ return { defaultIndex }
+ },
+ })
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 1 })
+ assertActiveElement(getByText('Tab 2'))
+
+ await click(getByText('Tab 3'))
+
+ assertTabs({ active: 2 })
+ assertActiveElement(getByText('Tab 3'))
+
+ // Change default index
+ await click(getByText('change'))
+
+ // Nothing should change...
+ assertTabs({ active: 2 })
+ })
+ })
+})
+
+describe('`selectedIndex`', () => {
+ it('should be possible to change active tab controlled and uncontrolled', async () => {
+ let handleChange = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ `,
+ setup() {
+ let selectedIndex = ref(0)
+
+ return {
+ selectedIndex,
+ handleChange(value: number) {
+ selectedIndex.value = value
+ handleChange(value)
+ },
+ next() {
+ selectedIndex.value += 1
+ },
+ }
+ },
+ })
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ // test uncontrolled behaviour
+ await click(getByText('Tab 2'))
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenNthCalledWith(1, 1)
+ assertTabs({ active: 1 })
+
+ // test controlled behaviour
+ await click(getByText('setSelectedIndex'))
+ assertTabs({ active: 2 })
+ })
+
+ it('should jump to the nearest tab when the selectedIndex is out of bounds (-2)', async () => {
+ renderTemplate(
+ html`
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ `
+ )
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 0 })
+ assertActiveElement(getByText('Tab 1'))
+ })
+
+ it('should jump to the nearest tab when the selectedIndex is out of bounds (+5)', async () => {
+ renderTemplate(
+ html`
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ `
+ )
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 2 })
+ assertActiveElement(getByText('Tab 3'))
+ })
+
+ it('should jump to the next available tab when the selectedIndex is a disabled tab', async () => {
+ renderTemplate(
+ html`
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ `
+ )
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 1 })
+ assertActiveElement(getByText('Tab 2'))
+ })
+
+ it('should jump to the next available tab when the selectedIndex is a disabled tab and wrap around', async () => {
+ renderTemplate(
+ html`
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ `
+ )
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 0 })
+ assertActiveElement(getByText('Tab 1'))
+ })
+
+ it('should prefer selectedIndex over defaultIndex', async () => {
+ renderTemplate(
+ html`
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+ `
+ )
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ await press(Keys.Tab)
+
+ assertTabs({ active: 0 })
+ assertActiveElement(getByText('Tab 1'))
})
})
@@ -1880,7 +2136,7 @@ describe('Mouse interactions', () => {
})
})
-it('should trigger the `onChange` when the tab changes', async () => {
+it('should trigger the `change` when the tab changes', async () => {
let changes = jest.fn()
renderTemplate({
diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts
index 3705e8145a..c72784d033 100644
--- a/packages/@headlessui-vue/src/components/tabs/tabs.ts
+++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts
@@ -8,6 +8,7 @@ import {
computed,
InjectionKey,
Ref,
+ watchEffect,
} from 'vue'
import { Features, render, omit } from '../../utils/render'
@@ -58,6 +59,7 @@ export let TabGroup = defineComponent({
},
props: {
as: { type: [Object, String], default: 'template' },
+ selectedIndex: { type: [Number], default: null },
defaultIndex: { type: [Number], default: 0 },
vertical: { type: [Boolean], default: false },
manual: { type: [Boolean], default: false },
@@ -96,27 +98,29 @@ export let TabGroup = defineComponent({
provide(TabsContext, api)
- onMounted(() => {
- if (api.tabs.value.length <= 0) return console.log('bail')
- if (selectedIndex.value !== null) return console.log('bail 2')
+ watchEffect(() => {
+ if (api.tabs.value.length <= 0) return
+ if (props.selectedIndex === null && selectedIndex.value !== null) return
let tabs = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[]
let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled'))
+ let indexToSet = props.selectedIndex ?? props.defaultIndex
+
// Underflow
- if (props.defaultIndex < 0) {
+ if (indexToSet < 0) {
selectedIndex.value = tabs.indexOf(focusableTabs[0])
}
// Overflow
- else if (props.defaultIndex > api.tabs.value.length) {
+ else if (indexToSet > api.tabs.value.length) {
selectedIndex.value = tabs.indexOf(focusableTabs[focusableTabs.length - 1])
}
// Middle
else {
- let before = tabs.slice(0, props.defaultIndex)
- let after = tabs.slice(props.defaultIndex)
+ let before = tabs.slice(0, indexToSet)
+ let after = tabs.slice(indexToSet)
let next = [...after, ...before].find(tab => focusableTabs.includes(tab))
if (!next) return
@@ -129,7 +133,7 @@ export let TabGroup = defineComponent({
let slot = { selectedIndex: selectedIndex.value }
return render({
- props: omit(props, ['defaultIndex', 'manual', 'vertical']),
+ props: omit(props, ['selectedIndex', 'defaultIndex', 'manual', 'vertical', 'onChange']),
slot,
slots,
attrs,