diff --git a/src/tabs/__tests__/tabs.test.tsx b/src/tabs/__tests__/tabs.test.tsx index 907a1b2e7e..0f23ee05a8 100644 --- a/src/tabs/__tests__/tabs.test.tsx +++ b/src/tabs/__tests__/tabs.test.tsx @@ -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( {}} />); + expect(container.querySelectorAll(`.${styles['tabs-active-indicator']}`)).toHaveLength(1); + }); + + test('positions the indicator over the active tab', () => { + const { container } = renderTabs( {}} />); + 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 setActiveTabId(e.detail.activeTabId)} />; + } + const { container, wrapper } = renderTabs(); + 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( {}} />); + 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 setActiveTabId(e.detail.activeTabId)} />; + } + const { container, wrapper } = renderTabs(); + 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'); + }); +}); diff --git a/src/tabs/tab-header-bar.scss b/src/tabs/tab-header-bar.scss index 1e569ec770..9d19e0c520 100644 --- a/src/tabs/tab-header-bar.scss +++ b/src/tabs/tab-header-bar.scss @@ -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; @@ -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 @@ -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 { @@ -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 { diff --git a/src/tabs/tab-header-bar.tsx b/src/tabs/tab-header-bar.tsx index c7e2de6d41..f51be3c14b 100644 --- a/src/tabs/tab-header-bar.tsx +++ b/src/tabs/tab-header-bar.tsx @@ -108,6 +108,9 @@ export function TabHeaderBar({ const isVisualRefresh = useVisualRefresh(); + const activeIndicatorRef = useRef(null); + const indicatorMeasuredRef = useRef(false); + const containerObjectRef = useRef(null); const documentRef = useRef(typeof document !== 'undefined' ? document : null); const documentRefCallback = (node: null | Element) => (documentRef.current = node?.ownerDocument ?? document); @@ -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(`.${styles['tabs-tab-header-container']}`) ?? + activeTabElement.closest(`.${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)); @@ -365,6 +408,7 @@ export function TabHeaderBar({ onBlur={onBlur} > {tabs.map(renderTabHeader)} +