Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions src/components/ui/Tabs/fragments/TabContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ import { clsx } from 'clsx';

const COMPONENT_NAME = 'TabContent';

export type TabContentProps = {
className?: string;
type TabContentElement = React.ElementRef<'div'>;
export type TabContentProps = React.ComponentPropsWithoutRef<'div'> & {
customRootClass?: string;
value?: string;
children?: React.ReactNode;
asChild?: boolean;
forceMount?: boolean;
}
};

const TabContent = ({ className = '', value, children, customRootClass, asChild = false, forceMount = false }: TabContentProps) => {
const TabContent = React.forwardRef<TabContentElement, TabContentProps>(({ className = '', value, children, customRootClass, asChild = false, forceMount = false, ...props }, ref) => {
const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME);
const context = useContext(TabsRootContext);
if (!context) throw new Error('TabContent must be used within a TabRoot');
Expand All @@ -32,15 +31,20 @@ const TabContent = ({ className = '', value, children, customRootClass, asChild
dataAttributes['data-state'] = isActive ? 'active' : 'inactive';
dataAttributes['data-orientation'] = orientation || 'horizontal';

return <div
className={clsx(rootClass, className)}
role="tabpanel"
aria-hidden={!isActive}
{...dataAttributes}
>
{children}
</div>;
};
return (
<div
ref={ref}
className={clsx(rootClass, className)}
{...props}
role="tabpanel"
aria-hidden={!isActive}
hidden={forceMount && !isActive}
{...dataAttributes}
>
{children}
</div>
);
});

TabContent.displayName = COMPONENT_NAME;

Expand Down
38 changes: 25 additions & 13 deletions src/components/ui/Tabs/fragments/TabList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,39 @@ import RovingFocusGroup from '~/core/utils/RovingFocusGroup';

const COMPONENT_NAME = 'TabList';

export type TabListProps = {
className?: string;
type TabListElement = React.ElementRef<'div'>;
export type TabListProps = React.ComponentPropsWithoutRef<'div'> & {
children?: React.ReactNode;
asChild?: boolean;
}
};

const TabList = ({ className = '', children, asChild = false }: TabListProps) => {
const TabList = React.forwardRef<TabListElement, TabListProps>(({ className = '', children, asChild = false, ...props }, ref) => {
const context = useContext(TabsRootContext);
if (!context) throw new Error('TabList must be used within a TabRoot');

const { rootClass, orientation } = context;

return <RovingFocusGroup.Group
role="tablist"
aria-orientation={orientation}
aria-label="todo"
className={clsx(`${rootClass}-list`, className)}
>
{children}
</RovingFocusGroup.Group>;
};
const { ['aria-label']: ariaLabel, ...restProps } = props;

const childProps = {
ref,
className: clsx(`${rootClass}-list`, className),
...restProps,
role: 'tablist',
'aria-orientation': orientation,
...(ariaLabel ? { 'aria-label': ariaLabel } : {})
} as const;

const child =
asChild && React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<any>, {
...childProps,
className: clsx((children as React.ReactElement<any>).props.className, childProps.className)
})
: <div {...childProps}>{children}</div>;

return <RovingFocusGroup.Group>{child}</RovingFocusGroup.Group>;
});

TabList.displayName = COMPONENT_NAME;

Expand Down
16 changes: 8 additions & 8 deletions src/components/ui/Tabs/fragments/TabRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,20 @@ import useControllableState from '~/core/hooks/useControllableState';

const COMPONENT_NAME = 'Tabs';

