Skip to content

Commit 74fb635

Browse files
committed
feat(headless): tabs selectedIndex impl & tests
1 parent 3741bca commit 74fb635

File tree

2 files changed

+127
-34
lines changed

2 files changed

+127
-34
lines changed

packages/kit-headless/src/components/tabs/tabs.spec.tsx

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
import { component$, useStore } from '@builder.io/qwik';
1+
import { component$, useSignal, useStore, useTask$ } from '@builder.io/qwik';
22
import { Tab } from './tab';
33
import { Tabs } from './tabs';
44
import { TabList } from './tabs-list';
55
import { TabPanel } from './tabs-panel';
66

7-
interface DynamicTabsProps {
8-
tabIndexToDelete?: number;
9-
tabIndexToAdd?: number;
10-
tabsLength: number;
11-
selectedIndex?: number;
12-
}
13-
147
const ThreeTabsComponent = component$(() => {
158
return (
169
<Tabs data-testid="tabs">
@@ -27,22 +20,32 @@ const ThreeTabsComponent = component$(() => {
2720
);
2821
});
2922

23+
interface DynamicTabsProps {
24+
tabIndexToDelete?: number;
25+
tabIndexToAdd?: number;
26+
tabsLength: number;
27+
selectedIndex?: number;
28+
changeIndexTo?: number;
29+
}
30+
3031
const DynamicTabsComponent = component$(
3132
({
3233
tabIndexToDelete = 0,
3334
tabIndexToAdd = 0,
3435
tabsLength,
35-
selectedIndex = 0,
36+
changeIndexTo = 0,
3637
}: DynamicTabsProps) => {
3738
const tabNames = Array(tabsLength)
3839
.fill(1)
3940
.map((_, index) => `Dynamic Tab ${index + 1}`);
4041

4142
const tabsState = useStore(tabNames);
4243

44+
const selectedIndex = useSignal(0);
45+
4346
return (
4447
<>
45-
<Tabs selectedIndex={selectedIndex}>
48+
<Tabs selectedIndex={selectedIndex.value}>
4649
<TabList>
4750
{tabsState.map((tab) => (
4851
<Tab key={tab}>{tab}</Tab>
@@ -60,56 +63,137 @@ const DynamicTabsComponent = component$(
6063
>
6164
Add Tab
6265
</button>
66+
<button
67+
onClick$={() => {
68+
selectedIndex.value = changeIndexTo;
69+
}}
70+
>
71+
Change index to {changeIndexTo}
72+
</button>
6373
</>
6474
);
6575
}
6676
);
6777

78+
const TabsInsideOfTabs = component$(() => {
79+
return (
80+
<Tabs>
81+
<TabList>
82+
<Tab>Tab 1</Tab>
83+
<Tab>Tab 2</Tab>
84+
<Tab>Tab 3</Tab>
85+
</TabList>
86+
87+
<TabPanel>
88+
<Tabs>
89+
<TabList>
90+
<Tab>Tab 1</Tab>
91+
<Tab>Tab 2</Tab>
92+
<Tab>Tab 3</Tab>
93+
</TabList>
94+
95+
<TabPanel>Panel 1</TabPanel>
96+
<TabPanel>Child Panel 2</TabPanel>
97+
<TabPanel>Panel 3</TabPanel>
98+
</Tabs>
99+
</TabPanel>
100+
<TabPanel>Root Panel 2</TabPanel>
101+
<TabPanel>Panel 3</TabPanel>
102+
</Tabs>
103+
);
104+
});
105+
68106
describe('Tabs', () => {
69107
it('INIT', () => {
70108
cy.mount(<ThreeTabsComponent />);
71-
72-
// cy.findByTestId('tabs').should('be.visible').matchImage();
109+
// cy.findByTestId('tabs').matchImage();
73110

74111
cy.checkA11yForComponent();
75112
});
76-
it('should render the component', () => {
113+
it(`GIVEN 3 tabs
114+
WHEN clicking the middle one
115+
THEN render the middle panel`, () => {
77116
cy.mount(<ThreeTabsComponent />);
78117

79118
cy.findByRole('tab', { name: /Tab 2/i }).click();
80119

81120
cy.findByRole('tabpanel').should('contain', 'Panel 2');
82121
});
83122

84-
it('Given 3 tabs, when removing the last one dynamically, only 2 should remain', () => {
123+
it(`GIVEN 3 tabs
124+
WHEN changing the selected index programmatically to the middle
125+
THEN render the middle panel`, () => {
126+
cy.mount(<DynamicTabsComponent tabsLength={3} changeIndexTo={1} />);
127+
128+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 1 Panel');
129+
130+
cy.findByRole('button', { name: /Change index/i }).click();
131+
132+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 2 Panel');
133+
});
134+
135+
it(`GIVEN 3 tabs,
136+
WHEN removing the last one dynamically
137+
THEN only 2 should remain`, () => {
85138
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
86139

87140
cy.findByRole('button', { name: /remove tab/i }).click();
88141

89142
cy.findAllByRole('tab').should('have.length', 2);
90143
});
91144

92-
it('Given 3 tabs, when clicking on the last one and then removing it, tab 2 should be shown', () => {
145+
it(`GIVEN 3 tabs
146+
WHEN clicking on the last one and then removing it
147+
THEN tab 2 should be shown`, () => {
93148
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
149+
94150
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
151+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 3 Panel');
152+
95153
cy.findByRole('button', { name: /remove tab/i }).click();
96154

97155
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 2 Panel');
98156
});
99157

100-
it('Given 4 tabs, when clicking on the last one and then removing the 3rd, tab 4 should be shown', () => {
158+
it(`GIVEN 4 tabs
159+
WHEN clicking on the last one and then removing the 3rd
160+
THEN tab 4 should be shown`, () => {
101161
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToDelete={2} />);
102162
cy.findByRole('tab', { name: /Dynamic Tab 4/i }).click();
103163
cy.findByRole('button', { name: /remove tab/i }).click();
104164

105165
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 4 Panel');
106166
});
107167

108-
it('Given 4 tabs, when selecting the 3rd one and adding a tab at the start, the correct tab should be displayed', () => {
168+
it(`GIVEN 4 tabs
169+
WHEN selecting the 3rd one and adding a tab at the start
170+
THEN the correct tab should be displayed`, () => {
109171
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToAdd={1} />);
110172
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
111173
cy.findByRole('button', { name: /add tab/i }).click();
112174

113175
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 3 Panel');
114176
});
177+
178+
it(`GIVEN tabs inside of tabs
179+
WHEN clicking on the root second tab
180+
THEN it should show only the selected root panel`, () => {
181+
cy.mount(<TabsInsideOfTabs />);
182+
183+
cy.findAllByRole('tab', { name: /Tab 2/i }).first().click();
184+
185+
cy.findByRole('tabpanel')
186+
.should('be.visible')
187+
.should('contain', 'Root Panel 2');
188+
});
189+
190+
it(`GIVEN tabs inside of tabs
191+
WHEN clicking on the child second tab
192+
THEN it should show only the selected child panel`, () => {
193+
cy.mount(<TabsInsideOfTabs />);
194+
195+
cy.findAllByRole('tab', { name: /Tab 2/i }).eq(1).click();
196+
197+
cy.findAllByRole('tabpanel').eq(1).should('contain', 'Child Panel 2');
198+
});
115199
});

packages/kit-headless/src/components/tabs/tabs.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useSignal,
77
useVisibleTask$,
88
useStore,
9+
useTask$,
910
} from '@builder.io/qwik';
1011
import { tabsContextId } from './tabs-context-id';
1112
import { TabsContext } from './tabs-context.type';
@@ -14,9 +15,6 @@ import { Behavior } from './behavior.type';
1415
/**
1516
* TABS TODOs
1617
*
17-
* - CHANGE THE querySelector to "scoped" queries
18-
* - selectedIndex / default
19-
* - expose selectedIndex in the root
2018
* - Orientation
2119
* - aria-label https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby
2220
* - NOTE: Radix manually handle the value/id for each tab while we calculate it behind the scenes
@@ -61,7 +59,14 @@ export interface TabInfo {
6159

6260
export const Tabs = component$((props: TabsProps) => {
6361
const behavior = props.behavior ?? 'manual';
64-
const selectedIndex = useSignal(props.selectedIndex || 0);
62+
63+
const selectedIndex = useSignal(0);
64+
65+
useTask$(({ track }) => {
66+
track(() => props.selectedIndex);
67+
selectedIndex.value = props.selectedIndex || 0;
68+
});
69+
6570
const selectedTabId = useSignal<string>('');
6671
const reIndexTabs = useSignal(false);
6772
const showTabsSignal = useSignal(false);
@@ -117,20 +122,24 @@ export const Tabs = component$((props: TabsProps) => {
117122
reIndexTabs.value = false;
118123

119124
if (ref.value) {
120-
// TODO: Write a failing test for nested tabs to prove this querySelector should be scoped
121-
const tabElements = ref.value.querySelectorAll(
122-
'[role="tablist"] > [role="tab"]'
123-
);
124-
125-
/*
126-
const parentElement = document.querySelector('#parent');
127-
128-
const firstLevelElements = Array.from(parentElement.childNodes)
129-
.filter(node => node.nodeType === Node.ELEMENT_NODE && node.parentNode === parentElement);
130-
131-
*/
125+
const tabsRootElement = ref.value;
126+
127+
const tabListElement = tabsRootElement.querySelector('[role="tablist"]');
128+
let tabElements: Element[] = [];
129+
if (tabListElement) {
130+
tabElements = Array.from(tabListElement?.children).filter((child) => {
131+
return child.getAttribute('role') === 'tab';
132+
});
133+
}
132134

133-
const tabPanelElements = ref.value.querySelectorAll('[role="tabpanel"]');
135+
let tabPanelElements: Element[] = [];
136+
if (tabsRootElement.children) {
137+
tabPanelElements = Array.from(tabsRootElement.children).filter(
138+
(child) => {
139+
return child.getAttribute('role') === 'tabpanel';
140+
}
141+
);
142+
}
134143

135144
// See if the deleted index was the last one
136145
let previousSelectedTabWasLastOne = false;

0 commit comments

Comments
 (0)