Skip to content

Commit 487de0e

Browse files
shaireznaorpeledigalklebanov
committed
feat(headless/tabs): handle left arrow, home, end
Co-authored-by: Naor Peled <[email protected]> Co-authored-by: Igal Klebanov <[email protected]>
1 parent 1284b1e commit 487de0e

File tree

5 files changed

+305
-107
lines changed

5 files changed

+305
-107
lines changed

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import {
99
$,
1010
} from '@builder.io/qwik';
1111
import { tabsContextId } from './tabs-context-id';
12+
import { KeyCode } from '../../utils/key-code.type';
1213

1314
export interface TabProps {
1415
onClick?: PropFunction<() => void>;
1516
class?: string;
1617
selectedClassName?: string;
18+
disabled?: boolean;
1719
}
1820

1921
export const Tab = component$((props: TabProps) => {
@@ -29,6 +31,17 @@ export const Tab = component$((props: TabProps) => {
2931
});
3032
});
3133

34+
useTask$(({ track }) => {
35+
track(() => props.disabled);
36+
console.log(
37+
'contextService.tabsMap[uniqueId]',
38+
contextService.tabsMap[uniqueId]
39+
);
40+
if (props.disabled && contextService.tabsMap[uniqueId]) {
41+
contextService.tabsMap[uniqueId].disabled = true;
42+
}
43+
});
44+
3245
const isSelectedSignal = useComputed$(() => {
3346
return (
3447
contextService.selectedIndex.value ===
@@ -51,6 +64,10 @@ export const Tab = component$((props: TabProps) => {
5164

5265
const selectTab$ = $(() => {
5366
// TODO: try to move this to the Tabs component
67+
68+
if (props.disabled) {
69+
return;
70+
}
5471
contextService.selectedIndex.value =
5572
contextService.tabsMap[uniqueId]?.index || 0;
5673

@@ -69,6 +86,8 @@ export const Tab = component$((props: TabProps) => {
6986
data-tab-id={uniqueId}
7087
type="button"
7188
role="tab"
89+
disabled={props.disabled}
90+
aria-disabled={props.disabled}
7291
onFocus$={selectIfAutomatic$}
7392
onMouseEnter$={selectIfAutomatic$}
7493
aria-selected={isSelectedSignal.value}
@@ -85,6 +104,12 @@ export const Tab = component$((props: TabProps) => {
85104
props.onClick();
86105
}
87106
}}
107+
onKeyDown$={(e) => {
108+
contextService.onTabKeyDown$(
109+
e.key as KeyCode,
110+
(e.target as any).getAttribute('data-tab-id')
111+
);
112+
}}
88113
>
89114
<Slot />
90115
</button>

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

Lines changed: 225 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,51 @@ import { Tabs } from './tabs';
44
import { TabList } from './tabs-list';
55
import { TabPanel } from './tabs-panel';
66

7-
const ThreeTabsComponent = component$(() => {
8-
return (
9-
<Tabs data-testid="tabs">
10-
<TabList>
11-
<Tab>Tab 1</Tab>
12-
<Tab>Tab 2</Tab>
13-
<Tab>Tab 3</Tab>
14-
</TabList>
7+
interface ThreeTabsCompProps {
8+
isMiddleDisabled?: boolean;
9+
showDisableButton?: boolean;
10+
disabledIndex?: number;
11+
}
1512

16-
<TabPanel>Panel 1</TabPanel>
17-
<TabPanel>Panel 2</TabPanel>
18-
<TabPanel>Panel 3</TabPanel>
19-
</Tabs>
20-
);
21-
});
13+
const ThreeTabsComponent = component$(
14+
({
15+
isMiddleDisabled = false,
16+
showDisableButton = false,
17+
disabledIndex,
18+
}: ThreeTabsCompProps) => {
19+
const isMiddleDisabledSignal = useSignal(isMiddleDisabled);
20+
21+
return (
22+
<>
23+
<Tabs data-testid="tabs">
24+
<TabList>
25+
<Tab disabled={disabledIndex === 0}>Tab 1</Tab>
26+
<Tab disabled={disabledIndex === 1 || isMiddleDisabledSignal.value}>
27+
Tab 2
28+
</Tab>
29+
<Tab disabled={disabledIndex === 2}>Tab 3</Tab>
30+
</TabList>
31+
32+
<TabPanel>Panel 1</TabPanel>
33+
<TabPanel>Panel 2</TabPanel>
34+
<TabPanel>Panel 3</TabPanel>
35+
</Tabs>
36+
37+
<br />
38+
39+
{showDisableButton && (
40+
<button
41+
onClick$={() =>
42+
(isMiddleDisabledSignal.value = !isMiddleDisabledSignal.value)
43+
}
44+
>
45+
Toggle middle tab disabled
46+
</button>
47+
)}
48+
</>
49+
);
50+
}
51+
);
2252

2353
interface DynamicTabsProps {
2454
tabIndexToDelete?: number;
@@ -132,78 +162,208 @@ describe('Tabs', () => {
132162
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 2 Panel');
133163
});
134164

135-
it(`GIVEN 3 tabs,
136-
WHEN removing the last one dynamically
137-
THEN only 2 should remain`, () => {
138-
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
165+
describe('Dynamic Tabs', () => {
166+
it(`GIVEN 3 tabs,
167+
WHEN removing the last one dynamically
168+
THEN only 2 should remain`, () => {
169+
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
139170

140-
cy.findByRole('button', { name: /remove tab/i }).click();
171+
cy.findByRole('button', { name: /remove tab/i }).click();
141172

142-
cy.findAllByRole('tab').should('have.length', 2);
143-
});
173+
cy.findAllByRole('tab').should('have.length', 2);
174+
});
144175

145-
it(`GIVEN 3 tabs
146-
WHEN clicking on the last one and then removing it
147-
THEN tab 2 should be shown`, () => {
148-
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
176+
it(`GIVEN 3 tabs
177+
WHEN clicking on the last one and then removing it
178+
THEN tab 2 should be shown`, () => {
179+
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
149180

150-
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
151-
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 3 Panel');
181+
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
182+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 3 Panel');
152183

153-
cy.findByRole('button', { name: /remove tab/i }).click();
184+
cy.findByRole('button', { name: /remove tab/i }).click();
154185

155-
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 2 Panel');
156-
});
186+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 2 Panel');
187+
});
157188

158-
it(`GIVEN 4 tabs
159-
WHEN clicking on the last one and then removing the 3rd
160-
THEN tab 4 should be shown`, () => {
161-
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToDelete={2} />);
162-
cy.findByRole('tab', { name: /Dynamic Tab 4/i }).click();
163-
cy.findByRole('button', { name: /remove tab/i }).click();
189+
it(`GIVEN 4 tabs
190+
WHEN clicking on the last one and then removing the 3rd
191+
THEN tab 4 should be shown`, () => {
192+
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToDelete={2} />);
193+
cy.findByRole('tab', { name: /Dynamic Tab 4/i }).click();
194+
cy.findByRole('button', { name: /remove tab/i }).click();
164195

165-
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 4 Panel');
166-
});
196+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 4 Panel');
197+
});
198+
199+
it(`GIVEN 4 tabs
200+
WHEN selecting the 3rd one and adding a tab at the start
201+
THEN the correct tab should be displayed`, () => {
202+
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToAdd={1} />);
203+
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
204+
cy.findByRole('button', { name: /add tab/i }).click();
205+
206+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 3 Panel');
207+
});
208+
209+
it(`GIVEN tabs inside of tabs
210+
WHEN clicking on the root second tab
211+
THEN it should show only the selected root panel`, () => {
212+
cy.mount(<TabsInsideOfTabs />);
213+
214+
cy.findAllByRole('tab', { name: /Tab 2/i }).first().click();
167215

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`, () => {
171-
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToAdd={1} />);
172-
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
173-
cy.findByRole('button', { name: /add tab/i }).click();
216+
cy.findByRole('tabpanel')
217+
.should('be.visible')
218+
.should('contain', 'Root Panel 2');
219+
});
174220

175-
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 3 Panel');
221+
it(`GIVEN tabs inside of tabs
222+
WHEN clicking on the child second tab
223+
THEN it should show only the selected child panel`, () => {
224+
cy.mount(<TabsInsideOfTabs />);
225+
226+
cy.findAllByRole('tab', { name: /Tab 2/i }).eq(1).click();
227+
228+
cy.findAllByRole('tabpanel').eq(1).should('contain', 'Child Panel 2');
229+
});
176230
});
177231

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 />);
232+
describe('Right key handling', () => {
233+
it(`GIVEN 3 tabs and the focus is on the first,
234+
WHEN triggering the right arrow key
235+
THEN the focus should be on the next tab`, () => {
236+
cy.mount(<ThreeTabsComponent />);
237+
238+
cy.findByRole('tab', { name: /Tab 1/i }).type('{rightarrow}');
239+
240+
cy.findByRole('tab', { name: /Tab 2/i }).should('have.focus');
241+
});
242+
243+
it(`GIVEN 3 tabs and the focus is on the last,
244+
WHEN triggering the right arrow key
245+
THEN the focus should be on the first tab`, () => {
246+
cy.mount(<ThreeTabsComponent />);
247+
248+
cy.findByRole('tab', { name: /Tab 3/i }).type('{rightarrow}');
249+
250+
cy.findByRole('tab', { name: /Tab 1/i }).should('have.focus');
251+
});
252+
253+
it(`GIVEN 3 tabs and the second is disabled and the focus is on the first,
254+
WHEN triggering the right arrow key
255+
THEN the focus should be on the third tab`, () => {
256+
cy.mount(<ThreeTabsComponent isMiddleDisabled={true} />);
257+
258+
cy.findByRole('tab', { name: /Tab 1/i }).type('{rightarrow}');
182259

183-
cy.findAllByRole('tab', { name: /Tab 2/i }).first().click();
260+
cy.findByRole('tab', { name: /Tab 3/i }).should('have.focus');
261+
});
184262

185-
cy.findByRole('tabpanel')
186-
.should('be.visible')
187-
.should('contain', 'Root Panel 2');
263+
it(`GIVEN 3 tabs and the focus is on the first,
264+
WHEN disabling the middle dynamically and triggering the right arrow key
265+
THEN the focus should be on the third tab`, () => {
266+
cy.mount(<ThreeTabsComponent showDisableButton={true} />);
267+
268+
cy.findByRole('button', { name: 'Toggle middle tab disabled' }).click();
269+
270+
cy.findByRole('tab', { name: /Tab 1/i }).type('{rightarrow}');
271+
272+
cy.findByRole('tab', { name: /Tab 3/i }).should('have.focus');
273+
});
188274
});
189275

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 />);
276+
describe('Left key handling', () => {
277+
it(`GIVEN 3 tabs and the focus is on the second,
278+
WHEN triggering the left arrow key
279+
THEN the focus should be on the first tab`, () => {
280+
cy.mount(<ThreeTabsComponent />);
281+
282+
cy.findByRole('tab', { name: /Tab 2/i }).type('{leftarrow}');
283+
284+
cy.findByRole('tab', { name: /Tab 1/i }).should('have.focus');
285+
});
286+
287+
it(`GIVEN 3 tabs and the focus is on the first,
288+
WHEN triggering the left arrow key
289+
THEN the focus should be on the last tab`, () => {
290+
cy.mount(<ThreeTabsComponent />);
291+
292+
cy.findByRole('tab', { name: /Tab 1/i }).type('{leftarrow}');
293+
294+
cy.findByRole('tab', { name: /Tab 3/i }).should('have.focus');
295+
});
194296

195-
cy.findAllByRole('tab', { name: /Tab 2/i }).eq(1).click();
297+
it(`GIVEN 3 tabs and the second is disabled and the focus is on the third,
298+
WHEN triggering the left arrow key
299+
THEN the focus should be on the first tab`, () => {
300+
cy.mount(<ThreeTabsComponent isMiddleDisabled={true} />);
196301

197-
cy.findAllByRole('tabpanel').eq(1).should('contain', 'Child Panel 2');
302+
cy.findByRole('tab', { name: /Tab 3/i }).type('{leftarrow}');
303+
304+
cy.findByRole('tab', { name: /Tab 1/i }).should('have.focus');
305+
});
198306
});
199307

200-
it(`GIVEN 3 tabs and the focus is on the first,
201-
WHEN triggering the right arrow key
202-
THEN the focus should be on the next tab`, () => {
203-
cy.mount(<ThreeTabsComponent />);
308+
describe('Home, End, PageUp and PageDown keys handling', () => {
309+
it(`GIVEN 3 tabs and the focus is on the third,
310+
WHEN triggering the 'home' key
311+
THEN the focus should be on the first tab`, () => {
312+
cy.mount(<ThreeTabsComponent />);
313+
314+
cy.findByRole('tab', { name: /Tab 3/i }).type('{home}');
315+
316+
cy.findByRole('tab', { name: /Tab 1/i }).should('have.focus');
317+
});
318+
319+
it(`GIVEN 3 tabs and the first is disabled and the focus is on the third,
320+
WHEN triggering the 'home' key
321+
THEN the focus should be on the second tab`, () => {
322+
cy.mount(<ThreeTabsComponent disabledIndex={0} />);
323+
324+
cy.findByRole('tab', { name: /Tab 3/i }).type('{home}');
325+
326+
cy.findByRole('tab', { name: /Tab 2/i }).should('have.focus');
327+
});
328+
329+
it(`GIVEN 3 tabs and the focus is on the third,
330+
WHEN triggering the 'home' key
331+
THEN the focus should be on the first tab`, () => {
332+
cy.mount(<ThreeTabsComponent />);
333+
334+
cy.findByRole('tab', { name: /Tab 3/i }).type('{pageUp}');
335+
336+
cy.findByRole('tab', { name: /Tab 1/i }).should('have.focus');
337+
});
338+
339+
it(`GIVEN 3 tabs and the focus is on the third,
340+
WHEN triggering the 'end' key
341+
THEN the focus should be on the first tab`, () => {
342+
cy.mount(<ThreeTabsComponent />);
343+
344+
cy.findByRole('tab', { name: /Tab 1/i }).type('{end}');
345+
346+
cy.findByRole('tab', { name: /Tab 3/i }).should('have.focus');
347+
});
348+
349+
it(`GIVEN 3 tabs and the first is disabled and the focus is on the third,
350+
WHEN triggering the 'end' key
351+
THEN the focus should be on the second tab`, () => {
352+
cy.mount(<ThreeTabsComponent disabledIndex={2} />);
353+
354+
cy.findByRole('tab', { name: /Tab 1/i }).type('{end}');
355+
356+
cy.findByRole('tab', { name: /Tab 2/i }).should('have.focus');
357+
});
358+
359+
it(`GIVEN 3 tabs and the focus is on the third,
360+
WHEN triggering the 'end' key
361+
THEN the focus should be on the first tab`, () => {
362+
cy.mount(<ThreeTabsComponent />);
204363

205-
cy.findByRole('tab', { name: /Tab 1/i }).type('{rightarrow}');
364+
cy.findByRole('tab', { name: /Tab 1/i }).type('{pageDown}');
206365

207-
cy.findByRole('tab', { name: /Tab 2/i }).should('have.focus');
366+
cy.findByRole('tab', { name: /Tab 3/i }).should('have.focus');
367+
});
208368
});
209369
});

0 commit comments

Comments
 (0)