export type TabRootProps = {
children: React.ReactNode;
type TabRootElement = React.ElementRef<typeof RovingFocusGroup.Root>;
type RovingRootProps = Omit<React.ComponentPropsWithoutRef<typeof RovingFocusGroup.Root>, 'orientation'>;
export type TabRootProps = RovingRootProps & {
orientation?: 'horizontal' | 'vertical';
customRootClass?: string;
className?: string;
value?: string;
color?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
orientation?: 'horizontal' | 'vertical';
dir?: 'ltr' | 'rtl';
activationMode?: 'automatic' | 'manual';
asChild?: boolean;
};

const TabRoot = ({
const TabRoot = React.forwardRef<TabRootElement, TabRootProps>(({
children,
defaultValue = '',
onValueChange = () => {},
Expand All @@ -37,7 +36,7 @@ const TabRoot = ({
activationMode = 'automatic',
asChild = false,
...props
}: TabRootProps) => {
}, ref) => {
const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME);

const [tabValue, setTabValue] = useControllableState<string>(
Expand Down Expand Up @@ -76,6 +75,7 @@ const TabRoot = ({
return (
<TabsRootContext.Provider value={contextValues}>
<RovingFocusGroup.Root
ref={ref}
orientation={orientation}
loop
dir={dir}
Expand All @@ -88,7 +88,7 @@ const TabRoot = ({
</RovingFocusGroup.Root>
</TabsRootContext.Provider>
);
};
});

TabRoot.displayName = COMPONENT_NAME;

Expand Down
34 changes: 23 additions & 11 deletions src/components/ui/Tabs/fragments/TabTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
'use client';
import React, { useContext, useRef } from 'react';
import React, { useContext, useRef, useImperativeHandle } from 'react';
import { clsx } from 'clsx';

import TabsRootContext from '../context/TabsRootContext';

import RovingFocusGroup from '~/core/utils/RovingFocusGroup';

export type TabTriggerProps = {
className?: string;
type TabTriggerElement = React.ElementRef<'button'> | null;
export type TabTriggerProps = React.ComponentPropsWithoutRef<'button'> & {
value?: string;
children?: React.ReactNode;
disabled?: boolean;
asChild?: boolean;
}
};

const TabTrigger = ({ value, children, className = '', disabled, asChild = false, ...props }: TabTriggerProps) => {
const TabTrigger = React.forwardRef<TabTriggerElement, TabTriggerProps>(
({
value,
children,
className = '',
disabled,
asChild = false,
onClick,
...props
}, forwardedRef) => {
// use context
const context = useContext(TabsRootContext);
if (!context) throw new Error('TabTrigger must be used within a TabRoot');
const { tabValue: activeValue, handleTabChange, rootClass, orientation, activationMode } = context;

const ref = useRef<HTMLButtonElement>(null);
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(forwardedRef, () => ref.current);

const isActive = value === activeValue;

const handleFocus = (tabValue: string) => {
if (disabled) return; // Don't handle focus events when disabled

if (ref.current) {
ref.current?.focus();
ref.current.focus();
}

// Only change tab on focus if activation mode is automatic
Expand Down Expand Up @@ -59,25 +67,29 @@ const TabTrigger = ({ value, children, className = '', disabled, asChild = false
>
<button
ref={ref}
onClick={handleClick}
onClick={(e) => {
onClick?.(e);
handleClick(e);
}}
className={clsx(
`${rootClass}-trigger`,
isActive ? 'active' : '',
disabled ? 'disabled' : '',
className
)}
{...props}
type="button"
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
disabled={disabled}
{...dataAttributes}
{...props}
>
{children}
</button>
</RovingFocusGroup.Item>
);
};
});

TabTrigger.displayName = 'TabTrigger';

Expand Down
96 changes: 95 additions & 1 deletion src/components/ui/Tabs/tests/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,9 +424,10 @@ describe('Tabs Component', () => {
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.getByText('Content 2')).toBeInTheDocument();

// Tab 2 content should have aria-hidden="true"
// Tab 2 content should have aria-hidden="true" and be hidden
const tab2Content = screen.getByText('Content 2');
expect(tab2Content).toHaveAttribute('aria-hidden', 'true');
expect(tab2Content).toHaveAttribute('hidden');
});

test('data attributes are set correctly', () => {
Expand Down Expand Up @@ -480,6 +481,22 @@ describe('Tabs Component', () => {
expect(screen.getByText('Tab 2')).toBeInTheDocument();
});

test('allows custom aria-label on TabList', () => {
render(
<Tabs.Root defaultValue="tab1">
<Tabs.List aria-label="Custom Tabs">
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</Tabs.Root>
);

const tablist = screen.getByRole('tablist');
expect(tablist).toHaveAttribute('aria-label', 'Custom Tabs');
});

test('asChild prop is supported on TabList', () => {
render(
<Tabs.Root defaultValue="tab1">
Expand All @@ -500,4 +517,81 @@ describe('Tabs Component', () => {
expect(screen.getByText('Tab 2')).toBeInTheDocument();
});
});

describe('Ref forwarding', () => {
test('forwards refs to DOM elements', () => {
const rootRef = React.createRef<HTMLDivElement>();
const listRef = React.createRef<HTMLDivElement>();
const triggerRef = React.createRef<HTMLButtonElement>();
const contentRef = React.createRef<HTMLDivElement>();

render(
<Tabs.Root defaultValue="tab1" ref={rootRef}>
<Tabs.List ref={listRef}>
<Tabs.Trigger ref={triggerRef} value="tab1">Tab 1</Tabs.Trigger>
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
</Tabs.List>
<Tabs.Content ref={contentRef} value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</Tabs.Root>
);

expect(rootRef.current).toBeInstanceOf(HTMLDivElement);
expect(listRef.current).toBeInstanceOf(HTMLDivElement);
expect(triggerRef.current).toBeInstanceOf(HTMLButtonElement);
expect(contentRef.current).toBeInstanceOf(HTMLDivElement);
});
});

describe('Accessibility', () => {
test('aria-hidden toggles with active tab', () => {
render(
<Tabs.Root defaultValue="tab1">
<Tabs.List>
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
</Tabs.List>
<Tabs.Content forceMount value="tab1">Content 1</Tabs.Content>
<Tabs.Content forceMount value="tab2">Content 2</Tabs.Content>
</Tabs.Root>
);

const content1 = screen.getByText('Content 1');
const content2 = screen.getByText('Content 2');
expect(content1).toHaveAttribute('aria-hidden', 'false');
expect(content1).not.toHaveAttribute('hidden');
expect(content2).toHaveAttribute('aria-hidden', 'true');
expect(content2).toHaveAttribute('hidden');

fireEvent.click(screen.getByText('Tab 2'));

expect(content1).toHaveAttribute('aria-hidden', 'true');
expect(content1).toHaveAttribute('hidden');
expect(content2).toHaveAttribute('aria-hidden', 'false');
expect(content2).not.toHaveAttribute('hidden');
});
});

describe('Warnings', () => {
test('renders without console warnings', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

render(
<Tabs.Root defaultValue="tab1">
<Tabs.List>
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</Tabs.Root>
);

expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
errorSpy.mockRestore();
});
});
});
11 changes: 7 additions & 4 deletions src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef } from 'react';

import Primitive from '~/core/primitives/Primitive';

Expand Down Expand Up @@ -37,7 +37,7 @@ type RovingFocusRootProps = {
* </RovingFocusGroup>
* </RovingFocusRoot>
*/
const RovingFocusRoot = ({
const RovingFocusRoot = forwardRef<HTMLDivElement, RovingFocusRootProps>(({
children,
orientation = 'horizontal',
loop = true,
Expand All @@ -47,7 +47,7 @@ const RovingFocusRoot = ({
mode = 'default',
dir = 'ltr',
...props
}: RovingFocusRootProps) => {
}, ref) => {
const sendValues = {
orientation,
loop,
Expand All @@ -58,6 +58,7 @@ const RovingFocusRoot = ({

return <RovingFocusRootContext.Provider value={sendValues}>
<Primitive.div
ref={ref}
aria-orientation={orientation === 'both' ? undefined : orientation}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
Expand All @@ -66,6 +67,8 @@ const RovingFocusRoot = ({
{children}
</Primitive.div>
</RovingFocusRootContext.Provider>;
};
});

RovingFocusRoot.displayName = 'RovingFocusRoot';

export default RovingFocusRoot;
Loading