Skip to content

Commit e13d808

Browse files
committed
chore: big refactor
- tabs now select by tabId - info contains disabled - common logic moved out - ...
1 parent 049297a commit e13d808

File tree

6 files changed

+370
-353
lines changed

6 files changed

+370
-353
lines changed

packages/kit-headless/.eslintrc.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
{
2+
"rules": {
3+
"@typescript-eslint/no-unused-vars": [
4+
"error",
5+
// allow unused vars starting with _
6+
{
7+
"argsIgnorePattern": "^_",
8+
"varsIgnorePattern": "^_"
9+
}
10+
]
11+
},
212
"extends": [
313
"../../.eslintrc.json",
414
"eslint:recommended",
Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
12
import {
23
QwikIntrinsicElements,
34
Signal,
@@ -10,44 +11,48 @@ import { TAB_ID_PREFIX } from './tab';
1011
import { tabsContextId } from './tabs-context-id';
1112

1213
export type TabPanelProps = {
13-
label?: string;
14-
/** @deprecated Internal use only */
15-
_index?: number;
14+
/** Optional tab contents. */
15+
label?: QwikIntrinsicElements['div']['children'];
16+
1617
/** @deprecated Internal use only */
1718
_tabId?: string;
19+
/** @deprecated Internal use only */
20+
_extraClass?: QwikIntrinsicElements['div']['class'];
1821
} & QwikIntrinsicElements['div'];
1922

2023
export const TAB_PANEL_ID_PREFIX = '_tabpanel_';
2124

22-
export const TabPanel = component$(({ _index, _tabId, ...props }: TabPanelProps) => {
23-
const contextService = useContext(tabsContextId);
24-
25-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
26-
const tabId = _tabId!;
25+
export const TabPanel = component$(
26+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
27+
({ label, _tabId, _extraClass, ...props }: TabPanelProps) => {
28+
const contextService = useContext(tabsContextId);
2729

28-
const fullPanelElementId = contextService.tabsPrefix + TAB_PANEL_ID_PREFIX + tabId;
29-
const fullTabElementId = contextService.tabsPrefix + TAB_ID_PREFIX + tabId;
30+
const fullPanelElementId = contextService.tabsPrefix + TAB_PANEL_ID_PREFIX + _tabId!;
31+
const fullTabElementId = contextService.tabsPrefix + TAB_ID_PREFIX + _tabId!;
3032

31-
const isSelectedSig = useComputed$(() => {
32-
return contextService.selectedIndexSig.value === _index;
33-
});
33+
const isSelectedSig = useComputed$(() => {
34+
return contextService.selectedTabIdSig.value === _tabId;
35+
});
3436

35-
return (
36-
<div
37-
data-tabpanel-id={fullPanelElementId}
38-
id={fullPanelElementId}
39-
role="tabpanel"
40-
tabIndex={0}
41-
hidden={isSelectedSig.value ? (null as unknown as undefined) : true}
42-
aria-labelledby={fullTabElementId}
43-
class={[
44-
(props.class as Signal<string>)?.value ?? (props.class as string),
45-
isSelectedSig.value && 'is-hidden'
46-
]}
47-
// TODO require to do this via CSS in non-headless wrappers
48-
style={isSelectedSig.value ? 'display: block' : 'display: none'}
49-
>
50-
<Slot />
51-
</div>
52-
);
53-
});
37+
return (
38+
<div
39+
{...props}
40+
data-tabpanel-id={fullPanelElementId}
41+
id={fullPanelElementId}
42+
role="tabpanel"
43+
tabIndex={0}
44+
aria-labelledby={fullTabElementId}
45+
class={[
46+
(props.class as Signal<string>)?.value ?? (props.class as string),
47+
(_extraClass as Signal<string>)?.value ?? (_extraClass as string),
48+
// TODO hiddenClass
49+
isSelectedSig.value && 'is-hidden'
50+
]}
51+
// We need to use null so a previous hidden attribute is removed.
52+
hidden={isSelectedSig.value ? (null as unknown as undefined) : true}
53+
>
54+
<Slot />
55+
</div>
56+
);
57+
}
58+
);
Lines changed: 57 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
12
import {
23
$,
34
Slot,
45
component$,
56
useComputed$,
67
useContext,
78
useSignal,
8-
useTask$,
99
useVisibleTask$,
1010
type QwikIntrinsicElements,
11-
type QwikMouseEvent,
1211
type Signal
1312
} from '@builder.io/qwik';
1413
import { KeyCode } from '../../utils/key-code.type';
@@ -18,14 +17,13 @@ import { tabsContextId } from './tabs-context-id';
1817
export const TAB_ID_PREFIX = '_tab_';
1918

2019
export type TabProps = {
21-
onClick$?: (event: QwikMouseEvent) => void;
20+
disabled?: boolean;
2221
selectedClassName?: string;
2322

24-
disabled?: boolean;
2523
/** @deprecated Internal use only */
26-
_tabId?: string;
24+
_extraClass?: QwikIntrinsicElements['div']['class'];
2725
/** @deprecated Internal use only */
28-
_index?: number;
26+
_tabId?: string;
2927
} & QwikIntrinsicElements['button'];
3028

3129
export const preventedKeys = [
@@ -39,72 +37,65 @@ export const preventedKeys = [
3937
KeyCode.ArrowRight
4038
];
4139

42-
export const Tab = component$((props: TabProps) => {
43-
const contextService = useContext(tabsContextId);
44-
45-
const elementRefSig = useSignal<HTMLElement | undefined>();
40+
export const Tab = component$(
41+
({ selectedClassName, _extraClass, _tabId, ...props }: TabProps) => {
42+
const contextService = useContext(tabsContextId);
4643

47-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
48-
const tabId = props._tabId!;
49-
const fullTabElementId = contextService.tabsPrefix + TAB_ID_PREFIX + tabId;
50-
const fullPanelElementId = contextService.tabsPrefix + TAB_PANEL_ID_PREFIX + tabId;
44+
const elementRefSig = useSignal<HTMLElement | undefined>();
5145

52-
const selectedClassNameSig = useComputed$(() => {
53-
return props.selectedClassName || contextService.selectedClassName;
54-
});
46+
const fullTabElementId = contextService.tabsPrefix + TAB_ID_PREFIX + _tabId!;
47+
const fullPanelElementId = contextService.tabsPrefix + TAB_PANEL_ID_PREFIX + _tabId!;
5548

56-
const isSelectedSig = useComputed$(() => {
57-
return contextService.selectedIndexSig.value === props._index;
58-
});
49+
const selectedClassNameSig = useComputed$(() => {
50+
return selectedClassName || contextService.selectedClassName;
51+
});
5952

60-
useTask$(function disabledTask({ track }) {
61-
contextService.setTabDisabledStatus$(tabId, !!track(() => props.disabled));
62-
});
53+
const isSelectedSig = useComputed$(() => {
54+
return contextService.selectedTabIdSig.value === _tabId;
55+
});
6356

64-
useVisibleTask$(function preventDefaultOnKeysVisibleTask({ cleanup }) {
65-
function handler(event: KeyboardEvent) {
66-
if (preventedKeys.includes(event.key as KeyCode)) {
67-
event.preventDefault();
57+
useVisibleTask$(function preventDefaultOnKeysVisibleTask({ cleanup }) {
58+
function handler(event: KeyboardEvent) {
59+
if (preventedKeys.includes(event.key as KeyCode)) {
60+
event.preventDefault();
61+
}
62+
contextService.onTabKeyDown$(event.key as KeyCode, _tabId!);
6863
}
69-
contextService.onTabKeyDown$(event.key as KeyCode, tabId);
70-
}
71-
elementRefSig.value?.addEventListener('keydown', handler);
72-
cleanup(() => {
73-
elementRefSig.value?.removeEventListener('keydown', handler);
64+
// TODO put the listener on TabList
65+
elementRefSig.value?.addEventListener('keydown', handler);
66+
cleanup(() => {
67+
elementRefSig.value?.removeEventListener('keydown', handler);
68+
});
7469
});
75-
});
7670

77-
const selectIfAutomatic$ = $(() => {
78-
contextService.selectIfAutomatic$(tabId);
79-
});
71+
const selectIfAutomatic$ = $(() => {
72+
contextService.selectIfAutomatic$(_tabId!);
73+
});
8074

81-
return (
82-
<button
83-
type="button"
84-
role="tab"
85-
id={fullTabElementId}
86-
data-tab-id={fullTabElementId}
87-
ref={elementRefSig}
88-
disabled={props.disabled}
89-
aria-disabled={props.disabled}
90-
onFocus$={selectIfAutomatic$}
91-
onMouseEnter$={selectIfAutomatic$}
92-
aria-selected={isSelectedSig.value}
93-
tabIndex={isSelectedSig.value ? 0 : -1}
94-
aria-controls={fullPanelElementId}
95-
style={props.style}
96-
class={[
97-
(props.class as Signal<string>)?.value ?? (props.class as string),
98-
isSelectedSig.value && ['selected', selectedClassNameSig.value]
99-
]}
100-
onClick$={async (event) => {
101-
await contextService.selectTab$(tabId);
102-
if (props.onClick$) {
103-
await props.onClick$(event);
104-
}
105-
}}
106-
>
107-
<Slot />
108-
</button>
109-
);
110-
});
75+
return (
76+
<button
77+
{...props}
78+
type="button"
79+
role="tab"
80+
id={fullTabElementId}
81+
data-tab-id={fullTabElementId}
82+
ref={elementRefSig}
83+
aria-disabled={props.disabled}
84+
onFocus$={[selectIfAutomatic$, props.onFocus$]}
85+
onMouseEnter$={[selectIfAutomatic$, props.onMouseEnter$]}
86+
aria-selected={isSelectedSig.value}
87+
tabIndex={isSelectedSig.value ? 0 : -1}
88+
aria-controls={fullPanelElementId}
89+
class={[
90+
(props.class as Signal<string>)?.value ?? (props.class as string),
91+
(_extraClass as Signal<string>)?.value ?? (_extraClass as string),
92+
// TODO only given class if selected
93+
isSelectedSig.value && ['selected', selectedClassNameSig.value]
94+
]}
95+
onClick$={[$(() => contextService.selectTab$(_tabId!)), props.onClick$]}
96+
>
97+
<Slot />
98+
</button>
99+
);
100+
}
101+
);

packages/kit-headless/src/components/tabs/tabs-context.type.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import type { KeyCode } from '../../utils/key-code.type';
33

44
export interface TabsContext {
55
selectTab$: QRL<(tabId: string) => void>;
6-
setTabDisabledStatus$: QRL<(tabId: string, disabled: boolean) => void>;
76
onTabKeyDown$: QRL<(key: KeyCode, tabId: string) => void>;
87
selectIfAutomatic$: QRL<(tabId: string) => void>;
9-
selectedIndexSig: Signal<number>;
8+
selectedTabIdSig: Signal<string | undefined>;
109
tabsPrefix: string;
1110
selectedClassName?: string;
1211
}

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

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,19 @@ describe('Tabs', () => {
8484
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={0} />);
8585

8686
cy.findByRole('tab', { name: /Tab 3/i }).click();
87-
cy.findByTestId('selected-index-from-event').should('contain.text', 2);
87+
cy.findByTestId('selected-index-from-event').should('contain.text', ': 2');
8888

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

9191
cy.findAllByRole('tab').should('have.length', 2);
9292

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

95-
cy.findByTestId('selected-index-from-event').should('contain.text', 0);
95+
cy.findByTestId('selected-index-from-event').should('contain.text', ': 0');
96+
cy.findByTestId('selected-tab-id-from-event').should(
97+
'contain.text',
98+
': Dynamic Tab 2'
99+
);
96100
});
97101

98102
it(`GIVEN 3 tabs
@@ -142,10 +146,14 @@ describe('Tabs', () => {
142146

143147
const tabsState = useStore(tabNames);
144148
const selectedIndexSig = useSignal(0);
149+
const selectedTabIdSig = useSignal<string | undefined>();
145150

146151
return (
147152
<>
148-
<Tabs bind:selectedIndex={selectedIndexSig}>
153+
<Tabs
154+
bind:selectedIndex={selectedIndexSig}
155+
bind:selectedTabId={selectedTabIdSig}
156+
>
149157
<TabList>
150158
{tabsState.map((tab) => (
151159
<Tab key={tab}>{tab}</Tab>
@@ -163,7 +171,12 @@ describe('Tabs', () => {
163171
</button>
164172
{selectedIndexSig.value !== undefined && (
165173
<div data-testid="selected-index-from-event">
166-
Selected index from event: {selectedIndexSig.value}
174+
Selected index: {selectedIndexSig.value}
175+
</div>
176+
)}
177+
{selectedTabIdSig.value !== undefined && (
178+
<div data-testid="selected-tab-id-from-event">
179+
Selected tab id: {selectedTabIdSig.value}
167180
</div>
168181
)}
169182
</>
@@ -487,9 +500,7 @@ describe('Tabs', () => {
487500

488501
cy.findByRole('tabpanel').should('contain', 'Panel 2');
489502

490-
cy.findByRole('tab', { name: /Tab 1/ })
491-
.debug()
492-
.should('not.have.class', 'selected');
503+
cy.findByRole('tab', { name: /Tab 1/ }).should('not.have.class', 'selected');
493504
});
494505

495506
it(`GIVEN 5 tabs with tab 3 selected and tabs 3-5 are disabled
@@ -680,25 +691,21 @@ describe('Tabs', () => {
680691
});
681692

682693
describe('Shorthand API', () => {
683-
it(`GIVEN 3 tabs written using only panels
694+
it(`GIVEN 3 tabs written using shorthand
684695
WHEN clicking the middle one
685696
THEN render the middle panel`, () => {
686697
cy.mount(
687698
<Tabs>
688-
<TabPanel label="Tab 1">Panel 1</TabPanel>
689-
<TabPanel label="Tab 2">Panel 2</TabPanel>
690-
<TabPanel label="Tab 3">Panel 3</TabPanel>
691-
{/* <TabList>
692-
<Tab>Tab 1</Tab>
693-
<Tab>Tab 2</Tab>
694-
<Tab>Tab 3</Tab>
695-
</TabList>
699+
<Tab>Tab 1</Tab>
696700
<TabPanel>Panel 1</TabPanel>
697-
<TabPanel>Panel 2</TabPanel>
698-
<TabPanel>Panel 3</TabPanel> */}
701+
<TabPanel label="Tab 2">Panel 2</TabPanel>
702+
<TabPanel>Panel 3</TabPanel>
703+
<Tab>Tab 3</Tab>
699704
</Tabs>
700705
);
701706

707+
cy.get('[role="tab"]').should('have.length', 3);
708+
702709
cy.findByRole('tab', { name: /Tab 2/i }).click();
703710

704711
cy.findByRole('tabpanel').should('contain', 'Panel 2');

0 commit comments

Comments
 (0)