Skip to content
Draft
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
54 changes: 54 additions & 0 deletions src/tabs/__tests__/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1412,3 +1412,57 @@ describe('Tabs', () => {
expect(wrapper.findActions()!.getElement()).toHaveTextContent('Actions content');
});
});

describe('active-tab indicator', () => {
function findIndicator(container: HTMLElement) {
return container.querySelector(`.${styles['tabs-active-indicator']}`) as HTMLElement | null;
}
function indicatorOpacity(container: HTMLElement) {
return findIndicator(container)?.style.getPropertyValue('--awsui-internal-style-tabs-active-indicator-opacity');
}

test('renders a single shared indicator', () => {
const { container } = renderTabs(<Tabs tabs={defaultTabs} activeTabId="first" onChange={() => {}} />);
expect(container.querySelectorAll(`.${styles['tabs-active-indicator']}`)).toHaveLength(1);
});

test('positions the indicator over the active tab', () => {
const { container } = renderTabs(<Tabs tabs={defaultTabs} activeTabId="first" onChange={() => {}} />);
const indicator = findIndicator(container)!;
expect(indicator).not.toBeNull();
// The measuring effect sets the offset/scale custom properties and shows the indicator.
expect(indicator.style.getPropertyValue('--awsui-internal-style-tabs-active-indicator-offset')).not.toBe('');
expect(indicator.style.getPropertyValue('--awsui-internal-style-tabs-active-indicator-scale')).not.toBe('');
expect(indicatorOpacity(container)).toBe('1');
});

test('repositions the indicator when the active tab changes', () => {
function Wrapper() {
const [activeTabId, setActiveTabId] = useState('first');
return <Tabs tabs={defaultTabs} activeTabId={activeTabId} onChange={e => setActiveTabId(e.detail.activeTabId)} />;
}
const { container, wrapper } = renderTabs(<Wrapper />);
wrapper.findTabLinkById('second')!.click();
expect(indicatorOpacity(container)).toBe('1');
});

test('hides the indicator when the active tab is disabled', () => {
// 'third' is disabled in defaultTabs.
const { container } = renderTabs(<Tabs tabs={defaultTabs} activeTabId="third" onChange={() => {}} />);
expect(indicatorOpacity(container)).toBe('0');
});

test('falls back to the active tab element when no header container is present', () => {
function Wrapper() {
const [activeTabId, setActiveTabId] = useState('first');
return <Tabs tabs={defaultTabs} activeTabId={activeTabId} onChange={e => setActiveTabId(e.detail.activeTabId)} />;
}
const { container, wrapper } = renderTabs(<Wrapper />);
container
.querySelectorAll(`.${styles['tabs-tab-header-container']}`)
.forEach(element => element.classList.remove(styles['tabs-tab-header-container']));

wrapper.findTabLinkById('second')!.click();
expect(indicatorOpacity(container)).toBe('1');
});
});
77 changes: 37 additions & 40 deletions src/tabs/tab-header-bar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@use '../internal/styles//foundation' as foundation;
@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;
@use '../internal/generated/custom-css-properties/index.scss' as custom-props;
@use '../internal/styles/utils/theming' as theming;

$separator-color: awsui.$color-border-tabs-divider;
$label-horizontal-spacing: awsui.$space-xs;
Expand Down Expand Up @@ -125,45 +126,45 @@ $label-horizontal-spacing: awsui.$space-xs;
}
}

