Skip to content

Commit 4276d94

Browse files
authored
chore(Tabs): rework component (#2991)
Related to #2944 and #2449 Fixes issues found in #2355
1 parent a42f6f7 commit 4276d94

File tree

7 files changed

+136
-79
lines changed

7 files changed

+136
-79
lines changed

.changeset/red-gorillas-argue.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@digdir/designsystemet-react": patch
3+
---
4+
5+
Tabs: Content will get focus when it has no focusable elements

.changeset/unlucky-hairs-sit.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@digdir/designsystemet-css": patch
3+
---
4+
5+
Tabs: Rework component CSS

packages/css/src/tabs.css

+71-63
Original file line numberDiff line numberDiff line change
@@ -5,80 +5,88 @@
55
--dsc-tabs-content-padding: var(--ds-size-5);
66
--dsc-tabs-content-color: var(--ds-color-neutral-text-default);
77
--dsc-tabs-list-border-color: var(--ds-color-neutral-border-subtle);
8-
}
98

10-
.ds-tabs__panel {
11-
padding: var(--dsc-tabs-content-padding);
12-
color: var(--dsc-tabs-content-color);
13-
}
9+
& > [role='tabpanel'] {
10+
padding: var(--dsc-tabs-content-padding);
11+
color: var(--dsc-tabs-content-color);
1412

15-
.ds-tabs__tablist {
16-
display: flex;
17-
flex-direction: row;
18-
border-bottom: var(--ds-border-width-default) solid var(--dsc-tabs-list-border-color);
19-
position: relative;
20-
}
13+
@composes ds-focus from './base.css';
14+
}
2115

