Skip to content

Commit 3bc7545

Browse files
RobinMalfaitChiefORZxklovemergossiaux
authored
Next release (#916)
* placeholder for next release * Ensure portal root exists in the DOM (#950) * ensure that the portal root is always in the DOM When using NextJS, it happens that between page transitions the portal root gets removed form the DOM. We will check the DOM when the `target` updates, and if it doesn't exist anymore, then we will re-insert it in the DOM. * update changelog * Allow `Tabs` to be controllable (#970) * feat(react): Allow Tab Component to be controlled * fix falsy bug `selectedIndex || defaultIndex` would result in the `defaultIndex` if `selectedIndex` is set to 0. This means that if you have this code: ```js <Tab.Group selectedIndex={0} defaultIndex={2} /> ``` That you will never be able to see the very first tab, unless you provided a negative value like `-1`. `selectedIndex ?? defaultIndex` fixes this, since it purely checkes for `undefined` and `null`. * implemented controllable Tabs for Vue * add dedicated test to ensure changing the defaultIndex has no effect * update changelog Co-authored-by: ChiefORZ <[email protected]> * Fix missing key binding in examples (#1036) Co-authored-by: superDragon <[email protected]> * Fix slice => splice typo in Vue Tabs component (#1037) Co-authored-by: Ryan Gossiaux <[email protected]> * update changelog * Ensure correct DOM node order when performing focus actions (#1038) * ensure that the order of DOM nodes is correct When we are performing actions like `focusIn(list, Focus.First)` then we have to ensrue that we are working with the correct list that is properly sorted. It can happen that the list of DOM nodes is out of sync. This can happen if you have 3 Tabs, hide the second (which triggers an unmount and an `unregister` of the Tab), then re-add the second item in the middle. This will re-add the item to the end of the list instead of in the middle. We can solve this by always sorting items when we are adding / removing items, but this is a bit more error prone because it is easy to forget. Instead we will sort it when performing the actual keyboard action. If we didn't provide a list but an element, then we use a getFocusableElements(element) function, but this already gives you a correctly sorted list so we don't need to do that for this list. * add tests to prove the correct order when performing actions * cleanup code just for tests It could still happen that this internal list is not ordered correctly but that's not really a problem we just have the list to keep track of things. For our tests we now use the position from the DOM directly. * update changelog Co-authored-by: ChiefORZ <[email protected]> Co-authored-by: superDragon <[email protected]> Co-authored-by: Ryan Gossiaux <[email protected]>
1 parent e9e6ade commit 3bc7545

File tree

11 files changed

+666
-45
lines changed

11 files changed

+666
-45
lines changed

CHANGELOG.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased - React]
99

10-
- Nothing yet!
10+
### Fixes
11+
12+
- Ensure portal root exists in the DOM ([#950](https://github.com/tailwindlabs/headlessui/pull/950))
13+
- Ensure correct DOM node order when performing focus actions ([#1038](https://github.com/tailwindlabs/headlessui/pull/1038))
14+
15+
### Added
16+
17+
- Allow for `Tab.Group` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970))
1118

1219
## [Unreleased - Vue]
1320

14-
- Nothing yet!
21+
### Fixes
22+
23+
- Fix missing key binding in examples ([#1036](https://github.com/tailwindlabs/headlessui/pull/1036), [#1006](https://github.com/tailwindlabs/headlessui/pull/1006))
24+
- Fix slice => splice typo in `Tabs` component ([#1037](https://github.com/tailwindlabs/headlessui/pull/1037), [#986](https://github.com/tailwindlabs/headlessui/pull/986))
25+
- Ensure correct DOM node order when performing focus actions ([#1038](https://github.com/tailwindlabs/headlessui/pull/1038))
26+
27+
### Added
28+
29+
- Allow for `TabGroup` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970))
1530

1631
## [@headlessui/react@v1.4.2] - 2021-11-08
1732

packages/@headlessui-react/src/components/portal/portal.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ function usePortalTarget(): HTMLElement | null {
3434
return document.body.appendChild(root)
3535
})
3636

37+
// Ensure the portal root is always in the DOM
38+
useEffect(() => {
39+
if (target === null) return
40+
41+
if (!document.body.contains(target)) {
42+
document.body.appendChild(target)
43+
}
44+
}, [target])
45+
3746
useEffect(() => {
3847
if (forceInRoot) return
3948
if (groupTarget === null) return

packages/@headlessui-react/src/components/tabs/tabs.test.tsx

Lines changed: 283 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createElement } from 'react'
1+
import React, { createElement, useState } from 'react'
22
import { render } from '@testing-library/react'
33

44
import { Tab } from './tabs'
@@ -75,6 +75,45 @@ describe('Rendering', () => {
7575
assertTabs({ active: 0 })
7676
})
7777

78+
it('should guarantee the order of DOM nodes when performing actions', async () => {
79+
function Example() {
80+
let [hide, setHide] = useState(false)
81+
82+
return (
83+
<>
84+
<button onClick={() => setHide(v => !v)}>toggle</button>
85+
<Tab.Group>
86+
<Tab.List>
87+
<Tab>Tab 1</Tab>
88+
{!hide && <Tab>Tab 2</Tab>}
89+
<Tab>Tab 3</Tab>
90+
</Tab.List>
91+
92+
<Tab.Panels>
93+
<Tab.Panel>Content 1</Tab.Panel>
94+
{!hide && <Tab.Panel>Content 2</Tab.Panel>}
95+
<Tab.Panel>Content 3</Tab.Panel>
96+
</Tab.Panels>
97+
</Tab.Group>
98+
</>
99+
)
100+
}
101+
102+
render(<Example />)
103+
104+
await click(getByText('toggle')) // Remove Tab 2
105+
await click(getByText('toggle')) // Re-add Tab 2
106+
107+
await press(Keys.Tab)
108+
assertTabs({ active: 0 })
109+
110+
await press(Keys.ArrowRight)
111+
assertTabs({ active: 1 })
112+
113+
await press(Keys.ArrowRight)
114+
assertTabs({ active: 2 })
115+
})
116+
78117
describe('`renderProps`', () => {
79118
it('should expose the `selectedIndex` on the `Tab.Group` component', async () => {
80119
render(
@@ -415,6 +454,249 @@ describe('Rendering', () => {
415454
assertTabs({ active: 0 })
416455
assertActiveElement(getByText('Tab 1'))
417456
})
457+
458+
it('should not change the Tab if the defaultIndex changes', async () => {
459+
function Example() {
460+
let [defaultIndex, setDefaultIndex] = useState(1)
461+
462+
return (
463+
<>
464+
<Tab.Group defaultIndex={defaultIndex}>
465+
<Tab.List>
466+
<Tab>Tab 1</Tab>
467+
<Tab>Tab 2</Tab>
468+
<Tab>Tab 3</Tab>
469+
</Tab.List>
470+
471+
<Tab.Panels>
472+
<Tab.Panel>Content 1</Tab.Panel>
473+
<Tab.Panel>Content 2</Tab.Panel>
474+
<Tab.Panel>Content 3</Tab.Panel>
475+
</Tab.Panels>
476+
</Tab.Group>
477+
478+
<button>after</button>
479+
<button onClick={() => setDefaultIndex(0)}>change</button>
480+
</>
481+
)
482+
}
483+
484+
render(<Example />)
485+
486+
assertActiveElement(document.body)
487+
488+
await press(Keys.Tab)
489+
490+
assertTabs({ active: 1 })
491+
assertActiveElement(getByText('Tab 2'))
492+
493+
await click(getByText('Tab 3'))
494+
495+
assertTabs({ active: 2 })
496+
assertActiveElement(getByText('Tab 3'))
497+
498+
// Change default index
499+
await click(getByText('change'))
500+
501+
// Nothing should change...
502+
assertTabs({ active: 2 })
503+
})
504+
})
505+
506+
describe('`selectedIndex`', () => {
507+
it('should be possible to change active tab controlled and uncontrolled', async () => {
508+
let handleChange = jest.fn()
509+
510+
function ControlledTabs() {
511+
let [selectedIndex, setSelectedIndex] = useState(0)
512+
513+
return (
514+
<>
515+
<Tab.Group
516+
selectedIndex={selectedIndex}
517+
onChange={value => {
518+
setSelectedIndex(value)
519+
handleChange(value)
520+
}}
521+
>
522+
<Tab.List>
523+
<Tab>Tab 1</Tab>
524+
<Tab>Tab 2</Tab>
525+
<Tab>Tab 3</Tab>
526+
</Tab.List>
527+
528+
<Tab.Panels>
529+
<Tab.Panel>Content 1</Tab.Panel>
530+
<Tab.Panel>Content 2</Tab.Panel>
531+
<Tab.Panel>Content 3</Tab.Panel>
532+
</Tab.Panels>
533+
</Tab.Group>
534+
535+
<button>after</button>
536+
<button onClick={() => setSelectedIndex(prev => prev + 1)}>setSelectedIndex</button>
537+
</>
538+
)
539+
}
540+
541+
render(<ControlledTabs />)
542+
543+
assertActiveElement(document.body)
544+
545+
// test uncontrolled behaviour
546+
await click(getByText('Tab 2'))
547+
expect(handleChange).toHaveBeenCalledTimes(1)
548+
expect(handleChange).toHaveBeenNthCalledWith(1, 1)
549+
assertTabs({ active: 1 })
550+
551+
// test controlled behaviour
552+
await click(getByText('setSelectedIndex'))
553+
assertTabs({ active: 2 })
554+
})
555+
556+
it('should jump to the nearest tab when the selectedIndex is out of bounds (-2)', async () => {
557+
render(
558+
<>
559+
<Tab.Group selectedIndex={-2}>
560+
<Tab.List>
561+
<Tab>Tab 1</Tab>
562+
<Tab>Tab 2</Tab>
563+
<Tab>Tab 3</Tab>
564+
</Tab.List>
565+
566+
<Tab.Panels>
567+
<Tab.Panel>Content 1</Tab.Panel>
568+
<Tab.Panel>Content 2</Tab.Panel>
569+
<Tab.Panel>Content 3</Tab.Panel>
570+
</Tab.Panels>
571+
</Tab.Group>
572+
573+
<button>after</button>
574+
</>
575+
)
576+
577+
assertActiveElement(document.body)
578+
579+
await press(Keys.Tab)
580+
581+
assertTabs({ active: 0 })
582+
assertActiveElement(getByText('Tab 1'))
583+
})
584+
585+
it('should jump to the nearest tab when the selectedIndex is out of bounds (+5)', async () => {
586+
render(
587+
<>
588+
<Tab.Group selectedIndex={5}>
589+
<Tab.List>
590+
<Tab>Tab 1</Tab>
591+
<Tab>Tab 2</Tab>
592+
<Tab>Tab 3</Tab>
593+
</Tab.List>
594+
595+
<Tab.Panels>
596+
<Tab.Panel>Content 1</Tab.Panel>
597+
<Tab.Panel>Content 2</Tab.Panel>
598+
<Tab.Panel>Content 3</Tab.Panel>
599+
</Tab.Panels>
600+
</Tab.Group>
601+
602+
<button>after</button>
603+
</>
604+
)
605+
606+
assertActiveElement(document.body)
607+
608+
await press(Keys.Tab)
609+
610+
assertTabs({ active: 2 })
611+
assertActiveElement(getByText('Tab 3'))
612+
})
613+
614+
it('should jump to the next available tab when the selectedIndex is a disabled tab', async () => {
615+
render(
616+
<>
617+
<Tab.Group selectedIndex={0}>
618+
<Tab.List>
619+
<Tab disabled>Tab 1</Tab>
620+
<Tab>Tab 2</Tab>
621+
<Tab>Tab 3</Tab>
622+
</Tab.List>
623+
624+
<Tab.Panels>
625+
<Tab.Panel>Content 1</Tab.Panel>
626+
<Tab.Panel>Content 2</Tab.Panel>
627+
<Tab.Panel>Content 3</Tab.Panel>
628+
</Tab.Panels>
629+
</Tab.Group>
630+
631+
<button>after</button>
632+
</>
633+
)
634+
635+
assertActiveElement(document.body)
636+
637+
await press(Keys.Tab)
638+
639+
assertTabs({ active: 1 })
640+
assertActiveElement(getByText('Tab 2'))
641+
})
642+
643+
it('should jump to the next available tab when the selectedIndex is a disabled tab and wrap around', async () => {
644+
render(
645+
<>
646+
<Tab.Group defaultIndex={2}>
647+
<Tab.List>
648+
<Tab>Tab 1</Tab>
649+
<Tab>Tab 2</Tab>
650+
<Tab disabled>Tab 3</Tab>
651+
</Tab.List>
652+
653+
<Tab.Panels>
654+
<Tab.Panel>Content 1</Tab.Panel>
655+
<Tab.Panel>Content 2</Tab.Panel>
656+
<Tab.Panel>Content 3</Tab.Panel>
657+
</Tab.Panels>
658+
</Tab.Group>
659+
660+
<button>after</button>
661+
</>
662+
)
663+
664+
assertActiveElement(document.body)
665+
666+
await press(Keys.Tab)
667+
668+
assertTabs({ active: 0 })
669+
assertActiveElement(getByText('Tab 1'))
670+
})
671+
672+
it('should prefer selectedIndex over defaultIndex', async () => {
673+
render(
674+
<>
675+
<Tab.Group selectedIndex={0} defaultIndex={2}>
676+
<Tab.List>
677+
<Tab>Tab 1</Tab>
678+
<Tab>Tab 2</Tab>
679+
<Tab>Tab 3</Tab>
680+
</Tab.List>
681+
682+
<Tab.Panels>
683+
<Tab.Panel>Content 1</Tab.Panel>
684+
<Tab.Panel>Content 2</Tab.Panel>
685+
<Tab.Panel>Content 3</Tab.Panel>
686+
</Tab.Panels>
687+
</Tab.Group>
688+
689+
<button>after</button>
690+
</>
691+
)
692+
693+
assertActiveElement(document.body)
694+
695+
await press(Keys.Tab)
696+
697+
assertTabs({ active: 0 })
698+
assertActiveElement(getByText('Tab 1'))
699+
})
418700
})
419701

420702
describe(`'Tab'`, () => {

0 commit comments

Comments
 (0)