Skip to content

Commit 975db81

Browse files
authored
Merge pull request #325 from shairez/pr-tabs-ready
2 parents 69913ae + a68fa21 commit 975db81

File tree

12 files changed

+225
-28
lines changed

12 files changed

+225
-28
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@storybook/jest": "0.1.0",
4646
"@storybook/test-runner": "0.10.0",
4747
"@storybook/testing-library": "0.1.0",
48+
"@testing-library/cypress": "9.0.0",
4849
"@types/eslint": "8.37.0",
4950
"@types/node": "18.16.1",
5051
"@types/testing-library__jest-dom": "5.14.5",

packages/kit-headless/cypress/support/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
// https://on.cypress.io/custom-commands
1010
// ***********************************************
1111

12+
import '@testing-library/cypress/add-commands';
13+
1214
// eslint-disable-next-line @typescript-eslint/no-namespace
1315
declare namespace Cypress {
1416
// eslint-disable-next-line @typescript-eslint/no-unused-vars

packages/kit-headless/cypress/tsconfig.cy.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"compilerOptions": {
44
"outDir": "../../../dist/out-tsc",
55
"module": "commonjs",
6-
"types": ["cypress", "node"]
6+
"types": ["cypress", "@testing-library/cypress", "node"]
77
},
88
"include": [
99
"support/**/*.ts",

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { mount } from 'cypress-ct-qwik';
21
import { Accordion, AccordionItem } from './accordion';
32

43
describe('Accordion', () => {
54
it('should render an Accordion', () => {
6-
mount(
5+
cy.mount(
76
<Accordion class="accordion">
87
<AccordionItem label="Heading 1">
98
<p>

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export const Tab = component$((props: TabProps) => {
5252
const selectTab$ = $(() => {
5353
contextService.selectedIndex.value =
5454
contextService.tabsMap[uniqueId]?.index || 0;
55+
56+
contextService.selectTab$(uniqueId);
5557
});
5658

5759
const selectIfAutomatic$ = $(() => {
@@ -72,7 +74,9 @@ export const Tab = component$((props: TabProps) => {
7274
tabIndex={isSelectedSignal.value ? 0 : -1}
7375
aria-controls={'tabpanel-' + matchedTabPanelId.value}
7476
class={`${
75-
isSelectedSignal.value ? `selected ${props.selectedClassName}` : ''
77+
isSelectedSignal.value
78+
? `selected ${props.selectedClassName || ''}`
79+
: ''
7680
}${props.class ? ` ${props.class}` : ''}`}
7781
onClick$={() => {
7882
selectTab$();

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ export const TabPanel = component$(({ ...props }: TabPanelProps) => {
4141
id={'tabpanel-' + panelUID}
4242
role="tabpanel"
4343
tabIndex={0}
44-
hidden={isSelectedSignal.value ? undefined : true}
44+
hidden={isSelectedSignal.value ? (null as unknown as undefined) : true}
4545
aria-labelledby={`tab-${matchedTabId.value}`}
46-
class={`${isSelectedSignal.value ? 'is-hidden' : ''}${
46+
class={`${isSelectedSignal.value ? '' : 'is-hidden'}${
4747
props.class ? ` ${props.class}` : ''
4848
}`}
4949
style={isSelectedSignal.value ? 'display: block' : 'display: none'}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { component$, useStore } from '@builder.io/qwik';
2+
import { Tab } from './tab';
3+
import { Tabs } from './tabs';
4+
import { TabList } from './tabs-list';
5+
import { TabPanel } from './tabs-panel';
6+
7+
interface DynamicTabsProps {
8+
tabIndexToDelete?: number;
9+
tabIndexToAdd?: number;
10+
tabsLength: number;
11+
selectedIndex?: number;
12+
}
13+
14+
const DynamicTabsComponent = component$(
15+
({
16+
tabIndexToDelete = 0,
17+
tabIndexToAdd = 0,
18+
tabsLength,
19+
selectedIndex = 0,
20+
}: DynamicTabsProps) => {
21+
const tabNames = Array(tabsLength)
22+
.fill(1)
23+
.map((_, index) => `Dynamic Tab ${index + 1}`);
24+
25+
const tabsState = useStore(tabNames);
26+
27+
return (
28+
<>
29+
<Tabs selectedIndex={selectedIndex}>
30+
<TabList>
31+
{tabsState.map((tab) => (
32+
<Tab key={tab}>{tab}</Tab>
33+
))}
34+
</TabList>
35+
{tabsState.map((tab) => (
36+
<TabPanel key={tab}>{tab} Panel</TabPanel>
37+
))}
38+
</Tabs>
39+
<button onClick$={() => tabsState.splice(tabIndexToDelete, 1)}>
40+
Remove Tab
41+
</button>
42+
<button
43+
onClick$={() => tabsState.splice(tabIndexToAdd, 0, 'new added tab')}
44+
>
45+
Add Tab
46+
</button>
47+
</>
48+
);
49+
}
50+
);
51+
52+
describe('Tabs', () => {
53+
it('should render the component and check if its accessible', () => {
54+
cy.mount(
55+
<Tabs>
56+
<TabList>
57+
<Tab>Tab 1</Tab>
58+
<Tab>Tab 2</Tab>
59+
<Tab>Tab 3</Tab>
60+
</TabList>
61+
62+
<TabPanel>Panel 1</TabPanel>
63+
<TabPanel>Panel 2</TabPanel>
64+
<TabPanel>Panel 3</TabPanel>
65+
</Tabs>
66+
);
67+
68+
cy.findByRole('tab', { name: /Tab 2/i }).click();
69+
70+
cy.findByRole('tabpanel').should('contain', 'Panel 2');
71+
72+
cy.checkA11yForComponent();
73+
});
74+
75+
it('Given 3 tabs, when removing the last one dynamically, only 2 should remain', () => {
76+
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
77+
78+
cy.findByRole('button', { name: /remove tab/i }).click();
79+
80+
cy.findAllByRole('tab').should('have.length', 2);
81+
});
82+
83+
it('Given 3 tabs, when clicking on the last one and then removing it, tab 2 should be shown', () => {
84+
cy.mount(<DynamicTabsComponent tabsLength={3} tabIndexToDelete={2} />);
85+
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
86+
cy.findByRole('button', { name: /remove tab/i }).click();
87+
88+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 2 Panel');
89+
});
90+
91+
it('Given 4 tabs, when clicking on the last one and then removing the 3rd, tab 4 should be shown', () => {
92+
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToDelete={2} />);
93+
cy.findByRole('tab', { name: /Dynamic Tab 4/i }).click();
94+
cy.findByRole('button', { name: /remove tab/i }).click();
95+
96+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 4 Panel');
97+
});
98+
99+
it('Given 4 tabs, when selecting the 3rd one and adding a tab at the start, the correct tab should be displayed', () => {
100+
cy.mount(<DynamicTabsComponent tabsLength={4} tabIndexToAdd={1} />);
101+
cy.findByRole('tab', { name: /Dynamic Tab 3/i }).click();
102+
cy.findByRole('button', { name: /add tab/i }).click();
103+
104+
cy.findByRole('tabpanel').should('contain', 'Dynamic Tab 3 Panel');
105+
});
106+
});

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { Meta, StoryObj } from 'storybook-framework-qwik';
2+
import {
3+
useSignal,
4+
useStore,
5+
component$,
6+
useComputed$,
7+
} from '@builder.io/qwik';
28
import { Tab, TabList, TabPanel, Tabs, TabsProps } from './';
3-
import { userEvent, within, screen } from '@storybook/testing-library';
9+
import { userEvent, within, screen, waitFor } from '@storybook/testing-library';
410
import { expect } from '@storybook/jest';
511

612
const meta: Meta<TabsProps> = {
@@ -45,8 +51,49 @@ export const Primary: Story = {
4551
screen.debug(canvasElement);
4652
await userEvent.click(secondTab);
4753

54+
screen.debug(canvasElement);
4855
const activeTabPanel = await canvas.findByRole('tabpanel');
4956

5057
await expect(activeTabPanel).toHaveTextContent('Panel 2');
5158
},
5259
};
60+
61+
// const DynamicTabsComponent = component$(() => {
62+
// const tabsState = useStore(['Dynamic Tab 1', 'Dynamic Tab 2', 'Dynamic Tab 3']);
63+
64+
// return (
65+
// <>
66+
// <Tabs>
67+
// <TabList>
68+
// {tabsState.map((tab) => (
69+
// <Tab key={tab}>{tab}</Tab>
70+
// ))
71+
// }
72+
// </TabList>
73+
// {tabsState.map((tab) => (
74+
// <TabPanel key={tab}>{tab} Panel</TabPanel>
75+
// ))}
76+
// </Tabs>
77+
// <button onClick$={()=> tabsState.splice(0,1)}>Remove Tab</button>
78+
// </>
79+
// )
80+
// })
81+
82+
// export const DynamicTabsRemoveFirst: Story = {
83+
// render: () => <DynamicTabsComponent/>,
84+
// play: async ({ canvasElement }) => {
85+
// const canvas = within(canvasElement);
86+
87+
// const removeButton = await canvas.findByRole('button', { name: /remove tab/i });
88+
89+
// await userEvent.click(removeButton);
90+
91+
// const activeTabPanel = await canvas.findByRole('tabpanel', {name: /.*2 Panel/i});
92+
93+
// const remainingTabs = await canvas.findAllByRole('tab');
94+
95+
// await expect(activeTabPanel).toHaveTextContent(/.*2 Panel/i);
96+
// await expect(remainingTabs).toHaveLength(2);
97+
// },
98+
99+
// };

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

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,14 @@ import { Behavior } from './behavior.type';
1515
* TABS TODOs
1616
*
1717
* - CHANGE THE querySelector to "scoped" queries
18-
*
1918
* - selectedIndex / default
20-
* - Orientation
19+
* - expose selectedIndex in the root
20+
* - Orientation
2121
* - aria-label https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby
2222
* - NOTE: Radix manually handle the value/id for each tab while we calculate it behind the scenes
2323
* If we end up implementing this, we need to expose a way to set this value in the root
2424
* - keyboard interactions (arrowDown, ARrowRight, ArrowUp, ArrowLeft, Home, End, PageUp, PageDown)
2525
* Support Loop
26-
* - expose selectedIndex in the root
2726
* - onValueChange
2827
* POST V1:
2928
* - RTL
@@ -49,7 +48,6 @@ export interface TabsProps {
4948
selectedIndex?: number;
5049
}
5150

52-
export type TabIndexMap = [string, string][];
5351
export interface TabPair {
5452
tabId: string;
5553
tabPanelId: string;
@@ -67,6 +65,7 @@ export const Tabs = component$((props: TabsProps) => {
6765
const selectedTabId = useSignal<string>('');
6866
const reIndexTabs = useSignal(false);
6967
const showTabsSignal = useSignal(false);
68+
const tabPairs = useStore<TabPair[]>([]);
7069

7170
const tabsMap = useStore<{ [key: string]: TabInfo }>({});
7271

@@ -118,6 +117,7 @@ export const Tabs = component$((props: TabsProps) => {
118117
reIndexTabs.value = false;
119118

120119
if (ref.value) {
120+
// TODO: Write a failing test for nested tabs to prove this querySelector should be scoped
121121
const tabElements = ref.value.querySelectorAll(
122122
'[role="tablist"] > [role="tab"]'
123123
);
@@ -132,23 +132,39 @@ export const Tabs = component$((props: TabsProps) => {
132132

133133
const tabPanelElements = ref.value.querySelectorAll('[role="tabpanel"]');
134134

135-
// let lastSelectedTabId = undefined;
135+
// See if the deleted index was the last one
136+
let previousSelectedTabWasLastOne = false;
137+
if (selectedIndex.value === tabPairs.length - 1) {
138+
previousSelectedTabWasLastOne = true;
139+
}
140+
141+
tabPairs.length = 0;
142+
tabsMap;
136143

137144
tabElements.forEach((tab, index) => {
138145
const tabId = tab.getAttribute('data-tab-id');
139146

140-
// const tabForId = tab.getAttribute('data-for');
141-
142147
if (!tabId) {
143148
throw new Error('Missing tab id for tab: ' + index);
144149
}
145150

151+
// clear all lists and maps
152+
let tabWasDeleted = true;
153+
// TODO: delete object maps, or turn into Map()
154+
155+
if (tabId === selectedTabId.value) {
156+
selectedIndex.value = index;
157+
tabWasDeleted = false;
158+
}
159+
146160
const tabPanelElement = tabPanelElements[index];
147161
if (!tabPanelElement) {
148162
throw new Error('Missing tab panel for tab: ' + index);
149163
}
150164
const tabPanelId = tabPanelElement.getAttribute('data-tabpanel-id');
151165
if (tabId && tabPanelId) {
166+
tabPairs.push({ tabId, tabPanelId });
167+
152168
tabsMap[tabId] = {
153169
tabId,
154170
tabPanelId,
@@ -164,19 +180,13 @@ export const Tabs = component$((props: TabsProps) => {
164180
throw new Error('Missing tab id or tab panel id for tab: ' + index);
165181
}
166182

167-
// if (indexByTabId[tabForId] === selectedIndex.value) {
168-
// lastSelectedTabId = tabForId;
169-
// }
170-
171-
// indexByTabId[tabForId] = index;
183+
if (tabPairs.length > 0) {
184+
if (previousSelectedTabWasLastOne && tabWasDeleted) {
185+
selectedIndex.value = tabPairs.length - 1;
186+
}
187+
selectedTabId.value = tabPairs[selectedIndex.value].tabId;
188+
}
172189
});
173-
174-
// Update selected index
175-
// if (lastSelectedTabId) {
176-
// selectedIndex.value = indexByTabId[lastSelectedTabId];
177-
// } else {
178-
// selectedIndex.value = 0;
179-
// }
180190
}
181191
});
182192

packages/kit-headless/tsconfig.editor.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
"extends": "./tsconfig.json",
33
"include": ["**/*.ts", "**/*.tsx"],
44
"compilerOptions": {
5-
"types": ["node", "jest", "@testing-library/jest-dom"]
5+
"types": [
6+
"node",
7+
"jest",
8+
"@testing-library/jest-dom",
9+
"@testing-library/cypress"
10+
]
611
}
712
}

0 commit comments

Comments
 (0)