From 8335994024e33b2905f40aa1820eedbfa51ebe9e Mon Sep 17 00:00:00 2001 From: Yu Yu Date: Mon, 14 Sep 2026 15:55:35 +0200 Subject: [PATCH 1/4] feat: Add travelling active-tab indicator for Tabs in One Theme --- src/tabs/tab-header-bar.scss | 47 ++++++++++++++++++++++++++++++++++++ src/tabs/tab-header-bar.tsx | 46 ++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/tabs/tab-header-bar.scss b/src/tabs/tab-header-bar.scss index 1e569ec770..44e7db8975 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; @@ -161,6 +162,52 @@ $label-horizontal-spacing: awsui.$space-xs; } } } + + @include theming.expressive-motion-only { + &:after { + display: none; + } + } +} + +.tabs-active-indicator { + @include theming.expressive-motion-only { + 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) { diff --git a/src/tabs/tab-header-bar.tsx b/src/tabs/tab-header-bar.tsx index c7e2de6d41..875afd39d0 100644 --- a/src/tabs/tab-header-bar.tsx +++ b/src/tabs/tab-header-bar.tsx @@ -20,7 +20,7 @@ import { getAllFocusables } from '../internal/components/focus-lock/utils'; import { hasModifierKeys, isPlainLeftClick } from '../internal/events'; import useHiddenDescription from '../internal/hooks/use-hidden-description'; import { usePrevious } from '../internal/hooks/use-previous'; -import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useOneTheme, useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { KeyCode } from '../internal/keycode'; import { circleIndex } from '../internal/utils/circle-index'; import { isHTMLElement } from '../internal/utils/dom'; @@ -107,6 +107,10 @@ export function TabHeaderBar({ const i18n = useInternalI18n('tabs'); const isVisualRefresh = useVisualRefresh(); + const isOneTheme = useOneTheme(); + + const activeIndicatorRef = useRef(null); + const indicatorMeasuredRef = useRef(false); const containerObjectRef = useRef(null); const documentRef = useRef(typeof document !== 'undefined' ? document : null); @@ -196,6 +200,43 @@ export function TabHeaderBar({ } }, [activeTabId]); + useEffect(() => { + if (!isOneTheme) { + return; + } + const indicator = activeIndicatorRef.current; + const list = headerBarRef.current; + 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 offset = activeTabElement.offsetLeft; + const width = activeTabElement.offsetWidth; + + 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(); + } + }, [isOneTheme, activeTabId, widthChange, tabs]); + const onScroll = () => { if (headerBarRef.current) { setInlineStartOverflow(hasInlineStartOverflow(headerBarRef.current)); @@ -364,6 +405,9 @@ export function TabHeaderBar({ onFocus={onFocus} onBlur={onBlur} > + {isOneTheme && ( +