Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/community/components/tabs/tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const meta: Meta<typeof Tabs> = {
component: Tabs,
title: 'Community/Tabs',
subcomponents: { TabsItem } as never,
parameters: {
status: {
type: ['deprecated', 'ExistsInTediReady'],
},
},
};

export default meta;
Expand Down
6 changes: 6 additions & 0 deletions src/tedi/components/navigation/tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './tabs';
export * from './tabs-context';
export * from './tabs-list/tabs-list';
export * from './tabs-trigger/tabs-trigger';
export * from './tabs-content/tabs-content';
export * from './tabs-dropdown/tabs-dropdown';
47 changes: 47 additions & 0 deletions src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import cn from 'classnames';
import React from 'react';

import styles from '../tabs.module.scss';
import { useTabsContext } from '../tabs-context';

export interface TabsContentProps {
/**
* Unique identifier matching the corresponding TabsTrigger id.
* When provided, content is only shown when this tab is active.
* When omitted, content is always rendered (useful for router outlets).
*/
id?: string;
/**
* Tab panel content
*/
children: React.ReactNode;
/**
* Additional class name(s)
*/
className?: string;
}

export const TabsContent = (props: TabsContentProps) => {
const { id, children, className } = props;
const { currentTab } = useTabsContext();

if (id && currentTab !== id) {
return null;
}

return (
<div
data-name="tabs-content"
id={id ? `${id}-panel` : undefined}
role="tabpanel"
aria-labelledby={id ?? undefined}
className={cn(styles['tedi-tabs__content'], className)}
>
{children}
</div>
);
};

TabsContent.displayName = 'TabsContent';

export default TabsContent;
16 changes: 16 additions & 0 deletions src/tedi/components/navigation/tabs/tabs-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, { useContext } from 'react';

export type TabsContextValue = {
currentTab: string;
setCurrentTab: (id: string) => void;
};

export const TabsContext = React.createContext<TabsContextValue | null>(null);

export const useTabsContext = () => {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error('Tabs components must be used within <Tabs />');
}
return ctx;
};
125 changes: 125 additions & 0 deletions src/tedi/components/navigation/tabs/tabs-dropdown/tabs-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import cn from 'classnames';
import React from 'react';

import { Icon } from '../../../base/icon/icon';
import { Dropdown } from '../../../overlays/dropdown/dropdown';
import styles from '../tabs.module.scss';
import { useTabsContext } from '../tabs-context';
import { navigateTablist } from '../tabs-helpers';

export interface TabsDropdownItemProps {
/**
* Unique identifier matching the corresponding TabsContent id
*/
id: string;
/**
* Item label
*/
children: React.ReactNode;
/**
* Whether the item is disabled
* @default false
*/
disabled?: boolean;
}

const TabsDropdownItem = (props: TabsDropdownItemProps) => {
const { id, children } = props;
return <span data-tab-id={id}>{children}</span>;
};

TabsDropdownItem.displayName = 'TabsDropdownItem';

export interface TabsDropdownProps {
/**
* Dropdown label displayed on the trigger
*/
label: string;
/**
* TabsDropdown.Item elements
*/
children: React.ReactNode;
/**
* Whether the dropdown trigger is disabled
* @default false
*/
disabled?: boolean;
/**
* Additional class name(s)
*/
className?: string;
}

export const TabsDropdown = (props: TabsDropdownProps) => {
const { label, children, disabled = false, className } = props;
const { currentTab, setCurrentTab } = useTabsContext();

const [open, setOpen] = React.useState(false);

const childArray = React.Children.toArray(children).filter(React.isValidElement);
const childIds = childArray.map((child) => (child.props as TabsDropdownItemProps).id);
const isSelected = childIds.includes(currentTab);

const selectedChild = childArray.find((child) => (child.props as TabsDropdownItemProps).id === currentTab);
const displayLabel = selectedChild ? (selectedChild.props as TabsDropdownItemProps).children : label;

const handleSelect = (id: string) => {
setCurrentTab(id);
setOpen(false);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
const target = navigateTablist(e);
if (target) {
setOpen(false);
setCurrentTab(target.id);
}
};

return (
<Dropdown open={open} onOpenChange={setOpen} placement="bottom-start">
<Dropdown.Trigger>
<button
data-name="tabs-dropdown"
role="tab"
type="button"
disabled={disabled}
aria-selected={isSelected}
aria-controls={isSelected ? `${currentTab}-panel` : undefined}
tabIndex={isSelected ? 0 : -1}
className={cn(
styles['tedi-tabs__trigger'],
{ [styles['tedi-tabs__trigger--selected']]: isSelected },
{ [styles['tedi-tabs__trigger--disabled']]: disabled },
className
)}
onKeyDown={handleKeyDown}
>
{displayLabel}
<Icon name={open ? 'expand_less' : 'expand_more'} size={18} color="inherit" />
</button>
</Dropdown.Trigger>
<Dropdown.Content>
{childArray.map((child, index) => {
const itemProps = child.props as TabsDropdownItemProps;
return (
<Dropdown.Item
key={itemProps.id}
index={index}
active={currentTab === itemProps.id}
disabled={itemProps.disabled}
onClick={() => handleSelect(itemProps.id)}
>
{itemProps.children}
</Dropdown.Item>
);
})}
</Dropdown.Content>
</Dropdown>
);
};