22-
.ds-tabs__tab {
23-
align-items: center;
24-
background: none;
25-
border: 0;
26-
box-sizing: border-box;
27-
color: var(--dsc-tabs-tab-color);
28-
cursor: pointer;
29-
display: flex;
30-
flex-direction: row;
31-
font-family: inherit;
32-
font-size: inherit;
33-
gap: var(--ds-size-1);
34-
justify-content: center;
35-
line-height: var(--ds-line-height-sm);
36-
margin: 0;
37-
padding: var(--dsc-tabs-tab-padding);
38-
position: relative;
39-
text-align: center;
16+
& > [role='tablist'] {
17+
flex-direction: row;
18+
border-bottom: var(--ds-border-width-default) solid var(--dsc-tabs-list-border-color);
19+
position: relative;
4020

41-
&:not([data-size]) {
42-
font-size: inherit; /* Ensure inheriting font-size when <button> */
43-
}
21+
&:not([hidden]) {
22+
display: flex;
23+
}
4424

45-
& :where(img, svg) {
46-
flex-shrink: 0; /* Never shrink icon */
47-
font-size: 1.25em; /* Auto scale icon based on font-size */
48-
}
25+
& > button {
26+
align-items: center;
27+
background: none;
28+
border: 0;
29+
box-sizing: border-box;
30+
color: var(--dsc-tabs-tab-color);
31+
cursor: pointer;
32+
flex-direction: row;
33+
font-family: inherit;
34+
font-size: inherit;
35+
gap: var(--ds-size-1);
36+
justify-content: center;
37+
line-height: var(--ds-line-height-sm);
38+
margin: 0;
39+
padding: var(--dsc-tabs-tab-padding);
40+
position: relative;
41+
text-align: center;
4942

50-
&[aria-selected='true'] {
51-
--dsc-tabs-tab-bottom-border-color: var(--ds-color-base-default);
52-
--dsc-tabs-tab-color: var(--ds-color-text-subtle);
43+
&:not([hidden]) {
44+
display: flex;
45+
}
5346

54-
@media (forced-colors: active) {
55-
--dsc-tabs-tab-color: CanvasText;
56-
border-bottom: 2px solid CanvasText;
57-
}
58-
}
47+
&:not([data-size]) {
48+
font-size: inherit; /* Ensure inheriting font-size when <button> */
49+
}
5950

60-
@composes ds-focus from './base.css';
51+
& :where(img, svg) {
52+
flex-shrink: 0; /* Never shrink icon */
53+
font-size: 1.25em; /* Auto scale icon based on font-size */
54+
}
6155

62-
/* We set z-index to make sure the active line does not bleed over the focus indicator */
63-
&:focus-visible {
64-
z-index: 2;
65-
}
56+
&[aria-selected='true'] {
57+
--dsc-tabs-tab-bottom-border-color: var(--ds-color-base-default);
58+
--dsc-tabs-tab-color: var(--ds-color-text-subtle);
6659

67-
&::after {
68-
content: '';
69-
display: block;
70-
height: .15em; /* Scale with font */
71-
width: 100%;
72-
background-color: var(--dsc-tabs-tab-bottom-border-color);
73-
position: absolute;
74-
bottom: 0;
75-
left: 0;
76-
}
60+
@media (forced-colors: active) {
61+
--dsc-tabs-tab-color: CanvasText;
62+
border-bottom: 2px solid CanvasText;
63+
}
64+
}
65+
66+
@composes ds-focus from './base.css';
67+
68+
/* We set z-index to make sure the active line does not bleed over the focus indicator */
69+
&:focus-visible {
70+
z-index: 2;
71+
}
72+
73+
&::after {
74+
content: '';
75+
display: block;
76+
height: .15em; /* Scale with font */
77+
width: 100%;
78+
background-color: var(--dsc-tabs-tab-bottom-border-color);
79+
position: absolute;
80+
bottom: 0;
81+
left: 0;
82+
}
7783

78-
@media (hover: hover) and (pointer: fine) {
79-
&:hover:not([aria-selected='true']) {
80-
--dsc-tabs-tab-bottom-border-color: var(--ds-color-neutral-border-subtle);
81-
--dsc-tabs-tab-color: var(--ds-color-neutral-text-default);
84+
@media (hover: hover) and (pointer: fine) {
85+
&:hover:not([aria-selected='true']) {
86+
--dsc-tabs-tab-bottom-border-color: var(--ds-color-neutral-border-subtle);
87+
--dsc-tabs-tab-color: var(--ds-color-neutral-text-default);
88+
}
89+
}
8290
}
8391
}
8492
}

packages/react/src/components/Tabs/Tabs.test.tsx

+29-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Tabs } from '.';
66
const user = userEvent.setup();
77

88
describe('Tabs', () => {
9-
test('can navigate tabs with keyboard', async () => {
9+
it('can navigate tabs with keyboard', async () => {
1010
render(
1111
<Tabs>
1212
<Tabs.List>
@@ -28,7 +28,7 @@ describe('Tabs', () => {
2828
expect(tab1).toHaveFocus();
2929
});
3030

31-
test('renders content based on value', async () => {
31+
it('renders content based on value', async () => {
3232
render(
3333
<Tabs defaultValue='value1'>
3434
<Tabs.List>
@@ -47,7 +47,7 @@ describe('Tabs', () => {
4747
expect(screen.queryByText('content 1')).not.toBeInTheDocument();
4848
});
4949

50-
test('item renders with correct aria attributes', async () => {
50+
it('item renders with correct aria attributes', async () => {
5151
render(
5252
<Tabs defaultValue='value1'>
5353
<Tabs.List>
@@ -63,7 +63,7 @@ describe('Tabs', () => {
6363
expect(tab).toHaveAttribute('aria-selected', 'true');
6464
});
6565

66-
test('renders ReactNodes as children when TabsPanels value is selected', () => {
66+
it('renders ReactNodes as children when TabsPanels value is selected', () => {
6767
render(
6868
<Tabs defaultValue='value1'>
6969
<Tabs.Panel value='value1'>
@@ -76,7 +76,7 @@ describe('Tabs', () => {
7676
expect(content).toBeInTheDocument();
7777
});
7878

79-
test('can navigate tabs with keyboard', async () => {
79+
it('can navigate tabs with keyboard', async () => {
8080
render(
8181
<Tabs.List>
8282
<Tabs.Tab value='value1'>Tab 1</Tabs.Tab>
@@ -93,4 +93,28 @@ describe('Tabs', () => {
9393
await user.type(tab2, '{arrowleft}');
9494
expect(tab1).toHaveFocus();
9595
});
96+
97+
it('has tabindex 0 on tabpanel', () => {
98+
render(
99+
<Tabs defaultValue='value1'>
100+
<Tabs.Panel value='value1'>content 1</Tabs.Panel>
101+
</Tabs>,
102+
);
103+
104+
const panel = screen.getByRole('tabpanel');
105+
expect(panel).toHaveAttribute('tabindex', '0');
106+
});
107+
108+
it('has no tabindex when tabpanel has focusable element', () => {
109+
render(
110+
<Tabs defaultValue='value1'>
111+
<Tabs.Panel value='value1'>
112+
<input type='text' />
113+
</Tabs.Panel>
114+
</Tabs>,
115+
);
116+
117+
const panel = screen.getByRole('tabpanel');
118+
expect(panel).not.toHaveAttribute('tabindex', '0');
119+
});
96120
});

packages/react/src/components/Tabs/TabsList.tsx

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import cl from 'clsx/lite';
21
import type { HTMLAttributes } from 'react';
32
import { forwardRef, useContext } from 'react';
43

@@ -19,14 +18,13 @@ export type TabsListProps = HTMLAttributes<HTMLDivElement>;
1918
* ```
2019
*/
2120
export const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
22-
function TabsList({ children, className, ...rest }, ref) {
21+
function TabsList({ children, ...rest }, ref) {
2322
const { value } = useContext(Context);
2423

2524
return (
2625
<RovingFocusRoot
2726
role='tablist'
2827
activeValue={value}
29-
className={cl('ds-tabs__tablist', className)}
3028
orientation='ambiguous'
3129
ref={ref}
3230
{...rest}

packages/react/src/components/Tabs/TabsPanel.tsx

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import cl from 'clsx/lite';
21
import type { HTMLAttributes } from 'react';
3-
import { forwardRef, useContext } from 'react';
2+
import { forwardRef, useContext, useEffect, useRef, useState } from 'react';
43

4+
import { useMergeRefs } from '@floating-ui/react';
55
import { Context } from './Tabs';
66

77
export type TabsPanelProps = {
@@ -17,14 +17,33 @@ export type TabsPanelProps = {
1717
* ```
1818
*/
1919
export const TabsPanel = forwardRef<HTMLDivElement, TabsPanelProps>(
20-
function TabsPanel({ children, value, className, ...rest }, ref) {
20+
function TabsPanel({ children, value, ...rest }, ref) {
2121
const { value: tabsValue } = useContext(Context);
2222
const active = value === tabsValue;
2323

24+
const [hasTabbableElement, setHasTabbableElement] = useState(false);
25+
26+
const internalRef = useRef<HTMLDivElement>(null);
27+
const mergedRef = useMergeRefs([ref, internalRef]);
28+
29+
/* Check if the panel has any tabbable elements */
30+
useEffect(() => {
31+
if (!internalRef.current) return;
32+
const tabbableElements = internalRef.current.querySelectorAll(
33+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
34+
);
35+
setHasTabbableElement(tabbableElements.length > 0);
36+
}, [children]);
37+
2438
return (
2539
<>
2640
{active && (
27-
<div className={cl('ds-tabs__panel', className)} ref={ref} {...rest}>
41+
<div
42+
ref={mergedRef}
43+
role='tabpanel'
44+
tabIndex={hasTabbableElement ? undefined : 0}
45+
{...rest}
46+
>
2847
{children}
2948
</div>
3049
)}

packages/react/src/components/Tabs/TabsTab.tsx

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import cl from 'clsx/lite';
21
import type { HTMLAttributes } from 'react';
32
import { forwardRef, useContext, useId } from 'react';
43

@@ -16,16 +15,15 @@ export type TabsTabProps = {
1615
* <Tabs.Tab value='1'>Tab 1</Tabs.Tab>
1716
*/
1817
export const TabsTab = forwardRef<HTMLButtonElement, TabsTabProps>(
19-
function TabsTab({ className, value, ...rest }, ref) {
18+
function TabsTab({ value, id, ...rest }, ref) {
2019
const tabs = useContext(Context);
21-
const buttonId = `tab-${useId()}`;
20+
const buttonId = id ?? `tab-${useId()}`;
2221

2322
return (
2423
<RovingFocusItem value={value} {...rest} asChild>
2524
<button
2625
{...rest}
2726
aria-selected={tabs.value === value}
28-
className={cl('ds-tabs__tab', className)}
2927
id={buttonId}
3028
onClick={() => tabs.onChange?.(value)}
3129
ref={ref}

0 commit comments

Comments
 (0)