.tabs-tab-header-container:not(.tabs-tab-disabled) {
// This is the underline for the currently selected tab.
&:after {
content: '';
position: absolute;
inset-inline-start: 0;
inline-size: calc(100% - 1px);
inset-block-end: calc(-1 * #{awsui.$border-divider-section-width});
block-size: var(#{custom-props.$styleTabsActiveIndicatorWidth}, awsui.$border-active-width);
border-start-start-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
border-start-end-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
border-end-start-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
border-end-end-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
background: var(#{custom-props.$styleTabsActiveIndicatorColor}, awsui.$color-border-tabs-underline);
opacity: 0;
}

&.refresh {
&:after {
@include styles.with-motion {
transition: opacity awsui.$motion-duration-refresh-only-medium awsui.$motion-easing-refresh-only-c;
}
.tabs-active-indicator {
position: absolute;
inset-block-end: calc(-1 * #{awsui.$border-divider-section-width});
inset-inline-start: 0;
inline-size: 1px;
block-size: var(#{custom-props.$styleTabsActiveIndicatorWidth}, awsui.$border-active-width);
border-start-start-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
border-start-end-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
border-end-start-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
border-end-end-radius: var(
#{custom-props.$styleTabsActiveIndicatorBorderRadius},
awsui.$border-radius-tabs-focus-ring
);
background: var(#{custom-props.$styleTabsActiveIndicatorColor}, awsui.$color-border-tabs-underline);
transform-origin: left center;
transform: translateX(var(--awsui-internal-style-tabs-active-indicator-offset, 0))
scaleX(var(--awsui-internal-style-tabs-active-indicator-scale, 0));
opacity: var(--awsui-internal-style-tabs-active-indicator-opacity, 0);
pointer-events: none;

@include theming.expressive-motion-only {
@include styles.with-motion {
transition:
transform awsui.$motion-duration-complex awsui.$motion-easing-refresh-only-c,
opacity awsui.$motion-duration-complex awsui.$motion-easing-refresh-only-c;
}
}
}

.tabs-tab:not(:last-child) {
.tabs-tab:not(:nth-last-child(2)) {
& > .tabs-tab-header-container {
margin-inline-end: calc(-1 * #{awsui.$border-divider-section-width});
// This is the divider for the tab
Expand Down Expand Up @@ -254,8 +255,7 @@ $label-horizontal-spacing: awsui.$space-xs;
}
}

// Remediate focus shadow
.tabs-tab:last-child {
.tabs-tab:nth-last-child(2) {
margin-inline-end: 1px;
scroll-margin-inline-end: 1px;
& > .tabs-tab-header-container {
Expand All @@ -278,9 +278,6 @@ $label-horizontal-spacing: awsui.$space-xs;
color: var(#{custom-props.$styleColorActive}, awsui.$color-text-accent);
border-color: var(#{custom-props.$styleBorderColorActive}, transparent);
background-color: var(#{custom-props.$styleBackgroundActive}, transparent);
&:after {
opacity: 1;
}
}

.tabs-header-with-divider {
Expand Down
44 changes: 44 additions & 0 deletions src/tabs/tab-header-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export function TabHeaderBar({

const isVisualRefresh = useVisualRefresh();

const activeIndicatorRef = useRef<HTMLSpanElement>(null);
const indicatorMeasuredRef = useRef(false);

const containerObjectRef = useRef<HTMLDivElement>(null);
const documentRef = useRef<null | Document>(typeof document !== 'undefined' ? document : null);
const documentRefCallback = (node: null | Element) => (documentRef.current = node?.ownerDocument ?? document);
Expand Down Expand Up @@ -196,6 +199,46 @@ export function TabHeaderBar({
}
}, [activeTabId]);

useEffect(() => {
const indicator = activeIndicatorRef.current;
const list = headerBarRef.current;
/* istanbul ignore if: refs are always attached when the effect runs */
if (!indicator || !list) {
return;
}
const activeTabElement = activeTabId ? tabRefs.current.get(activeTabId) : undefined;
if (!activeTabElement || (activeTabId && tabs.find(tab => tab.id === activeTabId)?.disabled)) {
indicator.style.setProperty('--awsui-internal-style-tabs-active-indicator-opacity', '0');
return;
}

const headerContainer =
activeTabElement.querySelector<HTMLElement>(`.${styles['tabs-tab-header-container']}`) ??
activeTabElement.closest<HTMLElement>(`.${styles['tabs-tab-header-container']}`) ??
activeTabElement;

const offset = headerContainer.offsetLeft;
const width = headerContainer.offsetWidth - 1;

const apply = () => {
indicator.style.setProperty('--awsui-internal-style-tabs-active-indicator-offset', `${offset}px`);
indicator.style.setProperty('--awsui-internal-style-tabs-active-indicator-scale', `${width}`);
indicator.style.setProperty('--awsui-internal-style-tabs-active-indicator-opacity', '1');
};

if (!indicatorMeasuredRef.current) {
const previousTransition = indicator.style.transition;
indicator.style.transition = 'none';
apply();

void indicator.offsetWidth;
indicator.style.transition = previousTransition;
indicatorMeasuredRef.current = true;
} else {
apply();
}
}, [activeTabId, widthChange, tabs]);

const onScroll = () => {
if (headerBarRef.current) {
setInlineStartOverflow(hasInlineStartOverflow(headerBarRef.current));
Expand Down Expand Up @@ -365,6 +408,7 @@ export function TabHeaderBar({
onBlur={onBlur}
>
{tabs.map(renderTabHeader)}
<span className={styles['tabs-active-indicator']} ref={activeIndicatorRef} aria-hidden="true" />
</TabList>
</SingleTabStopNavigationProvider>
{horizontalOverflow && (
Expand Down
Loading