Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 .changeset/every-cows-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@drivenets/design-system': minor
---

Add `DsTabs` component
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.content {
flex: 1;
width: 100%;

&[hidden] {
display: none;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Tabs } from '@ark-ui/react/tabs';
import classNames from 'classnames';
import type { DsTabsContentProps } from './ds-tabs-content.types';
import styles from './ds-tabs-content.module.scss';

export const DsTabsContent = ({ value, className, style, children }: DsTabsContentProps) => {
return (
<Tabs.Content value={value} className={classNames(styles.content, className)} style={style}>
{children}
</Tabs.Content>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { CSSProperties, ReactNode } from 'react';

/**
* Props for the DsTabsContent component
* Container for the content associated with each tab
* @interface DsTabsContentProps
*/
export interface DsTabsContentProps {
/**
* Tab value this content is associated with
* Content is shown when this value matches the selected tab
* @type {string}
*/
value: string;

/**
* Additional CSS class name
* @type {string}
*/
className?: string;

/**
* Inline styles
* @type {CSSProperties}
*/
style?: CSSProperties;

/**
* Content to display when this tab is active
* @type {ReactNode}
*/
children: ReactNode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DsTabsContent } from './ds-tabs-content';
export type { DsTabsContentProps } from './ds-tabs-content.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Styles for tab dropdown have been moved to ds-tabs.module.scss
// to prevent global scope pollution and use CSS modules properly
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type React from 'react';
import { useState, useEffect, useRef } from 'react';
import classNames from 'classnames';
import { DsIcon } from '../../../ds-icon';
import { DsDropdownMenu } from '../../../ds-dropdown-menu';
import type { DsTabsDropdownProps } from './ds-tabs-dropdown.types';
import styles from '../../ds-tabs.module.scss';
import { DROPDOWN_CLOSE_DELAY } from '../ds-tabs-tab/ds-tabs-tab';

export const DsTabsDropdown: React.FC<DsTabsDropdownProps> = ({
trigger,
items,
onItemSelect,
isOverflowDropdown = false,
}) => {
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);

const handleMouseEnter = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setDropdownOpen(true);
};

const handleMouseLeave = () => {
closeTimeoutRef.current = setTimeout(() => {
setDropdownOpen(false);
closeTimeoutRef.current = null;
}, DROPDOWN_CLOSE_DELAY);
};

useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);

return (
<div ref={wrapperRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<DsDropdownMenu.Root
open={dropdownOpen}
onOpenChange={setDropdownOpen}
positioning={{ placement: 'bottom-start', gutter: 4 }}
>
<DsDropdownMenu.Trigger asChild>{trigger}</DsDropdownMenu.Trigger>
<DsDropdownMenu.Content>
{items.map((item) => (
<DsDropdownMenu.Item
key={item.value}
value={item.value}
disabled={item.disabled}
className={classNames({
[styles.dropdownMenuItem]: isOverflowDropdown,
})}
onSelect={() => {
onItemSelect(item.value);
setDropdownOpen(false);
// Remove focus from trigger button after selection
requestAnimationFrame(() => {
const button = wrapperRef.current?.querySelector('button');
button?.blur();
});
}}
>
{item.icon && (
<DsIcon
icon={item.icon}
size="small"
className={classNames({
[styles.dropdownMenuIcon]: isOverflowDropdown,
})}
/>
)}
<span
className={classNames({
[styles.dropdownMenuLabel]: isOverflowDropdown,
})}
>
{item.label || item.value}
</span>
{item.badge !== undefined && (
<span
className={classNames({
[styles.dropdownMenuBadge]: isOverflowDropdown,
})}
style={!isOverflowDropdown ? { marginLeft: 'auto', opacity: 0.6 } : undefined}
>
{isOverflowDropdown ? item.badge : `(${String(item.badge)})`}
</span>
)}
</DsDropdownMenu.Item>
))}
</DsDropdownMenu.Content>
</DsDropdownMenu.Root>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ReactNode } from 'react';
import type { IconType } from '../../../ds-icon';

/**
* Item in a tab dropdown menu
* @interface DsTabsDropdownItem
*/
export interface DsTabsDropdownItem {
/**
* Unique value for the dropdown item
* @type {string}
*/
value: string;

/**
* Display label for the item
* @type {string}
*/
label?: string;

/**
* Icon to display before the label
* @type {IconType}
*/
icon?: IconType;

/**
* Badge content (number or text) to display
* @type {number | string}
*/
badge?: number | string;

/**
* Whether the item is disabled
* @type {boolean}
*/
disabled?: boolean;
}

/**
* Props for the DsTabsDropdown component
* @interface DsTabsDropdownProps
*/
export interface DsTabsDropdownProps {
/**
* Element that triggers the dropdown (e.g., a button or tab)
* @type {ReactNode}
*/
trigger: ReactNode;

/**
* Array of dropdown menu items
* @type {DsTabsDropdownItem[]}
*/
items: DsTabsDropdownItem[];

/**
* Callback fired when a dropdown item is selected
* @param {string} value - The value of the selected item
*/
onItemSelect: (value: string) => void;

/**
* Whether this dropdown is used for overflow tabs (applies specific styling)
* @type {boolean}
* @default false
*/
isOverflowDropdown?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DsTabsDropdown } from './ds-tabs-dropdown';
export type { DsTabsDropdownProps, DsTabsDropdownItem } from './ds-tabs-dropdown.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.indicator {
position: absolute;
background: var(--color-background-selected);
transition:
left 0.3s ease,
top 0.3s ease,
width 0.3s ease,
height 0.3s ease;
border-radius: 4px;
pointer-events: none;
}

.indicator-horizontal {
height: 2px;
bottom: 0;
}

.indicator-vertical {
width: 2px;
right: 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useState, useEffect, useRef, type CSSProperties } from 'react';
import classNames from 'classnames';
import { useTabsContext } from '../../context/ds-tabs-context';
import type { DsTabsIndicatorProps } from './ds-tabs-indicator.types';
import styles from './ds-tabs-indicator.module.scss';

export const DsTabsIndicator = ({ className, style }: DsTabsIndicatorProps) => {
const { orientation, currentValue, size } = useTabsContext();
const [indicatorStyle, setIndicatorStyle] = useState<CSSProperties>({});
const indicatorRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const updateIndicator = () => {
const tabsList = indicatorRef.current?.parentElement;
if (!tabsList) {
return;
}

const selectedTrigger = tabsList.querySelector('button[aria-selected="true"]');

if (selectedTrigger) {
const tabsListRect = tabsList.getBoundingClientRect();
const triggerRect = selectedTrigger.getBoundingClientRect();

if (orientation === 'horizontal') {
const left = triggerRect.left - tabsListRect.left;
const width = triggerRect.width;
setIndicatorStyle({
left: `${left.toString()}px`,
width: `${width.toString()}px`,
transform: 'none',
});
} else {
const top = triggerRect.top - tabsListRect.top;
const height = triggerRect.height;
setIndicatorStyle({
top: `${top.toString()}px`,
height: `${height.toString()}px`,
transform: 'none',
});
}
}
};

updateIndicator();

const tabsList = indicatorRef.current?.parentElement;
if (!tabsList) {
return;
}

const observer = new MutationObserver(updateIndicator);
observer.observe(tabsList, {
attributes: true,
attributeFilter: ['aria-selected'],
childList: true,
characterData: true,
subtree: true,
});

const resizeObserver = new ResizeObserver(updateIndicator);
resizeObserver.observe(tabsList);

const frameId = requestAnimationFrame(updateIndicator);

return () => {
observer.disconnect();
resizeObserver.disconnect();
cancelAnimationFrame(frameId);
};
}, [orientation, currentValue, size]);

return (
<div
ref={indicatorRef}
className={classNames(styles.indicator, styles[`indicator-${orientation}`], className)}
style={{ ...indicatorStyle, ...style }}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { CSSProperties } from 'react';

/**
* Props for the DsTabsIndicator component
* Visual indicator that highlights the currently selected tab
* @interface DsTabsIndicatorProps
*/
export interface DsTabsIndicatorProps {
/**
* Additional CSS class name
* @type {string}
*/
className?: string;

/**
* Inline styles (note: position and size are calculated automatically)
* @type {CSSProperties}
*/
style?: CSSProperties;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DsTabsIndicator } from './ds-tabs-indicator';
export type { DsTabsIndicatorProps } from './ds-tabs-indicator.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@use '../../../../styles/typography';

.tabList {
display: flex;
position: relative;
}

.tabList-horizontal {
flex-direction: row;
gap: var(--spacing-xs);
align-items: center;
}

.tabList-vertical {
flex-direction: column;
gap: var(--spacing-3xs);
align-items: flex-start;
}
Loading