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
8 changes: 8 additions & 0 deletions jest-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ Object.defineProperty(window, 'matchMedia', {
dispatchEvent: jest.fn(),
})),
});

global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));

Element.prototype.scrollIntoView = jest.fn();
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
60 changes: 16 additions & 44 deletions src/tedi/components/misc/scroll-fade/scroll-fade.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cn from 'classnames';
import { forwardRef, useCallback, useState } from 'react';
import { forwardRef, useCallback } from 'react';

import { useScrollFade } from '../../../helpers';
import styles from './scroll-fade.module.scss';

export interface ScrollFadeProps {
Expand Down Expand Up @@ -47,61 +48,32 @@ export const ScrollFade = forwardRef<HTMLDivElement, ScrollFadeProps>((props, re
fadeSize = 20,
fadePosition = 'both',
} = props;
const [fade, setFade] = useState({ top: false, bottom: false });

const handleFade = useCallback(
(scrollTop: number, scrollHeight: number, clientHeight: number) => {
const atTop = scrollTop === 0;
const atBottom = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1;

let fadeTop = true;
let fadeBottom = true;

if (atTop) {
fadeTop = false;
onScrollToTop?.();
}

if (atBottom) {
fadeBottom = false;
onScrollToBottom?.();
}

setFade({ top: fadeTop, bottom: fadeBottom });
},
[onScrollToTop, onScrollToBottom]
);

const onScroll = useCallback(
(e: React.UIEvent<HTMLDivElement, UIEvent>) => {
const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLDivElement;
handleFade(scrollTop, scrollHeight, clientHeight);
},
[handleFade]
);
const { scrollRef, canScrollStart, canScrollEnd, handleScroll } = useScrollFade({
direction: 'vertical',
onScrollToStart: onScrollToTop,
onScrollToEnd: onScrollToBottom,
});

const callbackRef = useCallback(
const mergedRef = useCallback(
(node: HTMLDivElement | null) => {
scrollRef(node);
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}

if (node) {
handleFade(node.scrollTop, node.scrollHeight, node.clientHeight);
}
},
[handleFade, ref]
[scrollRef, ref]
);

const showStartFade = canScrollStart && (fadePosition === 'both' || fadePosition === 'top');
const showEndFade = canScrollEnd && (fadePosition === 'both' || fadePosition === 'bottom');

const ScrollFadeBEM = cn(
styles['tedi-scroll-fade'],
{ [styles[`tedi-scroll-fade--top-${fadeSize}`]]: fade.top && (fadePosition === 'both' || fadePosition === 'top') },
{
[styles[`tedi-scroll-fade--bottom-${fadeSize}`]]:
fade.bottom && (fadePosition === 'both' || fadePosition === 'bottom'),
},
{ [styles[`tedi-scroll-fade--top-${fadeSize}`]]: showStartFade },
{ [styles[`tedi-scroll-fade--bottom-${fadeSize}`]]: showEndFade },
className
);

Expand All @@ -111,7 +83,7 @@ export const ScrollFade = forwardRef<HTMLDivElement, ScrollFadeProps>((props, re

return (
<div data-name="scroll-fade" className={ScrollFadeBEM}>
<div ref={callbackRef} onScroll={onScroll} className={ScrollFadeInnerBEM} tabIndex={0}>
<div ref={mergedRef} onScroll={handleScroll} className={ScrollFadeInnerBEM} tabIndex={0}>
{children}
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/tedi/components/navigation/tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
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';
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;
};
42 changes: 42 additions & 0 deletions src/tedi/components/navigation/tabs/tabs-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* 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();
tabs[newIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' });
return tabs[newIndex];
}

return null;
};
Loading
Loading