TabsDropdown.displayName = 'TabsDropdown';
TabsDropdown.Item = TabsDropdownItem;

export default TabsDropdown;
41 changes: 41 additions & 0 deletions src/tedi/components/navigation/tabs/tabs-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Navigates to a sibling tab in the tablist using ArrowLeft/ArrowRight/Home/End keys.
* Returns the target tab element if navigation occurred, or null otherwise.
*/
export const navigateTablist = (e: React.KeyboardEvent<HTMLButtonElement>): HTMLButtonElement | null => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {
return null;
}

const tablist = e.currentTarget.closest('[role="tablist"]');
if (!tablist) return null;

const tabs = Array.from(tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])')).filter(
(tab) => getComputedStyle(tab).display !== 'none'
);
const currentIndex = tabs.indexOf(e.currentTarget);
let newIndex = -1;

switch (e.key) {
case 'ArrowLeft':
newIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
break;
case 'ArrowRight':
newIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
}

if (newIndex !== -1) {
e.preventDefault();
tabs[newIndex].focus();
return tabs[newIndex];
}

return null;
};
135 changes: 135 additions & 0 deletions src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import cn from 'classnames';
import React from 'react';

import { isBreakpointBelow, useBreakpoint } from '../../../../helpers';
import { useLabels } from '../../../../providers/label-provider';
import { Icon } from '../../../base/icon/icon';
import Print, { PrintProps } from '../../../misc/print/print';
import { Dropdown } from '../../../overlays/dropdown/dropdown';
import styles from '../tabs.module.scss';
import { useTabsContext } from '../tabs-context';
import { TabsDropdown } from '../tabs-dropdown/tabs-dropdown';
import { TabsDropdownItemProps } from '../tabs-dropdown/tabs-dropdown';

export interface TabsListProps {
/**
* Tab trigger elements
*/
children: React.ReactNode;
/**
* Additional class name(s)
*/
className?: string;
/**
* Accessible label for the tablist
*/
'aria-label'?: string;
/**
* ID of element labelling the tablist
*/
'aria-labelledby'?: string;
/**
* Controls visibility when printing
* @default 'show'
*/
printVisibility?: PrintProps['visibility'];
}

interface MobileDropdownItem {
id: string;
label: React.ReactNode;
disabled?: boolean;
}

export const TabsList = (props: TabsListProps) => {
const {
children,
className,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
printVisibility = 'show',
} = props;

const { getLabel } = useLabels();
const { currentTab, setCurrentTab } = useTabsContext();

const breakpoint = useBreakpoint();
const isMobile = isBreakpointBelow(breakpoint, 'md');

const childArray = React.useMemo(() => {
return React.Children.toArray(children).filter(React.isValidElement);
}, [children]);

// Flatten all children (including TabsDropdown items) for mobile dropdown
const mobileItems = React.useMemo(() => {
const result: MobileDropdownItem[] = [];
childArray.forEach((child) => {
if ((child.type as { displayName?: string }).displayName === TabsDropdown.displayName) {
const dropdownProps = child.props as { children: React.ReactNode };
const items = React.Children.toArray(dropdownProps.children).filter(React.isValidElement);
items.forEach((item) => {
const itemProps = item.props as TabsDropdownItemProps;
result.push({ id: itemProps.id, label: itemProps.children, disabled: itemProps.disabled });
});
} else {
const triggerProps = child.props as { id: string; children: React.ReactNode; disabled?: boolean };
result.push({ id: triggerProps.id, label: triggerProps.children, disabled: triggerProps.disabled });
}
});
return result;
}, [childArray]);

const showMore = isMobile && mobileItems.length > 1;

// Filter out the currently selected tab from the mobile dropdown
const dropdownItems = mobileItems.filter((item) => item.id !== currentTab);

const handleMobileSelect = (id: string) => {
if (id) {
setCurrentTab(id);
}
};

return (
<Print visibility={printVisibility}>
<div
data-name="tabs-list"
role="tablist"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(styles['tedi-tabs__list'], className)}
>
{children}
{showMore && (
<div className={styles['tedi-tabs__more-wrapper']}>
<Dropdown placement="bottom-end">
<Dropdown.Trigger>
<button data-name="tabs-more-btn" type="button" className={styles['tedi-tabs__more-btn']}>
{getLabel('tabs.more')}
<Icon name="expand_more" size={18} />
</button>
</Dropdown.Trigger>
<Dropdown.Content>
{dropdownItems.map((item, index) => (
<Dropdown.Item
key={item.id}
index={index}
active={currentTab === item.id}
disabled={item.disabled}
onClick={() => handleMobileSelect(item.id)}
>
{item.label}
</Dropdown.Item>
))}
</Dropdown.Content>
</Dropdown>
</div>
)}
</div>
</Print>
);
};

TabsList.displayName = 'TabsList';

export default TabsList;
Loading
Loading