diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
index cb3a3e3b8b..f2d635f44f 100644
--- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
+++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
@@ -543,6 +543,64 @@ describe('Rendering', () => {
})
describe('`selectedIndex`', () => {
+ it(
+ 'should not change the tab in a controlled component if you do not respond to the onChange',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+
+ function ControlledTabs() {
+ let [selectedIndex, setSelectedIndex] = useState(0)
+
+ return (
+ <>
+ {
+ handleChange(value)
+ }}
+ >
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+
+
+
+ >
+ )
+ }
+
+ render()
+
+ assertActiveElement(document.body)
+
+ // test controlled behaviour
+ await click(getByText('setSelectedIndex'))
+ assertTabs({ active: 1 })
+ await click(getByText('setSelectedIndex'))
+ assertTabs({ active: 2 })
+
+ // test uncontrolled behaviour again
+ await click(getByText('Tab 1'))
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ await click(getByText('Tab 2'))
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ await click(getByText('Tab 3'))
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ await click(getByText('Tab 1'))
+ expect(handleChange).toHaveBeenCalledTimes(3) // We did see the 'onChange' calls, but only 3 because clicking Tab 3 is already the active one which means that this doesn't trigger the onChange
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ })
+ )
+
it(
'should be possible to change active tab controlled and uncontrolled',
suppressConsoleLogs(async () => {
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx
index 01ce338cab..730c023996 100644
--- a/packages/@headlessui-react/src/components/tabs/tabs.tsx
+++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx
@@ -26,6 +26,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
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'
interface StateDefinition {
selectedIndex: number
@@ -196,6 +197,8 @@ let Tabs = forwardRefWithAs(function Tabs ({
registerTab(tab) {
@@ -226,13 +229,16 @@ let Tabs = forwardRefWithAs(function Tabs {
@@ -388,9 +394,17 @@ let TabRoot = forwardRefWithAs(function Tab {
+ if (ready.current) return
+ ready.current = true
+
internalTabRef.current?.focus()
actions.change(myIndex)
+
+ microTask(() => {
+ ready.current = false
+ })
})
// This is important because we want to only focus the tab when it gets focus
diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts
index b9cef77772..29a6cb9374 100644
--- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts
+++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts
@@ -515,6 +515,67 @@ describe('Rendering', () => {
})
describe('`selectedIndex`', () => {
+ it(
+ 'should not change the tab in a controlled component if you do not respond to the @change',
+ suppressConsoleLogs(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) {
+ handleChange(value)
+ },
+ next() {
+ selectedIndex.value += 1
+ },
+ }
+ },
+ })
+
+ await new Promise(nextTick)
+
+ assertActiveElement(document.body)
+
+ // test controlled behaviour
+ await click(getByText('setSelectedIndex'))
+ assertTabs({ active: 1 })
+ await click(getByText('setSelectedIndex'))
+ assertTabs({ active: 2 })
+
+ // test uncontrolled behaviour again
+ await click(getByText('Tab 1'))
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ await click(getByText('Tab 2'))
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ await click(getByText('Tab 3'))
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ await click(getByText('Tab 1'))
+ expect(handleChange).toHaveBeenCalledTimes(3) // We did see the '@change' calls, but only 3 because clicking Tab 3 is already the active one which means that this doesn't trigger the @change
+ assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
+ })
+ )
+
it('should be possible to change active tab controlled and uncontrolled', async () => {
let handleChange = jest.fn()
diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts
index ed9e5474eb..a9fb146dc9 100644
--- a/packages/@headlessui-vue/src/components/tabs/tabs.ts
+++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts
@@ -23,6 +23,7 @@ import { match } from '../../utils/match'
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'
type StateDefinition = {
// State
@@ -75,6 +76,11 @@ export let TabGroup = defineComponent({
let tabs = ref([])
let panels = ref([])
+ let isControlled = computed(() => props.selectedIndex !== null)
+ let realSelectedIndex = computed(() =>
+ isControlled.value ? props.selectedIndex : selectedIndex.value
+ )
+
let api = {
selectedIndex,
orientation: computed(() => (props.vertical ? 'vertical' : 'horizontal')),
@@ -82,9 +88,13 @@ export let TabGroup = defineComponent({
tabs,
panels,
setSelectedIndex(index: number) {
- if (selectedIndex.value === index) return
- selectedIndex.value = index
- emit('change', index)
+ if (realSelectedIndex.value !== index) {
+ emit('change', index)
+ }
+
+ if (!isControlled.value) {
+ selectedIndex.value = index
+ }
},
registerTab(tab: typeof tabs['value'][number]) {
if (!tabs.value.includes(tab)) tabs.value.push(tab)
@@ -272,11 +282,19 @@ export let Tab = defineComponent({
dom(internalTabRef)?.focus()
}
+ let ready = ref(false)
function handleSelection() {
+ if (ready.value) return
+ ready.value = true
+
if (props.disabled) return
dom(internalTabRef)?.focus()
api.setSelectedIndex(myIndex.value)
+
+ microTask(() => {
+ ready.value = false
+ })
}
// This is important because we want to only focus the tab when it gets focus